diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3e12b4e --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Copy to .env and fill in the secrets. .env is gitignored. + +# Postgres superuser password shared by the per-service database containers. +POSTGRES_PASSWORD=lovenet + +# HS512 signing key shared by all services (Identity signs, everyone validates). +# Use a long random string (64+ chars). +JWT_KEY=REPLACE_WITH_A_LONG_RANDOM_SECRET_OF_AT_LEAST_64_CHARACTERS________ +JWT_ISSUER=https://localhost:3000/ +JWT_AUDIENCE=https://localhost:3000/ + +# Cloudinary (image storage) — same account the monolith used. +CLOUDINARY_CLOUD_NAME= +CLOUDINARY_KEY= +CLOUDINARY_SECRET= + +# SendGrid (transactional email). +SENDGRID_API_KEY= + +# SPA origin allowed by CORS. +FRONTEND_ORIGIN=http://localhost:3000 diff --git a/Data/LOVE.NET.Data.Common/IDbQueryRunner.cs b/Data/LOVE.NET.Data.Common/IDbQueryRunner.cs deleted file mode 100644 index 8b4b70a..0000000 --- a/Data/LOVE.NET.Data.Common/IDbQueryRunner.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace LOVE.NET.Data.Common -{ - using System; - using System.Threading.Tasks; - - public interface IDbQueryRunner : IDisposable - { - Task RunQueryAsync(string query, params object[] parameters); - } -} diff --git a/Data/LOVE.NET.Data.Common/LOVE.NET.Data.Common.csproj b/Data/LOVE.NET.Data.Common/LOVE.NET.Data.Common.csproj deleted file mode 100644 index 0826740..0000000 --- a/Data/LOVE.NET.Data.Common/LOVE.NET.Data.Common.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net8.0 - latest - - - - ..\..\Rules.ruleset - - - - - - - - runtime; build; native; contentfiles; analyzers - - - - - \ No newline at end of file diff --git a/Data/LOVE.NET.Data.Common/Models/BaseDeletableModel.cs b/Data/LOVE.NET.Data.Common/Models/BaseDeletableModel.cs deleted file mode 100644 index 4694193..0000000 --- a/Data/LOVE.NET.Data.Common/Models/BaseDeletableModel.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace LOVE.NET.Data.Common.Models -{ - using System; - - public abstract class BaseDeletableModel : BaseModel, IDeletableEntity - { - public bool IsDeleted { get; set; } - - public DateTime? DeletedOn { get; set; } - } -} diff --git a/Data/LOVE.NET.Data.Common/Models/BaseModel.cs b/Data/LOVE.NET.Data.Common/Models/BaseModel.cs deleted file mode 100644 index a7e68d7..0000000 --- a/Data/LOVE.NET.Data.Common/Models/BaseModel.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace LOVE.NET.Data.Common.Models -{ - using System; - using System.ComponentModel.DataAnnotations; - - public abstract class BaseModel : IAuditInfo - { - [Key] - public TKey Id { get; set; } - - public DateTime CreatedOn { get; set; } - - public DateTime? ModifiedOn { get; set; } - } -} diff --git a/Data/LOVE.NET.Data.Common/Models/IAuditInfo.cs b/Data/LOVE.NET.Data.Common/Models/IAuditInfo.cs deleted file mode 100644 index e45e245..0000000 --- a/Data/LOVE.NET.Data.Common/Models/IAuditInfo.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace LOVE.NET.Data.Common.Models -{ - using System; - - public interface IAuditInfo - { - DateTime CreatedOn { get; set; } - - DateTime? ModifiedOn { get; set; } - } -} diff --git a/Data/LOVE.NET.Data.Common/Models/IDeletableEntity.cs b/Data/LOVE.NET.Data.Common/Models/IDeletableEntity.cs deleted file mode 100644 index d095fbd..0000000 --- a/Data/LOVE.NET.Data.Common/Models/IDeletableEntity.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace LOVE.NET.Data.Common.Models -{ - using System; - - public interface IDeletableEntity - { - bool IsDeleted { get; set; } - - DateTime? DeletedOn { get; set; } - } -} diff --git a/Data/LOVE.NET.Data.Common/Repositories/IDeletableEntityRepository.cs b/Data/LOVE.NET.Data.Common/Repositories/IDeletableEntityRepository.cs deleted file mode 100644 index 70127ff..0000000 --- a/Data/LOVE.NET.Data.Common/Repositories/IDeletableEntityRepository.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace LOVE.NET.Data.Common.Repositories -{ - using System.Linq; - - using LOVE.NET.Data.Common.Models; - - public interface IDeletableEntityRepository : IRepository - where TEntity : class, IDeletableEntity - { - IQueryable AllWithDeleted(); - - IQueryable AllAsNoTrackingWithDeleted(); - - void HardDelete(TEntity entity); - - void Undelete(TEntity entity); - } -} diff --git a/Data/LOVE.NET.Data.Common/Repositories/IRepository.cs b/Data/LOVE.NET.Data.Common/Repositories/IRepository.cs deleted file mode 100644 index 7d2dd86..0000000 --- a/Data/LOVE.NET.Data.Common/Repositories/IRepository.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace LOVE.NET.Data.Common.Repositories -{ - using System; - using System.Linq; - using System.Threading.Tasks; - - public interface IRepository : IDisposable - where TEntity : class - { - IQueryable All(); - - IQueryable AllAsNoTracking(); - - Task AddAsync(TEntity entity); - - void Update(TEntity entity); - - void Delete(TEntity entity); - - Task SaveChangesAsync(); - } -} diff --git a/Data/LOVE.NET.Data.Models/ApplicationRole.cs b/Data/LOVE.NET.Data.Models/ApplicationRole.cs deleted file mode 100644 index 7329151..0000000 --- a/Data/LOVE.NET.Data.Models/ApplicationRole.cs +++ /dev/null @@ -1,31 +0,0 @@ -// ReSharper disable VirtualMemberCallInConstructor -namespace LOVE.NET.Data.Models -{ - using System; - - using LOVE.NET.Data.Common.Models; - - using Microsoft.AspNetCore.Identity; - - public class ApplicationRole : IdentityRole, IAuditInfo, IDeletableEntity - { - public ApplicationRole() - : this(null) - { - } - - public ApplicationRole(string name) - : base(name) - { - this.Id = Guid.NewGuid().ToString(); - } - - public DateTime CreatedOn { get; set; } - - public DateTime? ModifiedOn { get; set; } - - public bool IsDeleted { get; set; } - - public DateTime? DeletedOn { get; set; } - } -} diff --git a/Data/LOVE.NET.Data.Models/ApplicationUser.cs b/Data/LOVE.NET.Data.Models/ApplicationUser.cs deleted file mode 100644 index 0dc29ca..0000000 --- a/Data/LOVE.NET.Data.Models/ApplicationUser.cs +++ /dev/null @@ -1,77 +0,0 @@ -// ReSharper disable VirtualMemberCallInConstructor -namespace LOVE.NET.Data.Models -{ - using System; - using System.Collections.Generic; - using System.ComponentModel.DataAnnotations; - using System.ComponentModel.DataAnnotations.Schema; - - using LOVE.NET.Data.Common.Models; - - using Microsoft.AspNetCore.Identity; - - public class ApplicationUser : IdentityUser, IAuditInfo, IDeletableEntity - { - public ApplicationUser() - { - this.Id = Guid.NewGuid().ToString(); - this.Roles = new HashSet>(); - this.Claims = new HashSet>(); - this.Logins = new HashSet>(); - this.LikesSent = new HashSet(); - this.LikesReceived = new HashSet(); - this.Images = new HashSet(); - this.RefreshTokens = new HashSet(); - this.Messages = new HashSet(); - } - - // Audit info - public DateTime CreatedOn { get; set; } - - public DateTime? ModifiedOn { get; set; } - - // Deletable entity - public bool IsDeleted { get; set; } - - public DateTime? DeletedOn { get; set; } - - [Required] - [MaxLength(255)] - public string Bio { get; set; } - - public DateTime Birthdate { get; set; } - - public virtual ICollection> Roles { get; set; } - - public virtual ICollection> Claims { get; set; } - - public virtual ICollection> Logins { get; set; } - - [InverseProperty(nameof(Like.User))] - public virtual ICollection LikesSent { get; set; } - - [InverseProperty(nameof(Like.LikedUser))] - public virtual ICollection LikesReceived { get; set; } - - public virtual ICollection Images { get; set; } - - public int GenderId { get; set; } - - [Required] - public virtual Gender Gender { get; set; } - - public int CountryId { get; set; } - - [Required] - public virtual Country Country { get; set; } - - public int CityId { get; set; } - - [Required] - public virtual City City { get; set; } - - public virtual ICollection RefreshTokens { get; set; } - - public virtual ICollection Messages { get; set; } - } -} diff --git a/Data/LOVE.NET.Data.Models/Chatroom.cs b/Data/LOVE.NET.Data.Models/Chatroom.cs deleted file mode 100644 index 6cf93f4..0000000 --- a/Data/LOVE.NET.Data.Models/Chatroom.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace LOVE.NET.Data.Models -{ - using System; - - using LOVE.NET.Data.Common.Models; - - public class Chatroom : BaseModel - { - public Chatroom() - { - this.Id = Guid.NewGuid().ToString(); - } - - public string Title { get; set; } - - public string Url { get; set; } - } -} diff --git a/Data/LOVE.NET.Data.Models/City.cs b/Data/LOVE.NET.Data.Models/City.cs deleted file mode 100644 index d7330e3..0000000 --- a/Data/LOVE.NET.Data.Models/City.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace LOVE.NET.Data.Models -{ - using System.Collections.Generic; - using System.ComponentModel.DataAnnotations; - - using LOVE.NET.Data.Common.Models; - - public class City : BaseModel - { - public City() - { - this.Users = new HashSet(); - } - - [Required] - [MaxLength(255)] - public string Name { get; set; } - - [Required] - [MaxLength(255)] - public string NameAscii { get; set; } - - public double Latitude { get; set; } - - public double Longitude { get; set; } - - public int CountryId { get; set; } - - public virtual Country Country { get; set; } - - public virtual ICollection Users { get; set; } - } -} diff --git a/Data/LOVE.NET.Data.Models/Country.cs b/Data/LOVE.NET.Data.Models/Country.cs deleted file mode 100644 index 04243b8..0000000 --- a/Data/LOVE.NET.Data.Models/Country.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace LOVE.NET.Data.Models -{ - using System.Collections.Generic; - using System.ComponentModel.DataAnnotations; - - using LOVE.NET.Data.Common.Models; - - public class Country : BaseModel - { - public Country() - { - this.Cities = new HashSet(); - this.Users = new HashSet(); - } - - [Required] - [MaxLength(100)] - public string Name { get; set; } - - public virtual ICollection Cities { get; set; } - - public virtual ICollection Users { get; set; } - } -} diff --git a/Data/LOVE.NET.Data.Models/Gender.cs b/Data/LOVE.NET.Data.Models/Gender.cs deleted file mode 100644 index 74f204b..0000000 --- a/Data/LOVE.NET.Data.Models/Gender.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace LOVE.NET.Data.Models -{ - using LOVE.NET.Data.Common.Models; - - public class Gender : BaseModel - { - public string Name { get; set; } - } -} diff --git a/Data/LOVE.NET.Data.Models/Image.cs b/Data/LOVE.NET.Data.Models/Image.cs deleted file mode 100644 index 330c637..0000000 --- a/Data/LOVE.NET.Data.Models/Image.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace LOVE.NET.Data.Models -{ - using System.ComponentModel.DataAnnotations; - - using LOVE.NET.Data.Common.Models; - - public class Image : BaseDeletableModel - { - [Required] - public string Url { get; set; } - - public bool IsProfilePicture { get; set; } - - public virtual ApplicationUser User { get; set; } - } -} diff --git a/Data/LOVE.NET.Data.Models/LOVE.NET.Data.Models.csproj b/Data/LOVE.NET.Data.Models/LOVE.NET.Data.Models.csproj deleted file mode 100644 index 6965e05..0000000 --- a/Data/LOVE.NET.Data.Models/LOVE.NET.Data.Models.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - - net8.0 - latest - - - - ..\..\Rules.ruleset - - - - - - - - - runtime; build; native; contentfiles; analyzers - - - - - - - - - \ No newline at end of file diff --git a/Data/LOVE.NET.Data.Models/Like.cs b/Data/LOVE.NET.Data.Models/Like.cs deleted file mode 100644 index 2fa220f..0000000 --- a/Data/LOVE.NET.Data.Models/Like.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace LOVE.NET.Data.Models -{ - using System; - using System.ComponentModel.DataAnnotations.Schema; - - using LOVE.NET.Data.Common.Models; - - public class Like : BaseDeletableModel - { - public Like() - { - this.Id = Guid.NewGuid().ToString(); - } - - public string UserId { get; set; } - - [ForeignKey(nameof(UserId))] - public virtual ApplicationUser User { get; set; } - - public string LikedUserId { get; set; } - - [ForeignKey(nameof(LikedUserId))] - public virtual ApplicationUser LikedUser { get; set; } - } -} diff --git a/Data/LOVE.NET.Data.Models/Message.cs b/Data/LOVE.NET.Data.Models/Message.cs deleted file mode 100644 index d780159..0000000 --- a/Data/LOVE.NET.Data.Models/Message.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace LOVE.NET.Data.Models -{ - using System; - - using LOVE.NET.Data.Common.Models; - - public class Message : BaseModel - { - public Message() - { - this.Id = Guid.NewGuid().ToString(); - } - - public string RoomId { get; set; } - - public string UserId { get; set; } - - public ApplicationUser User { get; set; } - - public string? Text { get; set; } - - public string? ImageUrl { get; set; } - } -} diff --git a/Data/LOVE.NET.Data.Models/RefreshToken.cs b/Data/LOVE.NET.Data.Models/RefreshToken.cs deleted file mode 100644 index 329431e..0000000 --- a/Data/LOVE.NET.Data.Models/RefreshToken.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace LOVE.NET.Data.Models -{ - using System; - using System.Text.Json.Serialization; - - public class RefreshToken - { - [JsonIgnore] - public int Id { get; set; } - - public string Token { get; set; } - - public DateTime Expires { get; set; } - - public bool IsExpired => DateTime.UtcNow >= this.Expires; - - public DateTime? Revoked { get; set; } - - public bool IsActive => this.Revoked == null && !this.IsExpired; - - public DateTime CreatedOn { get; set; } - - public string ReplacedByToken { get; set; } - } -} diff --git a/Data/LOVE.NET.Data.Models/UserConnection.cs b/Data/LOVE.NET.Data.Models/UserConnection.cs deleted file mode 100644 index 32d7639..0000000 --- a/Data/LOVE.NET.Data.Models/UserConnection.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace LOVE.NET.Data.Models -{ - public class UserConnection - { - public string UserId { get; set; } - - public string RoomId { get; set; } - - public string ProfilePictureUrl { get; set; } - - public string UserName { get; set; } - } -} diff --git a/Data/LOVE.NET.Data/ApplicationDbContext.cs b/Data/LOVE.NET.Data/ApplicationDbContext.cs deleted file mode 100644 index 5131075..0000000 --- a/Data/LOVE.NET.Data/ApplicationDbContext.cs +++ /dev/null @@ -1,123 +0,0 @@ -namespace LOVE.NET.Data -{ - using System; - using System.Linq; - using System.Reflection; - using System.Threading; - using System.Threading.Tasks; - - using LOVE.NET.Data.Common.Models; - using LOVE.NET.Data.Models; - - using Microsoft.AspNetCore.Identity.EntityFrameworkCore; - using Microsoft.EntityFrameworkCore; - - public class ApplicationDbContext : IdentityDbContext - { - private static readonly MethodInfo SetIsDeletedQueryFilterMethod = - typeof(ApplicationDbContext).GetMethod( - nameof(SetIsDeletedQueryFilter), - BindingFlags.NonPublic | BindingFlags.Static); - - public ApplicationDbContext(DbContextOptions options) - : base(options) - { - } - - public DbSet Countries { get; set; } - - public DbSet Images { get; set; } - - public DbSet Likes { get; set; } - - public DbSet Cities { get; set; } - - public DbSet Genders { get; set; } - - public DbSet RefreshTokens { get; set; } - - public DbSet Messages { get; set; } - - public DbSet Chatrooms { get; set; } - - public override int SaveChanges() => this.SaveChanges(true); - - public override int SaveChanges(bool acceptAllChangesOnSuccess) - { - this.ApplyAuditInfoRules(); - return base.SaveChanges(acceptAllChangesOnSuccess); - } - - public override Task SaveChangesAsync(CancellationToken cancellationToken = default) => - this.SaveChangesAsync(true, cancellationToken); - - public override Task SaveChangesAsync( - bool acceptAllChangesOnSuccess, - CancellationToken cancellationToken = default) - { - this.ApplyAuditInfoRules(); - return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); - } - - protected override void OnModelCreating(ModelBuilder builder) - { - // Needed for Identity models configuration - base.OnModelCreating(builder); - - this.ConfigureUserIdentityRelations(builder); - - EntityIndexesConfiguration.Configure(builder); - - var entityTypes = builder.Model.GetEntityTypes().ToList(); - - // Set global query filter for not deleted entities only - var deletableEntityTypes = entityTypes - .Where(et => et.ClrType != null && typeof(IDeletableEntity).IsAssignableFrom(et.ClrType)); - foreach (var deletableEntityType in deletableEntityTypes) - { - var method = SetIsDeletedQueryFilterMethod.MakeGenericMethod(deletableEntityType.ClrType); - method.Invoke(null, new object[] { builder }); - } - - // Disable cascade delete - var foreignKeys = entityTypes - .SelectMany(e => e.GetForeignKeys().Where(f => f.DeleteBehavior == DeleteBehavior.Cascade)); - foreach (var foreignKey in foreignKeys) - { - foreignKey.DeleteBehavior = DeleteBehavior.Restrict; - } - } - - private static void SetIsDeletedQueryFilter(ModelBuilder builder) - where T : class, IDeletableEntity - { - builder.Entity().HasQueryFilter(e => !e.IsDeleted); - } - - // Applies configurations - private void ConfigureUserIdentityRelations(ModelBuilder builder) - => builder.ApplyConfigurationsFromAssembly(this.GetType().Assembly); - - private void ApplyAuditInfoRules() - { - var changedEntries = this.ChangeTracker - .Entries() - .Where(e => - e.Entity is IAuditInfo && - (e.State == EntityState.Added || e.State == EntityState.Modified)); - - foreach (var entry in changedEntries) - { - var entity = (IAuditInfo)entry.Entity; - if (entry.State == EntityState.Added && entity.CreatedOn == default) - { - entity.CreatedOn = DateTime.UtcNow; - } - else - { - entity.ModifiedOn = DateTime.UtcNow; - } - } - } - } -} diff --git a/Data/LOVE.NET.Data/Configurations/ApplicationUserConfiguration.cs b/Data/LOVE.NET.Data/Configurations/ApplicationUserConfiguration.cs deleted file mode 100644 index 54125b3..0000000 --- a/Data/LOVE.NET.Data/Configurations/ApplicationUserConfiguration.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace LOVE.NET.Data.Configurations -{ - using LOVE.NET.Data.Models; - - using Microsoft.EntityFrameworkCore; - using Microsoft.EntityFrameworkCore.Metadata.Builders; - - public class ApplicationUserConfiguration : IEntityTypeConfiguration - { - public void Configure(EntityTypeBuilder appUser) - { - appUser - .HasMany(e => e.Claims) - .WithOne() - .HasForeignKey(e => e.UserId) - .IsRequired() - .OnDelete(DeleteBehavior.Restrict); - - appUser - .HasMany(e => e.Logins) - .WithOne() - .HasForeignKey(e => e.UserId) - .IsRequired() - .OnDelete(DeleteBehavior.Restrict); - - appUser - .HasMany(e => e.Roles) - .WithOne() - .HasForeignKey(e => e.UserId) - .IsRequired() - .OnDelete(DeleteBehavior.Restrict); - } - } -} diff --git a/Data/LOVE.NET.Data/DbQueryRunner.cs b/Data/LOVE.NET.Data/DbQueryRunner.cs deleted file mode 100644 index 662d79b..0000000 --- a/Data/LOVE.NET.Data/DbQueryRunner.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace LOVE.NET.Data -{ - using System; - using System.Threading.Tasks; - - using LOVE.NET.Data.Common; - - using Microsoft.EntityFrameworkCore; - - public class DbQueryRunner : IDbQueryRunner - { - public DbQueryRunner(ApplicationDbContext context) - { - this.Context = context ?? throw new ArgumentNullException(nameof(context)); - } - - public ApplicationDbContext Context { get; set; } - - public Task RunQueryAsync(string query, params object[] parameters) - { - return this.Context.Database.ExecuteSqlRawAsync(query, parameters); - } - - public void Dispose() - { - this.Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - this.Context?.Dispose(); - } - } - } -} diff --git a/Data/LOVE.NET.Data/DesignTimeDbContextFactory.cs b/Data/LOVE.NET.Data/DesignTimeDbContextFactory.cs deleted file mode 100644 index bb5bd9d..0000000 --- a/Data/LOVE.NET.Data/DesignTimeDbContextFactory.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace LOVE.NET.Data -{ - using System.IO; - - using Microsoft.EntityFrameworkCore; - using Microsoft.EntityFrameworkCore.Design; - using Microsoft.Extensions.Configuration; - - public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory - { - public ApplicationDbContext CreateDbContext(string[] args) - { - var configuration = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) - .Build(); - - var builder = new DbContextOptionsBuilder(); - var connectionString = configuration.GetConnectionString("DefaultConnection"); - builder.UseSqlServer(connectionString); - - return new ApplicationDbContext(builder.Options); - } - } -} diff --git a/Data/LOVE.NET.Data/EntityIndexesConfiguration.cs b/Data/LOVE.NET.Data/EntityIndexesConfiguration.cs deleted file mode 100644 index 8fd99d2..0000000 --- a/Data/LOVE.NET.Data/EntityIndexesConfiguration.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace LOVE.NET.Data -{ - using System.Linq; - - using LOVE.NET.Data.Common.Models; - - using Microsoft.EntityFrameworkCore; - - internal static class EntityIndexesConfiguration - { - public static void Configure(ModelBuilder modelBuilder) - { - // IDeletableEntity.IsDeleted index - var deletableEntityTypes = modelBuilder.Model - .GetEntityTypes() - .Where(et => et.ClrType != null && typeof(IDeletableEntity).IsAssignableFrom(et.ClrType)); - foreach (var deletableEntityType in deletableEntityTypes) - { - modelBuilder.Entity(deletableEntityType.ClrType).HasIndex(nameof(IDeletableEntity.IsDeleted)); - } - } - } -} diff --git a/Data/LOVE.NET.Data/IdentityOptionsProvider.cs b/Data/LOVE.NET.Data/IdentityOptionsProvider.cs deleted file mode 100644 index dbe75db..0000000 --- a/Data/LOVE.NET.Data/IdentityOptionsProvider.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace LOVE.NET.Data -{ - using Microsoft.AspNetCore.Identity; - - public static class IdentityOptionsProvider - { - public static void GetIdentityOptions(IdentityOptions options) - { - options.Password.RequireDigit = false; - options.Password.RequiredLength = 5; - options.Password.RequireUppercase = false; - options.Password.RequireNonAlphanumeric = false; - options.Password.RequiredUniqueChars = 0; - options.User.AllowedUserNameCharacters += " "; - options.SignIn.RequireConfirmedEmail = false; // true - } - } -} diff --git a/Data/LOVE.NET.Data/LOVE.NET.Data.csproj b/Data/LOVE.NET.Data/LOVE.NET.Data.csproj deleted file mode 100644 index ab84901..0000000 --- a/Data/LOVE.NET.Data/LOVE.NET.Data.csproj +++ /dev/null @@ -1,61 +0,0 @@ - - - - net8.0 - true - latest - - - - ..\..\Rules.ruleset - - - - - - - - - PreserveNewest - Always - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - all - runtime; build; native; contentfiles; analyzers - - - - - runtime; build; native; contentfiles; analyzers - - - - - - - - - - - - Always - - - Always - - - Always - - - - \ No newline at end of file diff --git a/Data/LOVE.NET.Data/Migrations/20220607060115_InitialCreate.Designer.cs b/Data/LOVE.NET.Data/Migrations/20220607060115_InitialCreate.Designer.cs deleted file mode 100644 index fe77370..0000000 --- a/Data/LOVE.NET.Data/Migrations/20220607060115_InitialCreate.Designer.cs +++ /dev/null @@ -1,348 +0,0 @@ -// -using System; -using LOVE.NET.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20220607060115_InitialCreate")] - partial class InitialCreate - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "6.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Setting", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Claims") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Logins") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Roles") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Navigation("Claims"); - - b.Navigation("Logins"); - - b.Navigation("Roles"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20220607060115_InitialCreate.cs b/Data/LOVE.NET.Data/Migrations/20220607060115_InitialCreate.cs deleted file mode 100644 index 4c863f5..0000000 --- a/Data/LOVE.NET.Data/Migrations/20220607060115_InitialCreate.cs +++ /dev/null @@ -1,266 +0,0 @@ -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - using System; - - using Microsoft.EntityFrameworkCore.Migrations; - - public partial class InitialCreate : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "AspNetRoles", - columns: table => new - { - Id = table.Column(type: "nvarchar(450)", nullable: false), - CreatedOn = table.Column(type: "datetime2", nullable: false), - ModifiedOn = table.Column(type: "datetime2", nullable: true), - IsDeleted = table.Column(type: "bit", nullable: false), - DeletedOn = table.Column(type: "datetime2", nullable: true), - Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetRoles", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "AspNetUsers", - columns: table => new - { - Id = table.Column(type: "nvarchar(450)", nullable: false), - CreatedOn = table.Column(type: "datetime2", nullable: false), - ModifiedOn = table.Column(type: "datetime2", nullable: true), - IsDeleted = table.Column(type: "bit", nullable: false), - DeletedOn = table.Column(type: "datetime2", nullable: true), - UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - EmailConfirmed = table.Column(type: "bit", nullable: false), - PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), - SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), - ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), - PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), - PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), - TwoFactorEnabled = table.Column(type: "bit", nullable: false), - LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), - LockoutEnabled = table.Column(type: "bit", nullable: false), - AccessFailedCount = table.Column(type: "int", nullable: false), - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUsers", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Settings", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Name = table.Column(type: "nvarchar(max)", nullable: true), - Value = table.Column(type: "nvarchar(max)", nullable: true), - CreatedOn = table.Column(type: "datetime2", nullable: false), - ModifiedOn = table.Column(type: "datetime2", nullable: true), - IsDeleted = table.Column(type: "bit", nullable: false), - DeletedOn = table.Column(type: "datetime2", nullable: true), - }, - constraints: table => - { - table.PrimaryKey("PK_Settings", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "AspNetRoleClaims", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - RoleId = table.Column(type: "nvarchar(450)", nullable: false), - ClaimType = table.Column(type: "nvarchar(max)", nullable: true), - ClaimValue = table.Column(type: "nvarchar(max)", nullable: true), - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); - table.ForeignKey( - name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", - column: x => x.RoleId, - principalTable: "AspNetRoles", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserClaims", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - UserId = table.Column(type: "nvarchar(450)", nullable: false), - ClaimType = table.Column(type: "nvarchar(max)", nullable: true), - ClaimValue = table.Column(type: "nvarchar(max)", nullable: true), - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); - table.ForeignKey( - name: "FK_AspNetUserClaims_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserLogins", - columns: table => new - { - LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), - ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), - ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), - UserId = table.Column(type: "nvarchar(450)", nullable: false), - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); - table.ForeignKey( - name: "FK_AspNetUserLogins_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserRoles", - columns: table => new - { - UserId = table.Column(type: "nvarchar(450)", nullable: false), - RoleId = table.Column(type: "nvarchar(450)", nullable: false), - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); - table.ForeignKey( - name: "FK_AspNetUserRoles_AspNetRoles_RoleId", - column: x => x.RoleId, - principalTable: "AspNetRoles", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_AspNetUserRoles_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserTokens", - columns: table => new - { - UserId = table.Column(type: "nvarchar(450)", nullable: false), - LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), - Name = table.Column(type: "nvarchar(450)", nullable: false), - Value = table.Column(type: "nvarchar(max)", nullable: true), - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); - table.ForeignKey( - name: "FK_AspNetUserTokens_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateIndex( - name: "IX_AspNetRoleClaims_RoleId", - table: "AspNetRoleClaims", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetRoles_IsDeleted", - table: "AspNetRoles", - column: "IsDeleted"); - - migrationBuilder.CreateIndex( - name: "RoleNameIndex", - table: "AspNetRoles", - column: "NormalizedName", - unique: true, - filter: "[NormalizedName] IS NOT NULL"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserClaims_UserId", - table: "AspNetUserClaims", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserLogins_UserId", - table: "AspNetUserLogins", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserRoles_RoleId", - table: "AspNetUserRoles", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "EmailIndex", - table: "AspNetUsers", - column: "NormalizedEmail"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUsers_IsDeleted", - table: "AspNetUsers", - column: "IsDeleted"); - - migrationBuilder.CreateIndex( - name: "UserNameIndex", - table: "AspNetUsers", - column: "NormalizedUserName", - unique: true, - filter: "[NormalizedUserName] IS NOT NULL"); - - migrationBuilder.CreateIndex( - name: "IX_Settings_IsDeleted", - table: "Settings", - column: "IsDeleted"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "AspNetRoleClaims"); - - migrationBuilder.DropTable( - name: "AspNetUserClaims"); - - migrationBuilder.DropTable( - name: "AspNetUserLogins"); - - migrationBuilder.DropTable( - name: "AspNetUserRoles"); - - migrationBuilder.DropTable( - name: "AspNetUserTokens"); - - migrationBuilder.DropTable( - name: "Settings"); - - migrationBuilder.DropTable( - name: "AspNetRoles"); - - migrationBuilder.DropTable( - name: "AspNetUsers"); - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20220816183517_CountriesAndCities.Designer.cs b/Data/LOVE.NET.Data/Migrations/20220816183517_CountriesAndCities.Designer.cs deleted file mode 100644 index cb1aecc..0000000 --- a/Data/LOVE.NET.Data/Migrations/20220816183517_CountriesAndCities.Designer.cs +++ /dev/null @@ -1,424 +0,0 @@ -// -using System; -using LOVE.NET.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20220816183517_CountriesAndCities")] - partial class CountriesAndCities - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "6.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Latitude") - .HasColumnType("float"); - - b.Property("Longitude") - .HasColumnType("float"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("NameAscii") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("CountryId"); - - b.ToTable("Cities"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.ToTable("Countries"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Setting", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Cities") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Country"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Claims") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Logins") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Roles") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Navigation("Claims"); - - b.Navigation("Logins"); - - b.Navigation("Roles"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Navigation("Cities"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20220816183517_CountriesAndCities.cs b/Data/LOVE.NET.Data/Migrations/20220816183517_CountriesAndCities.cs deleted file mode 100644 index 55ba4d3..0000000 --- a/Data/LOVE.NET.Data/Migrations/20220816183517_CountriesAndCities.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - public partial class CountriesAndCities : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Countries", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Name = table.Column(type: "nvarchar(max)", nullable: true), - CreatedOn = table.Column(type: "datetime2", nullable: false), - ModifiedOn = table.Column(type: "datetime2", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Countries", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Cities", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Name = table.Column(type: "nvarchar(max)", nullable: false), - NameAscii = table.Column(type: "nvarchar(max)", nullable: false), - Latitude = table.Column(type: "float", nullable: false), - Longitude = table.Column(type: "float", nullable: false), - CountryId = table.Column(type: "int", nullable: false), - CreatedOn = table.Column(type: "datetime2", nullable: false), - ModifiedOn = table.Column(type: "datetime2", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Cities", x => x.Id); - table.ForeignKey( - name: "FK_Cities_Countries_CountryId", - column: x => x.CountryId, - principalTable: "Countries", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateIndex( - name: "IX_Cities_CountryId", - table: "Cities", - column: "CountryId"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Cities"); - - migrationBuilder.DropTable( - name: "Countries"); - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20220817180432_DatabaseModelsAndRelations.Designer.cs b/Data/LOVE.NET.Data/Migrations/20220817180432_DatabaseModelsAndRelations.Designer.cs deleted file mode 100644 index bdbc334..0000000 --- a/Data/LOVE.NET.Data/Migrations/20220817180432_DatabaseModelsAndRelations.Designer.cs +++ /dev/null @@ -1,627 +0,0 @@ -// -using System; -using LOVE.NET.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20220817180432_DatabaseModelsAndRelations")] - partial class DatabaseModelsAndRelations - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "6.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); - - modelBuilder.Entity("ApplicationUserMatch", b => - { - b.Property("MatchesId") - .HasColumnType("nvarchar(450)"); - - b.Property("UsersId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("MatchesId", "UsersId"); - - b.HasIndex("UsersId"); - - b.ToTable("ApplicationUserMatch"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("Age") - .HasMaxLength(150) - .HasColumnType("int"); - - b.Property("Bio") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("CityId") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("CityId"); - - b.HasIndex("CountryId"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Latitude") - .HasColumnType("float"); - - b.Property("Longitude") - .HasColumnType("float"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("NameAscii") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.HasKey("Id"); - - b.HasIndex("CountryId"); - - b.ToTable("Cities"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.ToTable("Countries"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Url") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("UserId"); - - b.ToTable("Images"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LikedUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("LikedUserId"); - - b.HasIndex("UserId"); - - b.ToTable("Likes"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Match", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.ToTable("Matches"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Setting", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("ApplicationUserMatch", b => - { - b.HasOne("LOVE.NET.Data.Models.Match", null) - .WithMany() - .HasForeignKey("MatchesId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UsersId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.HasOne("LOVE.NET.Data.Models.City", "City") - .WithMany("Users") - .HasForeignKey("CityId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Users") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("City"); - - b.Navigation("Country"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Cities") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Country"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("Images") - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "LikedUser") - .WithMany("LikesReceived") - .HasForeignKey("LikedUserId"); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("LikesSent") - .HasForeignKey("UserId"); - - b.Navigation("LikedUser"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Claims") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Logins") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Roles") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Navigation("Claims"); - - b.Navigation("Images"); - - b.Navigation("LikesReceived"); - - b.Navigation("LikesSent"); - - b.Navigation("Logins"); - - b.Navigation("Roles"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Navigation("Users"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Navigation("Cities"); - - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20220817180432_DatabaseModelsAndRelations.cs b/Data/LOVE.NET.Data/Migrations/20220817180432_DatabaseModelsAndRelations.cs deleted file mode 100644 index e41314d..0000000 --- a/Data/LOVE.NET.Data/Migrations/20220817180432_DatabaseModelsAndRelations.cs +++ /dev/null @@ -1,296 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - public partial class DatabaseModelsAndRelations : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn( - name: "Name", - table: "Countries", - type: "nvarchar(100)", - maxLength: 100, - nullable: false, - defaultValue: "", - oldClrType: typeof(string), - oldType: "nvarchar(max)", - oldNullable: true); - - migrationBuilder.AlterColumn( - name: "NameAscii", - table: "Cities", - type: "nvarchar(255)", - maxLength: 255, - nullable: false, - oldClrType: typeof(string), - oldType: "nvarchar(max)"); - - migrationBuilder.AlterColumn( - name: "Name", - table: "Cities", - type: "nvarchar(255)", - maxLength: 255, - nullable: false, - oldClrType: typeof(string), - oldType: "nvarchar(max)"); - - migrationBuilder.AddColumn( - name: "Age", - table: "AspNetUsers", - type: "int", - maxLength: 150, - nullable: false, - defaultValue: 0); - - migrationBuilder.AddColumn( - name: "Bio", - table: "AspNetUsers", - type: "nvarchar(255)", - maxLength: 255, - nullable: false, - defaultValue: ""); - - migrationBuilder.AddColumn( - name: "CityId", - table: "AspNetUsers", - type: "int", - nullable: false, - defaultValue: 0); - - migrationBuilder.AddColumn( - name: "CountryId", - table: "AspNetUsers", - type: "int", - nullable: false, - defaultValue: 0); - - migrationBuilder.CreateTable( - name: "Images", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Url = table.Column(type: "nvarchar(max)", nullable: false), - UserId = table.Column(type: "nvarchar(450)", nullable: true), - CreatedOn = table.Column(type: "datetime2", nullable: false), - ModifiedOn = table.Column(type: "datetime2", nullable: true), - IsDeleted = table.Column(type: "bit", nullable: false), - DeletedOn = table.Column(type: "datetime2", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Images", x => x.Id); - table.ForeignKey( - name: "FK_Images_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id"); - }); - - migrationBuilder.CreateTable( - name: "Likes", - columns: table => new - { - Id = table.Column(type: "nvarchar(450)", nullable: false), - UserId = table.Column(type: "nvarchar(450)", nullable: true), - LikedUserId = table.Column(type: "nvarchar(450)", nullable: true), - CreatedOn = table.Column(type: "datetime2", nullable: false), - ModifiedOn = table.Column(type: "datetime2", nullable: true), - IsDeleted = table.Column(type: "bit", nullable: false), - DeletedOn = table.Column(type: "datetime2", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Likes", x => x.Id); - table.ForeignKey( - name: "FK_Likes_AspNetUsers_LikedUserId", - column: x => x.LikedUserId, - principalTable: "AspNetUsers", - principalColumn: "Id"); - table.ForeignKey( - name: "FK_Likes_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id"); - }); - - migrationBuilder.CreateTable( - name: "Matches", - columns: table => new - { - Id = table.Column(type: "nvarchar(450)", nullable: false), - CreatedOn = table.Column(type: "datetime2", nullable: false), - ModifiedOn = table.Column(type: "datetime2", nullable: true), - IsDeleted = table.Column(type: "bit", nullable: false), - DeletedOn = table.Column(type: "datetime2", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Matches", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "ApplicationUserMatch", - columns: table => new - { - MatchesId = table.Column(type: "nvarchar(450)", nullable: false), - UsersId = table.Column(type: "nvarchar(450)", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_ApplicationUserMatch", x => new { x.MatchesId, x.UsersId }); - table.ForeignKey( - name: "FK_ApplicationUserMatch_AspNetUsers_UsersId", - column: x => x.UsersId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_ApplicationUserMatch_Matches_MatchesId", - column: x => x.MatchesId, - principalTable: "Matches", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUsers_CityId", - table: "AspNetUsers", - column: "CityId"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUsers_CountryId", - table: "AspNetUsers", - column: "CountryId"); - - migrationBuilder.CreateIndex( - name: "IX_ApplicationUserMatch_UsersId", - table: "ApplicationUserMatch", - column: "UsersId"); - - migrationBuilder.CreateIndex( - name: "IX_Images_IsDeleted", - table: "Images", - column: "IsDeleted"); - - migrationBuilder.CreateIndex( - name: "IX_Images_UserId", - table: "Images", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_Likes_IsDeleted", - table: "Likes", - column: "IsDeleted"); - - migrationBuilder.CreateIndex( - name: "IX_Likes_LikedUserId", - table: "Likes", - column: "LikedUserId"); - - migrationBuilder.CreateIndex( - name: "IX_Likes_UserId", - table: "Likes", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_Matches_IsDeleted", - table: "Matches", - column: "IsDeleted"); - - migrationBuilder.AddForeignKey( - name: "FK_AspNetUsers_Cities_CityId", - table: "AspNetUsers", - column: "CityId", - principalTable: "Cities", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - - migrationBuilder.AddForeignKey( - name: "FK_AspNetUsers_Countries_CountryId", - table: "AspNetUsers", - column: "CountryId", - principalTable: "Countries", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_AspNetUsers_Cities_CityId", - table: "AspNetUsers"); - - migrationBuilder.DropForeignKey( - name: "FK_AspNetUsers_Countries_CountryId", - table: "AspNetUsers"); - - migrationBuilder.DropTable( - name: "ApplicationUserMatch"); - - migrationBuilder.DropTable( - name: "Images"); - - migrationBuilder.DropTable( - name: "Likes"); - - migrationBuilder.DropTable( - name: "Matches"); - - migrationBuilder.DropIndex( - name: "IX_AspNetUsers_CityId", - table: "AspNetUsers"); - - migrationBuilder.DropIndex( - name: "IX_AspNetUsers_CountryId", - table: "AspNetUsers"); - - migrationBuilder.DropColumn( - name: "Age", - table: "AspNetUsers"); - - migrationBuilder.DropColumn( - name: "Bio", - table: "AspNetUsers"); - - migrationBuilder.DropColumn( - name: "CityId", - table: "AspNetUsers"); - - migrationBuilder.DropColumn( - name: "CountryId", - table: "AspNetUsers"); - - migrationBuilder.AlterColumn( - name: "Name", - table: "Countries", - type: "nvarchar(max)", - nullable: true, - oldClrType: typeof(string), - oldType: "nvarchar(100)", - oldMaxLength: 100); - - migrationBuilder.AlterColumn( - name: "NameAscii", - table: "Cities", - type: "nvarchar(max)", - nullable: false, - oldClrType: typeof(string), - oldType: "nvarchar(255)", - oldMaxLength: 255); - - migrationBuilder.AlterColumn( - name: "Name", - table: "Cities", - type: "nvarchar(max)", - nullable: false, - oldClrType: typeof(string), - oldType: "nvarchar(255)", - oldMaxLength: 255); - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20220821122127_RefreshTokenToUser.Designer.cs b/Data/LOVE.NET.Data/Migrations/20220821122127_RefreshTokenToUser.Designer.cs deleted file mode 100644 index f4f7945..0000000 --- a/Data/LOVE.NET.Data/Migrations/20220821122127_RefreshTokenToUser.Designer.cs +++ /dev/null @@ -1,669 +0,0 @@ -// -using System; -using LOVE.NET.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20220821122127_RefreshTokenToUser")] - partial class RefreshTokenToUser - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "6.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); - - modelBuilder.Entity("ApplicationUserMatch", b => - { - b.Property("MatchesId") - .HasColumnType("nvarchar(450)"); - - b.Property("UsersId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("MatchesId", "UsersId"); - - b.HasIndex("UsersId"); - - b.ToTable("ApplicationUserMatch"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("Age") - .HasMaxLength(150) - .HasColumnType("int"); - - b.Property("Bio") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("CityId") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("CityId"); - - b.HasIndex("CountryId"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Latitude") - .HasColumnType("float"); - - b.Property("Longitude") - .HasColumnType("float"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("NameAscii") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.HasKey("Id"); - - b.HasIndex("CountryId"); - - b.ToTable("Cities"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.ToTable("Countries"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Url") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("UserId"); - - b.ToTable("Images"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LikedUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("LikedUserId"); - - b.HasIndex("UserId"); - - b.ToTable("Likes"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Match", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.ToTable("Matches"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ApplicationUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Expires") - .HasColumnType("datetime2"); - - b.Property("ReplacedByToken") - .HasColumnType("nvarchar(max)"); - - b.Property("Revoked") - .HasColumnType("datetime2"); - - b.Property("Token") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("ApplicationUserId"); - - b.ToTable("RefreshTokens"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Setting", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("ApplicationUserMatch", b => - { - b.HasOne("LOVE.NET.Data.Models.Match", null) - .WithMany() - .HasForeignKey("MatchesId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UsersId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.HasOne("LOVE.NET.Data.Models.City", "City") - .WithMany("Users") - .HasForeignKey("CityId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Users") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("City"); - - b.Navigation("Country"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Cities") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Country"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("Images") - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "LikedUser") - .WithMany("LikesReceived") - .HasForeignKey("LikedUserId"); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("LikesSent") - .HasForeignKey("UserId"); - - b.Navigation("LikedUser"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("RefreshTokens") - .HasForeignKey("ApplicationUserId"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Claims") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Logins") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Roles") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Navigation("Claims"); - - b.Navigation("Images"); - - b.Navigation("LikesReceived"); - - b.Navigation("LikesSent"); - - b.Navigation("Logins"); - - b.Navigation("RefreshTokens"); - - b.Navigation("Roles"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Navigation("Users"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Navigation("Cities"); - - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20220821122127_RefreshTokenToUser.cs b/Data/LOVE.NET.Data/Migrations/20220821122127_RefreshTokenToUser.cs deleted file mode 100644 index 2e84dd7..0000000 --- a/Data/LOVE.NET.Data/Migrations/20220821122127_RefreshTokenToUser.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - public partial class RefreshTokenToUser : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "RefreshTokens", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Token = table.Column(type: "nvarchar(max)", nullable: true), - Expires = table.Column(type: "datetime2", nullable: false), - Revoked = table.Column(type: "datetime2", nullable: true), - CreatedOn = table.Column(type: "datetime2", nullable: false), - ReplacedByToken = table.Column(type: "nvarchar(max)", nullable: true), - ApplicationUserId = table.Column(type: "nvarchar(450)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_RefreshTokens", x => x.Id); - table.ForeignKey( - name: "FK_RefreshTokens_AspNetUsers_ApplicationUserId", - column: x => x.ApplicationUserId, - principalTable: "AspNetUsers", - principalColumn: "Id"); - }); - - migrationBuilder.CreateIndex( - name: "IX_RefreshTokens_ApplicationUserId", - table: "RefreshTokens", - column: "ApplicationUserId"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "RefreshTokens"); - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20220822171858_RemovingAgeAddingBirthdate.Designer.cs b/Data/LOVE.NET.Data/Migrations/20220822171858_RemovingAgeAddingBirthdate.Designer.cs deleted file mode 100644 index 44c4236..0000000 --- a/Data/LOVE.NET.Data/Migrations/20220822171858_RemovingAgeAddingBirthdate.Designer.cs +++ /dev/null @@ -1,668 +0,0 @@ -// -using System; -using LOVE.NET.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20220822171858_RemovingAgeAddingBirthdate")] - partial class RemovingAgeAddingBirthdate - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "6.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); - - modelBuilder.Entity("ApplicationUserMatch", b => - { - b.Property("MatchesId") - .HasColumnType("nvarchar(450)"); - - b.Property("UsersId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("MatchesId", "UsersId"); - - b.HasIndex("UsersId"); - - b.ToTable("ApplicationUserMatch"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("Bio") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("Birthdate") - .HasColumnType("datetime2"); - - b.Property("CityId") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("CityId"); - - b.HasIndex("CountryId"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Latitude") - .HasColumnType("float"); - - b.Property("Longitude") - .HasColumnType("float"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("NameAscii") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.HasKey("Id"); - - b.HasIndex("CountryId"); - - b.ToTable("Cities"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.ToTable("Countries"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Url") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("UserId"); - - b.ToTable("Images"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LikedUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("LikedUserId"); - - b.HasIndex("UserId"); - - b.ToTable("Likes"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Match", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.ToTable("Matches"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ApplicationUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Expires") - .HasColumnType("datetime2"); - - b.Property("ReplacedByToken") - .HasColumnType("nvarchar(max)"); - - b.Property("Revoked") - .HasColumnType("datetime2"); - - b.Property("Token") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("ApplicationUserId"); - - b.ToTable("RefreshTokens"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Setting", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("ApplicationUserMatch", b => - { - b.HasOne("LOVE.NET.Data.Models.Match", null) - .WithMany() - .HasForeignKey("MatchesId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UsersId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.HasOne("LOVE.NET.Data.Models.City", "City") - .WithMany("Users") - .HasForeignKey("CityId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Users") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("City"); - - b.Navigation("Country"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Cities") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Country"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("Images") - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "LikedUser") - .WithMany("LikesReceived") - .HasForeignKey("LikedUserId"); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("LikesSent") - .HasForeignKey("UserId"); - - b.Navigation("LikedUser"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("RefreshTokens") - .HasForeignKey("ApplicationUserId"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Claims") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Logins") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Roles") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Navigation("Claims"); - - b.Navigation("Images"); - - b.Navigation("LikesReceived"); - - b.Navigation("LikesSent"); - - b.Navigation("Logins"); - - b.Navigation("RefreshTokens"); - - b.Navigation("Roles"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Navigation("Users"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Navigation("Cities"); - - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20220822171858_RemovingAgeAddingBirthdate.cs b/Data/LOVE.NET.Data/Migrations/20220822171858_RemovingAgeAddingBirthdate.cs deleted file mode 100644 index 0dc09b2..0000000 --- a/Data/LOVE.NET.Data/Migrations/20220822171858_RemovingAgeAddingBirthdate.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - public partial class RemovingAgeAddingBirthdate : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "Age", - table: "AspNetUsers"); - - migrationBuilder.AddColumn( - name: "Birthdate", - table: "AspNetUsers", - type: "datetime2", - nullable: false, - defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "Birthdate", - table: "AspNetUsers"); - - migrationBuilder.AddColumn( - name: "Age", - table: "AspNetUsers", - type: "int", - maxLength: 150, - nullable: false, - defaultValue: 0); - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20221029174931_AddIsProfilePictureFlagInImageModel.Designer.cs b/Data/LOVE.NET.Data/Migrations/20221029174931_AddIsProfilePictureFlagInImageModel.Designer.cs deleted file mode 100644 index edc6bcc..0000000 --- a/Data/LOVE.NET.Data/Migrations/20221029174931_AddIsProfilePictureFlagInImageModel.Designer.cs +++ /dev/null @@ -1,671 +0,0 @@ -// -using System; -using LOVE.NET.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20221029174931_AddIsProfilePictureFlagInImageModel")] - partial class AddIsProfilePictureFlagInImageModel - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "6.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); - - modelBuilder.Entity("ApplicationUserMatch", b => - { - b.Property("MatchesId") - .HasColumnType("nvarchar(450)"); - - b.Property("UsersId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("MatchesId", "UsersId"); - - b.HasIndex("UsersId"); - - b.ToTable("ApplicationUserMatch"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("Bio") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("Birthdate") - .HasColumnType("datetime2"); - - b.Property("CityId") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("CityId"); - - b.HasIndex("CountryId"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Latitude") - .HasColumnType("float"); - - b.Property("Longitude") - .HasColumnType("float"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("NameAscii") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.HasKey("Id"); - - b.HasIndex("CountryId"); - - b.ToTable("Cities"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.ToTable("Countries"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("IsProfilePicture") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Url") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("UserId"); - - b.ToTable("Images"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LikedUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("LikedUserId"); - - b.HasIndex("UserId"); - - b.ToTable("Likes"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Match", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.ToTable("Matches"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ApplicationUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Expires") - .HasColumnType("datetime2"); - - b.Property("ReplacedByToken") - .HasColumnType("nvarchar(max)"); - - b.Property("Revoked") - .HasColumnType("datetime2"); - - b.Property("Token") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("ApplicationUserId"); - - b.ToTable("RefreshTokens"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Setting", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("ApplicationUserMatch", b => - { - b.HasOne("LOVE.NET.Data.Models.Match", null) - .WithMany() - .HasForeignKey("MatchesId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UsersId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.HasOne("LOVE.NET.Data.Models.City", "City") - .WithMany("Users") - .HasForeignKey("CityId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Users") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("City"); - - b.Navigation("Country"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Cities") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Country"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("Images") - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "LikedUser") - .WithMany("LikesReceived") - .HasForeignKey("LikedUserId"); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("LikesSent") - .HasForeignKey("UserId"); - - b.Navigation("LikedUser"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("RefreshTokens") - .HasForeignKey("ApplicationUserId"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Claims") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Logins") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Roles") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Navigation("Claims"); - - b.Navigation("Images"); - - b.Navigation("LikesReceived"); - - b.Navigation("LikesSent"); - - b.Navigation("Logins"); - - b.Navigation("RefreshTokens"); - - b.Navigation("Roles"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Navigation("Users"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Navigation("Cities"); - - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20221029174931_AddIsProfilePictureFlagInImageModel.cs b/Data/LOVE.NET.Data/Migrations/20221029174931_AddIsProfilePictureFlagInImageModel.cs deleted file mode 100644 index 13f3794..0000000 --- a/Data/LOVE.NET.Data/Migrations/20221029174931_AddIsProfilePictureFlagInImageModel.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - public partial class AddIsProfilePictureFlagInImageModel : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "IsProfilePicture", - table: "Images", - type: "bit", - nullable: false, - defaultValue: false); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "IsProfilePicture", - table: "Images"); - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20221030161925_AddGenderEntity.Designer.cs b/Data/LOVE.NET.Data/Migrations/20221030161925_AddGenderEntity.Designer.cs deleted file mode 100644 index be5707b..0000000 --- a/Data/LOVE.NET.Data/Migrations/20221030161925_AddGenderEntity.Designer.cs +++ /dev/null @@ -1,706 +0,0 @@ -// -using System; -using LOVE.NET.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20221030161925_AddGenderEntity")] - partial class AddGenderEntity - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "6.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); - - modelBuilder.Entity("ApplicationUserMatch", b => - { - b.Property("MatchesId") - .HasColumnType("nvarchar(450)"); - - b.Property("UsersId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("MatchesId", "UsersId"); - - b.HasIndex("UsersId"); - - b.ToTable("ApplicationUserMatch"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("Bio") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("Birthdate") - .HasColumnType("datetime2"); - - b.Property("CityId") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("GenderId") - .HasColumnType("int"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("CityId"); - - b.HasIndex("CountryId"); - - b.HasIndex("GenderId"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Latitude") - .HasColumnType("float"); - - b.Property("Longitude") - .HasColumnType("float"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("NameAscii") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.HasKey("Id"); - - b.HasIndex("CountryId"); - - b.ToTable("Cities"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.ToTable("Countries"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Gender", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.ToTable("Genders"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("IsProfilePicture") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Url") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("UserId"); - - b.ToTable("Images"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LikedUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("LikedUserId"); - - b.HasIndex("UserId"); - - b.ToTable("Likes"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Match", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.ToTable("Matches"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ApplicationUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Expires") - .HasColumnType("datetime2"); - - b.Property("ReplacedByToken") - .HasColumnType("nvarchar(max)"); - - b.Property("Revoked") - .HasColumnType("datetime2"); - - b.Property("Token") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("ApplicationUserId"); - - b.ToTable("RefreshTokens"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Setting", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("ApplicationUserMatch", b => - { - b.HasOne("LOVE.NET.Data.Models.Match", null) - .WithMany() - .HasForeignKey("MatchesId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UsersId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.HasOne("LOVE.NET.Data.Models.City", "City") - .WithMany("Users") - .HasForeignKey("CityId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Users") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Gender", "Gender") - .WithMany() - .HasForeignKey("GenderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("City"); - - b.Navigation("Country"); - - b.Navigation("Gender"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Cities") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Country"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("Images") - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "LikedUser") - .WithMany("LikesReceived") - .HasForeignKey("LikedUserId"); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("LikesSent") - .HasForeignKey("UserId"); - - b.Navigation("LikedUser"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("RefreshTokens") - .HasForeignKey("ApplicationUserId"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Claims") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Logins") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Roles") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Navigation("Claims"); - - b.Navigation("Images"); - - b.Navigation("LikesReceived"); - - b.Navigation("LikesSent"); - - b.Navigation("Logins"); - - b.Navigation("RefreshTokens"); - - b.Navigation("Roles"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Navigation("Users"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Navigation("Cities"); - - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20221030161925_AddGenderEntity.cs b/Data/LOVE.NET.Data/Migrations/20221030161925_AddGenderEntity.cs deleted file mode 100644 index 5515c93..0000000 --- a/Data/LOVE.NET.Data/Migrations/20221030161925_AddGenderEntity.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - public partial class AddGenderEntity : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "GenderId", - table: "AspNetUsers", - type: "int", - nullable: false, - defaultValue: 0); - - migrationBuilder.CreateTable( - name: "Genders", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Name = table.Column(type: "nvarchar(max)", nullable: true), - CreatedOn = table.Column(type: "datetime2", nullable: false), - ModifiedOn = table.Column(type: "datetime2", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Genders", x => x.Id); - }); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUsers_GenderId", - table: "AspNetUsers", - column: "GenderId"); - - migrationBuilder.AddForeignKey( - name: "FK_AspNetUsers_Genders_GenderId", - table: "AspNetUsers", - column: "GenderId", - principalTable: "Genders", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_AspNetUsers_Genders_GenderId", - table: "AspNetUsers"); - - migrationBuilder.DropTable( - name: "Genders"); - - migrationBuilder.DropIndex( - name: "IX_AspNetUsers_GenderId", - table: "AspNetUsers"); - - migrationBuilder.DropColumn( - name: "GenderId", - table: "AspNetUsers"); - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20221101184133_RemoveMatchModelUseSelfReferenceAsMatch.Designer.cs b/Data/LOVE.NET.Data/Migrations/20221101184133_RemoveMatchModelUseSelfReferenceAsMatch.Designer.cs deleted file mode 100644 index cbf60b2..0000000 --- a/Data/LOVE.NET.Data/Migrations/20221101184133_RemoveMatchModelUseSelfReferenceAsMatch.Designer.cs +++ /dev/null @@ -1,663 +0,0 @@ -// -using System; -using LOVE.NET.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20221101184133_RemoveMatchModelUseSelfReferenceAsMatch")] - partial class RemoveMatchModelUseSelfReferenceAsMatch - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "6.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("ApplicationUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("Bio") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("Birthdate") - .HasColumnType("datetime2"); - - b.Property("CityId") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("GenderId") - .HasColumnType("int"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("ApplicationUserId"); - - b.HasIndex("CityId"); - - b.HasIndex("CountryId"); - - b.HasIndex("GenderId"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Latitude") - .HasColumnType("float"); - - b.Property("Longitude") - .HasColumnType("float"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("NameAscii") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.HasKey("Id"); - - b.HasIndex("CountryId"); - - b.ToTable("Cities"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.ToTable("Countries"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Gender", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.ToTable("Genders"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("IsProfilePicture") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Url") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("UserId"); - - b.ToTable("Images"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LikedUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("LikedUserId"); - - b.HasIndex("UserId"); - - b.ToTable("Likes"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ApplicationUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Expires") - .HasColumnType("datetime2"); - - b.Property("ReplacedByToken") - .HasColumnType("nvarchar(max)"); - - b.Property("Revoked") - .HasColumnType("datetime2"); - - b.Property("Token") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("ApplicationUserId"); - - b.ToTable("RefreshTokens"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Setting", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Matches") - .HasForeignKey("ApplicationUserId"); - - b.HasOne("LOVE.NET.Data.Models.City", "City") - .WithMany("Users") - .HasForeignKey("CityId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Users") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Gender", "Gender") - .WithMany() - .HasForeignKey("GenderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("City"); - - b.Navigation("Country"); - - b.Navigation("Gender"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Cities") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Country"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("Images") - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "LikedUser") - .WithMany("LikesReceived") - .HasForeignKey("LikedUserId"); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("LikesSent") - .HasForeignKey("UserId"); - - b.Navigation("LikedUser"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("RefreshTokens") - .HasForeignKey("ApplicationUserId"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Claims") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Logins") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Roles") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Navigation("Claims"); - - b.Navigation("Images"); - - b.Navigation("LikesReceived"); - - b.Navigation("LikesSent"); - - b.Navigation("Logins"); - - b.Navigation("Matches"); - - b.Navigation("RefreshTokens"); - - b.Navigation("Roles"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Navigation("Users"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Navigation("Cities"); - - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20221101184133_RemoveMatchModelUseSelfReferenceAsMatch.cs b/Data/LOVE.NET.Data/Migrations/20221101184133_RemoveMatchModelUseSelfReferenceAsMatch.cs deleted file mode 100644 index 1ff54da..0000000 --- a/Data/LOVE.NET.Data/Migrations/20221101184133_RemoveMatchModelUseSelfReferenceAsMatch.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - public partial class RemoveMatchModelUseSelfReferenceAsMatch : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "ApplicationUserMatch"); - - migrationBuilder.DropTable( - name: "Matches"); - - migrationBuilder.AddColumn( - name: "ApplicationUserId", - table: "AspNetUsers", - type: "nvarchar(450)", - nullable: true); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUsers_ApplicationUserId", - table: "AspNetUsers", - column: "ApplicationUserId"); - - migrationBuilder.AddForeignKey( - name: "FK_AspNetUsers_AspNetUsers_ApplicationUserId", - table: "AspNetUsers", - column: "ApplicationUserId", - principalTable: "AspNetUsers", - principalColumn: "Id"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_AspNetUsers_AspNetUsers_ApplicationUserId", - table: "AspNetUsers"); - - migrationBuilder.DropIndex( - name: "IX_AspNetUsers_ApplicationUserId", - table: "AspNetUsers"); - - migrationBuilder.DropColumn( - name: "ApplicationUserId", - table: "AspNetUsers"); - - migrationBuilder.CreateTable( - name: "Matches", - columns: table => new - { - Id = table.Column(type: "nvarchar(450)", nullable: false), - CreatedOn = table.Column(type: "datetime2", nullable: false), - DeletedOn = table.Column(type: "datetime2", nullable: true), - IsDeleted = table.Column(type: "bit", nullable: false), - ModifiedOn = table.Column(type: "datetime2", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Matches", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "ApplicationUserMatch", - columns: table => new - { - MatchesId = table.Column(type: "nvarchar(450)", nullable: false), - UsersId = table.Column(type: "nvarchar(450)", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_ApplicationUserMatch", x => new { x.MatchesId, x.UsersId }); - table.ForeignKey( - name: "FK_ApplicationUserMatch_AspNetUsers_UsersId", - column: x => x.UsersId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_ApplicationUserMatch_Matches_MatchesId", - column: x => x.MatchesId, - principalTable: "Matches", - principalColumn: "Id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateIndex( - name: "IX_ApplicationUserMatch_UsersId", - table: "ApplicationUserMatch", - column: "UsersId"); - - migrationBuilder.CreateIndex( - name: "IX_Matches_IsDeleted", - table: "Matches", - column: "IsDeleted"); - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20221101192258_RemoveUserSelfReference.Designer.cs b/Data/LOVE.NET.Data/Migrations/20221101192258_RemoveUserSelfReference.Designer.cs deleted file mode 100644 index b9a2a43..0000000 --- a/Data/LOVE.NET.Data/Migrations/20221101192258_RemoveUserSelfReference.Designer.cs +++ /dev/null @@ -1,652 +0,0 @@ -// -using System; -using LOVE.NET.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20221101192258_RemoveUserSelfReference")] - partial class RemoveUserSelfReference - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "6.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("Bio") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("Birthdate") - .HasColumnType("datetime2"); - - b.Property("CityId") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("GenderId") - .HasColumnType("int"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("CityId"); - - b.HasIndex("CountryId"); - - b.HasIndex("GenderId"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Latitude") - .HasColumnType("float"); - - b.Property("Longitude") - .HasColumnType("float"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("NameAscii") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.HasKey("Id"); - - b.HasIndex("CountryId"); - - b.ToTable("Cities"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.ToTable("Countries"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Gender", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.ToTable("Genders"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("IsProfilePicture") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Url") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("UserId"); - - b.ToTable("Images"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LikedUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("LikedUserId"); - - b.HasIndex("UserId"); - - b.ToTable("Likes"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ApplicationUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Expires") - .HasColumnType("datetime2"); - - b.Property("ReplacedByToken") - .HasColumnType("nvarchar(max)"); - - b.Property("Revoked") - .HasColumnType("datetime2"); - - b.Property("Token") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("ApplicationUserId"); - - b.ToTable("RefreshTokens"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Setting", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.HasOne("LOVE.NET.Data.Models.City", "City") - .WithMany("Users") - .HasForeignKey("CityId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Users") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Gender", "Gender") - .WithMany() - .HasForeignKey("GenderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("City"); - - b.Navigation("Country"); - - b.Navigation("Gender"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Cities") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Country"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("Images") - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "LikedUser") - .WithMany("LikesReceived") - .HasForeignKey("LikedUserId"); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("LikesSent") - .HasForeignKey("UserId"); - - b.Navigation("LikedUser"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("RefreshTokens") - .HasForeignKey("ApplicationUserId"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Claims") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Logins") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Roles") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Navigation("Claims"); - - b.Navigation("Images"); - - b.Navigation("LikesReceived"); - - b.Navigation("LikesSent"); - - b.Navigation("Logins"); - - b.Navigation("RefreshTokens"); - - b.Navigation("Roles"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Navigation("Users"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Navigation("Cities"); - - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20221101192258_RemoveUserSelfReference.cs b/Data/LOVE.NET.Data/Migrations/20221101192258_RemoveUserSelfReference.cs deleted file mode 100644 index 1256a87..0000000 --- a/Data/LOVE.NET.Data/Migrations/20221101192258_RemoveUserSelfReference.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - public partial class RemoveUserSelfReference : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropForeignKey( - name: "FK_AspNetUsers_AspNetUsers_ApplicationUserId", - table: "AspNetUsers"); - - migrationBuilder.DropIndex( - name: "IX_AspNetUsers_ApplicationUserId", - table: "AspNetUsers"); - - migrationBuilder.DropColumn( - name: "ApplicationUserId", - table: "AspNetUsers"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "ApplicationUserId", - table: "AspNetUsers", - type: "nvarchar(450)", - nullable: true); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUsers_ApplicationUserId", - table: "AspNetUsers", - column: "ApplicationUserId"); - - migrationBuilder.AddForeignKey( - name: "FK_AspNetUsers_AspNetUsers_ApplicationUserId", - table: "AspNetUsers", - column: "ApplicationUserId", - principalTable: "AspNetUsers", - principalColumn: "Id"); - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20221129164506_AddingMessages.Designer.cs b/Data/LOVE.NET.Data/Migrations/20221129164506_AddingMessages.Designer.cs deleted file mode 100644 index bdeb4ec..0000000 --- a/Data/LOVE.NET.Data/Migrations/20221129164506_AddingMessages.Designer.cs +++ /dev/null @@ -1,690 +0,0 @@ -// -using System; -using LOVE.NET.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20221129164506_AddingMessages")] - partial class AddingMessages - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "6.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("Bio") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("Birthdate") - .HasColumnType("datetime2"); - - b.Property("CityId") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("GenderId") - .HasColumnType("int"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("CityId"); - - b.HasIndex("CountryId"); - - b.HasIndex("GenderId"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Latitude") - .HasColumnType("float"); - - b.Property("Longitude") - .HasColumnType("float"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("NameAscii") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.HasKey("Id"); - - b.HasIndex("CountryId"); - - b.ToTable("Cities"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.ToTable("Countries"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Gender", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.ToTable("Genders"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("IsProfilePicture") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Url") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("UserId"); - - b.ToTable("Images"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LikedUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("LikedUserId"); - - b.HasIndex("UserId"); - - b.ToTable("Likes"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Message", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("RoomId") - .HasColumnType("nvarchar(max)"); - - b.Property("Text") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("Messages"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ApplicationUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Expires") - .HasColumnType("datetime2"); - - b.Property("ReplacedByToken") - .HasColumnType("nvarchar(max)"); - - b.Property("Revoked") - .HasColumnType("datetime2"); - - b.Property("Token") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("ApplicationUserId"); - - b.ToTable("RefreshTokens"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Setting", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.ToTable("Settings"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.HasOne("LOVE.NET.Data.Models.City", "City") - .WithMany("Users") - .HasForeignKey("CityId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Users") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Gender", "Gender") - .WithMany() - .HasForeignKey("GenderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("City"); - - b.Navigation("Country"); - - b.Navigation("Gender"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Cities") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Country"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("Images") - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "LikedUser") - .WithMany("LikesReceived") - .HasForeignKey("LikedUserId"); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("LikesSent") - .HasForeignKey("UserId"); - - b.Navigation("LikedUser"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Message", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("Messages") - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("RefreshTokens") - .HasForeignKey("ApplicationUserId"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Claims") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Logins") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Roles") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Navigation("Claims"); - - b.Navigation("Images"); - - b.Navigation("LikesReceived"); - - b.Navigation("LikesSent"); - - b.Navigation("Logins"); - - b.Navigation("Messages"); - - b.Navigation("RefreshTokens"); - - b.Navigation("Roles"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Navigation("Users"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Navigation("Cities"); - - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20221129164506_AddingMessages.cs b/Data/LOVE.NET.Data/Migrations/20221129164506_AddingMessages.cs deleted file mode 100644 index a120fc3..0000000 --- a/Data/LOVE.NET.Data/Migrations/20221129164506_AddingMessages.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - public partial class AddingMessages : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Messages", - columns: table => new - { - Id = table.Column(type: "nvarchar(450)", nullable: false), - RoomId = table.Column(type: "nvarchar(max)", nullable: true), - UserId = table.Column(type: "nvarchar(450)", nullable: true), - Text = table.Column(type: "nvarchar(max)", nullable: true), - CreatedOn = table.Column(type: "datetime2", nullable: false), - ModifiedOn = table.Column(type: "datetime2", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Messages", x => x.Id); - table.ForeignKey( - name: "FK_Messages_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id"); - }); - - migrationBuilder.CreateIndex( - name: "IX_Messages_UserId", - table: "Messages", - column: "UserId"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Messages"); - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20221217094417_FinalMigration.Designer.cs b/Data/LOVE.NET.Data/Migrations/20221217094417_FinalMigration.Designer.cs deleted file mode 100644 index 81ac563..0000000 --- a/Data/LOVE.NET.Data/Migrations/20221217094417_FinalMigration.Designer.cs +++ /dev/null @@ -1,657 +0,0 @@ -// -using System; -using LOVE.NET.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20221217094417_FinalMigration")] - partial class FinalMigration - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "6.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("Bio") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("Birthdate") - .HasColumnType("datetime2"); - - b.Property("CityId") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("GenderId") - .HasColumnType("int"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("CityId"); - - b.HasIndex("CountryId"); - - b.HasIndex("GenderId"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Latitude") - .HasColumnType("float"); - - b.Property("Longitude") - .HasColumnType("float"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("NameAscii") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.HasKey("Id"); - - b.HasIndex("CountryId"); - - b.ToTable("Cities"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.ToTable("Countries"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Gender", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.ToTable("Genders"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("IsProfilePicture") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Url") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("UserId"); - - b.ToTable("Images"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LikedUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("LikedUserId"); - - b.HasIndex("UserId"); - - b.ToTable("Likes"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Message", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("RoomId") - .HasColumnType("nvarchar(max)"); - - b.Property("Text") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("Messages"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ApplicationUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Expires") - .HasColumnType("datetime2"); - - b.Property("ReplacedByToken") - .HasColumnType("nvarchar(max)"); - - b.Property("Revoked") - .HasColumnType("datetime2"); - - b.Property("Token") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("ApplicationUserId"); - - b.ToTable("RefreshTokens"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.HasOne("LOVE.NET.Data.Models.City", "City") - .WithMany("Users") - .HasForeignKey("CityId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Users") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Gender", "Gender") - .WithMany() - .HasForeignKey("GenderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("City"); - - b.Navigation("Country"); - - b.Navigation("Gender"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Cities") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Country"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("Images") - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "LikedUser") - .WithMany("LikesReceived") - .HasForeignKey("LikedUserId"); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("LikesSent") - .HasForeignKey("UserId"); - - b.Navigation("LikedUser"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Message", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("Messages") - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("RefreshTokens") - .HasForeignKey("ApplicationUserId"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Claims") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Logins") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Roles") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Navigation("Claims"); - - b.Navigation("Images"); - - b.Navigation("LikesReceived"); - - b.Navigation("LikesSent"); - - b.Navigation("Logins"); - - b.Navigation("Messages"); - - b.Navigation("RefreshTokens"); - - b.Navigation("Roles"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Navigation("Users"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Navigation("Cities"); - - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20221217094417_FinalMigration.cs b/Data/LOVE.NET.Data/Migrations/20221217094417_FinalMigration.cs deleted file mode 100644 index d1829ba..0000000 --- a/Data/LOVE.NET.Data/Migrations/20221217094417_FinalMigration.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - public partial class FinalMigration : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Settings"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Settings", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - CreatedOn = table.Column(type: "datetime2", nullable: false), - DeletedOn = table.Column(type: "datetime2", nullable: true), - IsDeleted = table.Column(type: "bit", nullable: false), - ModifiedOn = table.Column(type: "datetime2", nullable: true), - Name = table.Column(type: "nvarchar(max)", nullable: true), - Value = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Settings", x => x.Id); - }); - - migrationBuilder.CreateIndex( - name: "IX_Settings_IsDeleted", - table: "Settings", - column: "IsDeleted"); - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20230219145814_AddImageUrlToMessage.Designer.cs b/Data/LOVE.NET.Data/Migrations/20230219145814_AddImageUrlToMessage.Designer.cs deleted file mode 100644 index 842a365..0000000 --- a/Data/LOVE.NET.Data/Migrations/20230219145814_AddImageUrlToMessage.Designer.cs +++ /dev/null @@ -1,660 +0,0 @@ -// -using System; -using LOVE.NET.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20230219145814_AddImageUrlToMessage")] - partial class AddImageUrlToMessage - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "6.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("Bio") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("Birthdate") - .HasColumnType("datetime2"); - - b.Property("CityId") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("GenderId") - .HasColumnType("int"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("CityId"); - - b.HasIndex("CountryId"); - - b.HasIndex("GenderId"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Latitude") - .HasColumnType("float"); - - b.Property("Longitude") - .HasColumnType("float"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("NameAscii") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.HasKey("Id"); - - b.HasIndex("CountryId"); - - b.ToTable("Cities"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.ToTable("Countries"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Gender", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.ToTable("Genders"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("IsProfilePicture") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Url") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("UserId"); - - b.ToTable("Images"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LikedUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("LikedUserId"); - - b.HasIndex("UserId"); - - b.ToTable("Likes"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Message", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ImageUrl") - .HasColumnType("nvarchar(max)"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("RoomId") - .HasColumnType("nvarchar(max)"); - - b.Property("Text") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("Messages"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ApplicationUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Expires") - .HasColumnType("datetime2"); - - b.Property("ReplacedByToken") - .HasColumnType("nvarchar(max)"); - - b.Property("Revoked") - .HasColumnType("datetime2"); - - b.Property("Token") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("ApplicationUserId"); - - b.ToTable("RefreshTokens"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.HasOne("LOVE.NET.Data.Models.City", "City") - .WithMany("Users") - .HasForeignKey("CityId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Users") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Gender", "Gender") - .WithMany() - .HasForeignKey("GenderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("City"); - - b.Navigation("Country"); - - b.Navigation("Gender"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Cities") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Country"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("Images") - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "LikedUser") - .WithMany("LikesReceived") - .HasForeignKey("LikedUserId"); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("LikesSent") - .HasForeignKey("UserId"); - - b.Navigation("LikedUser"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Message", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("Messages") - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("RefreshTokens") - .HasForeignKey("ApplicationUserId"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Claims") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Logins") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Roles") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Navigation("Claims"); - - b.Navigation("Images"); - - b.Navigation("LikesReceived"); - - b.Navigation("LikesSent"); - - b.Navigation("Logins"); - - b.Navigation("Messages"); - - b.Navigation("RefreshTokens"); - - b.Navigation("Roles"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Navigation("Users"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Navigation("Cities"); - - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20230219145814_AddImageUrlToMessage.cs b/Data/LOVE.NET.Data/Migrations/20230219145814_AddImageUrlToMessage.cs deleted file mode 100644 index aab57ca..0000000 --- a/Data/LOVE.NET.Data/Migrations/20230219145814_AddImageUrlToMessage.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - public partial class AddImageUrlToMessage : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "ImageUrl", - table: "Messages", - type: "nvarchar(max)", - nullable: true); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "ImageUrl", - table: "Messages"); - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20230219160749_MakeMessageTextOptional.Designer.cs b/Data/LOVE.NET.Data/Migrations/20230219160749_MakeMessageTextOptional.Designer.cs deleted file mode 100644 index a4ffbba..0000000 --- a/Data/LOVE.NET.Data/Migrations/20230219160749_MakeMessageTextOptional.Designer.cs +++ /dev/null @@ -1,660 +0,0 @@ -// -using System; -using LOVE.NET.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20230219160749_MakeMessageTextOptional")] - partial class MakeMessageTextOptional - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "6.0.5") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("Bio") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("Birthdate") - .HasColumnType("datetime2"); - - b.Property("CityId") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("GenderId") - .HasColumnType("int"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("CityId"); - - b.HasIndex("CountryId"); - - b.HasIndex("GenderId"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Latitude") - .HasColumnType("float"); - - b.Property("Longitude") - .HasColumnType("float"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("NameAscii") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.HasKey("Id"); - - b.HasIndex("CountryId"); - - b.ToTable("Cities"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.ToTable("Countries"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Gender", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.ToTable("Genders"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("IsProfilePicture") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Url") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("UserId"); - - b.ToTable("Images"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LikedUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("LikedUserId"); - - b.HasIndex("UserId"); - - b.ToTable("Likes"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Message", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ImageUrl") - .HasColumnType("nvarchar(max)"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("RoomId") - .HasColumnType("nvarchar(max)"); - - b.Property("Text") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("Messages"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ApplicationUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Expires") - .HasColumnType("datetime2"); - - b.Property("ReplacedByToken") - .HasColumnType("nvarchar(max)"); - - b.Property("Revoked") - .HasColumnType("datetime2"); - - b.Property("Token") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("ApplicationUserId"); - - b.ToTable("RefreshTokens"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.HasOne("LOVE.NET.Data.Models.City", "City") - .WithMany("Users") - .HasForeignKey("CityId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Users") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Gender", "Gender") - .WithMany() - .HasForeignKey("GenderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("City"); - - b.Navigation("Country"); - - b.Navigation("Gender"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Cities") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Country"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("Images") - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "LikedUser") - .WithMany("LikesReceived") - .HasForeignKey("LikedUserId"); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("LikesSent") - .HasForeignKey("UserId"); - - b.Navigation("LikedUser"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Message", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("Messages") - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("RefreshTokens") - .HasForeignKey("ApplicationUserId"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Claims") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Logins") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Roles") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Navigation("Claims"); - - b.Navigation("Images"); - - b.Navigation("LikesReceived"); - - b.Navigation("LikesSent"); - - b.Navigation("Logins"); - - b.Navigation("Messages"); - - b.Navigation("RefreshTokens"); - - b.Navigation("Roles"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Navigation("Users"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Navigation("Cities"); - - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20230219160749_MakeMessageTextOptional.cs b/Data/LOVE.NET.Data/Migrations/20230219160749_MakeMessageTextOptional.cs deleted file mode 100644 index 8c8fe76..0000000 --- a/Data/LOVE.NET.Data/Migrations/20230219160749_MakeMessageTextOptional.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - public partial class MakeMessageTextOptional : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20240331190546_AddingChatrooms.Designer.cs b/Data/LOVE.NET.Data/Migrations/20240331190546_AddingChatrooms.Designer.cs deleted file mode 100644 index 37b6cb0..0000000 --- a/Data/LOVE.NET.Data/Migrations/20240331190546_AddingChatrooms.Designer.cs +++ /dev/null @@ -1,683 +0,0 @@ -// -using System; -using LOVE.NET.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20240331190546_AddingChatrooms")] - partial class AddingChatrooms - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "8.0.2") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("Bio") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("Birthdate") - .HasColumnType("datetime2"); - - b.Property("CityId") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("GenderId") - .HasColumnType("int"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("CityId"); - - b.HasIndex("CountryId"); - - b.HasIndex("GenderId"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Chatroom", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Title") - .HasColumnType("nvarchar(max)"); - - b.Property("Url") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.ToTable("Chatrooms"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Latitude") - .HasColumnType("float"); - - b.Property("Longitude") - .HasColumnType("float"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("NameAscii") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.HasKey("Id"); - - b.HasIndex("CountryId"); - - b.ToTable("Cities"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.ToTable("Countries"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Gender", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.ToTable("Genders"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("IsProfilePicture") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Url") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("UserId"); - - b.ToTable("Images"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LikedUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("LikedUserId"); - - b.HasIndex("UserId"); - - b.ToTable("Likes"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Message", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ImageUrl") - .HasColumnType("nvarchar(max)"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("RoomId") - .HasColumnType("nvarchar(max)"); - - b.Property("Text") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("Messages"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ApplicationUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Expires") - .HasColumnType("datetime2"); - - b.Property("ReplacedByToken") - .HasColumnType("nvarchar(max)"); - - b.Property("Revoked") - .HasColumnType("datetime2"); - - b.Property("Token") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("ApplicationUserId"); - - b.ToTable("RefreshTokens"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.HasOne("LOVE.NET.Data.Models.City", "City") - .WithMany("Users") - .HasForeignKey("CityId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Users") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Gender", "Gender") - .WithMany() - .HasForeignKey("GenderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("City"); - - b.Navigation("Country"); - - b.Navigation("Gender"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Cities") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Country"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("Images") - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "LikedUser") - .WithMany("LikesReceived") - .HasForeignKey("LikedUserId"); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("LikesSent") - .HasForeignKey("UserId"); - - b.Navigation("LikedUser"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Message", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("Messages") - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("RefreshTokens") - .HasForeignKey("ApplicationUserId"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Claims") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Logins") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Roles") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Navigation("Claims"); - - b.Navigation("Images"); - - b.Navigation("LikesReceived"); - - b.Navigation("LikesSent"); - - b.Navigation("Logins"); - - b.Navigation("Messages"); - - b.Navigation("RefreshTokens"); - - b.Navigation("Roles"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Navigation("Users"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Navigation("Cities"); - - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/20240331190546_AddingChatrooms.cs b/Data/LOVE.NET.Data/Migrations/20240331190546_AddingChatrooms.cs deleted file mode 100644 index 39216a9..0000000 --- a/Data/LOVE.NET.Data/Migrations/20240331190546_AddingChatrooms.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - /// - public partial class AddingChatrooms : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Chatrooms", - columns: table => new - { - Id = table.Column(type: "nvarchar(450)", nullable: false), - Title = table.Column(type: "nvarchar(max)", nullable: true), - Url = table.Column(type: "nvarchar(max)", nullable: true), - CreatedOn = table.Column(type: "datetime2", nullable: false), - ModifiedOn = table.Column(type: "datetime2", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Chatrooms", x => x.Id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Chatrooms"); - } - } -} diff --git a/Data/LOVE.NET.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/Data/LOVE.NET.Data/Migrations/ApplicationDbContextModelSnapshot.cs deleted file mode 100644 index cb5a613..0000000 --- a/Data/LOVE.NET.Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ /dev/null @@ -1,680 +0,0 @@ -// -using System; -using LOVE.NET.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace LOVE.NET.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - partial class ApplicationDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "8.0.2") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("Bio") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("Birthdate") - .HasColumnType("datetime2"); - - b.Property("CityId") - .HasColumnType("int"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("GenderId") - .HasColumnType("int"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("CityId"); - - b.HasIndex("CountryId"); - - b.HasIndex("GenderId"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Chatroom", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Title") - .HasColumnType("nvarchar(max)"); - - b.Property("Url") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.ToTable("Chatrooms"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CountryId") - .HasColumnType("int"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Latitude") - .HasColumnType("float"); - - b.Property("Longitude") - .HasColumnType("float"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.Property("NameAscii") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("nvarchar(255)"); - - b.HasKey("Id"); - - b.HasIndex("CountryId"); - - b.ToTable("Cities"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("nvarchar(100)"); - - b.HasKey("Id"); - - b.ToTable("Countries"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Gender", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Name") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.ToTable("Genders"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("IsProfilePicture") - .HasColumnType("bit"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("Url") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("UserId"); - - b.ToTable("Images"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("DeletedOn") - .HasColumnType("datetime2"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LikedUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("LikedUserId"); - - b.HasIndex("UserId"); - - b.ToTable("Likes"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Message", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("ImageUrl") - .HasColumnType("nvarchar(max)"); - - b.Property("ModifiedOn") - .HasColumnType("datetime2"); - - b.Property("RoomId") - .HasColumnType("nvarchar(max)"); - - b.Property("Text") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("Messages"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ApplicationUserId") - .HasColumnType("nvarchar(450)"); - - b.Property("CreatedOn") - .HasColumnType("datetime2"); - - b.Property("Expires") - .HasColumnType("datetime2"); - - b.Property("ReplacedByToken") - .HasColumnType("nvarchar(max)"); - - b.Property("Revoked") - .HasColumnType("datetime2"); - - b.Property("Token") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("ApplicationUserId"); - - b.ToTable("RefreshTokens"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.HasOne("LOVE.NET.Data.Models.City", "City") - .WithMany("Users") - .HasForeignKey("CityId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Users") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.Gender", "Gender") - .WithMany() - .HasForeignKey("GenderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("City"); - - b.Navigation("Country"); - - b.Navigation("Gender"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.HasOne("LOVE.NET.Data.Models.Country", "Country") - .WithMany("Cities") - .HasForeignKey("CountryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Country"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Image", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("Images") - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Like", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "LikedUser") - .WithMany("LikesReceived") - .HasForeignKey("LikedUserId"); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("LikesSent") - .HasForeignKey("UserId"); - - b.Navigation("LikedUser"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Message", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", "User") - .WithMany("Messages") - .HasForeignKey("UserId"); - - b.Navigation("User"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.RefreshToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("RefreshTokens") - .HasForeignKey("ApplicationUserId"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Claims") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Logins") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany("Roles") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("LOVE.NET.Data.Models.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.ApplicationUser", b => - { - b.Navigation("Claims"); - - b.Navigation("Images"); - - b.Navigation("LikesReceived"); - - b.Navigation("LikesSent"); - - b.Navigation("Logins"); - - b.Navigation("Messages"); - - b.Navigation("RefreshTokens"); - - b.Navigation("Roles"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.City", b => - { - b.Navigation("Users"); - }); - - modelBuilder.Entity("LOVE.NET.Data.Models.Country", b => - { - b.Navigation("Cities"); - - b.Navigation("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Data/LOVE.NET.Data/Repositories/Chat/ChatRepository.cs b/Data/LOVE.NET.Data/Repositories/Chat/ChatRepository.cs deleted file mode 100644 index bc83ab9..0000000 --- a/Data/LOVE.NET.Data/Repositories/Chat/ChatRepository.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace LOVE.NET.Data.Repositories.Chat -{ - using System; - using System.Linq; - using System.Linq.Expressions; - using System.Threading.Tasks; - - using LOVE.NET.Data.Models; - - using Microsoft.EntityFrameworkCore; - - public class ChatRepository : EfRepository, IChatRepository - { - public ChatRepository(ApplicationDbContext context) - : base(context) - { - } - - public IQueryable AllAsNoTracking(Expression> func) - { - return this.Context.Messages - .AsNoTracking() - .Include(c => c.User) - .Where(func); - } - - public async Task SaveMessageAsync(Message message) - { - await this.AddAsync(message); - await this.SaveChangesAsync(); - } - } -} diff --git a/Data/LOVE.NET.Data/Repositories/Chat/IChatRepository.cs b/Data/LOVE.NET.Data/Repositories/Chat/IChatRepository.cs deleted file mode 100644 index 1f49991..0000000 --- a/Data/LOVE.NET.Data/Repositories/Chat/IChatRepository.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace LOVE.NET.Data.Repositories.Chat -{ - using System; - using System.Linq; - using System.Linq.Expressions; - using System.Threading.Tasks; - - using LOVE.NET.Data.Models; - - public interface IChatRepository - { - IQueryable AllAsNoTracking(Expression> func); - - Task SaveMessageAsync(Message message); - } -} diff --git a/Data/LOVE.NET.Data/Repositories/Chatroom/ChatroomRepository.cs b/Data/LOVE.NET.Data/Repositories/Chatroom/ChatroomRepository.cs deleted file mode 100644 index b7d216c..0000000 --- a/Data/LOVE.NET.Data/Repositories/Chatroom/ChatroomRepository.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace LOVE.NET.Data.Repositories.Chat -{ - using System.Linq; - - using LOVE.NET.Data.Models; - - using Microsoft.EntityFrameworkCore; - - public class ChatroomRepository : EfRepository, IChatroomRepository - { - public ChatroomRepository(ApplicationDbContext context) - : base(context) - { - } - } -} diff --git a/Data/LOVE.NET.Data/Repositories/Chatroom/IChatroomRepository.cs b/Data/LOVE.NET.Data/Repositories/Chatroom/IChatroomRepository.cs deleted file mode 100644 index 2562ac0..0000000 --- a/Data/LOVE.NET.Data/Repositories/Chatroom/IChatroomRepository.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace LOVE.NET.Data.Repositories.Chat -{ - using System.Linq; - - using LOVE.NET.Data.Models; - - public interface IChatroomRepository - { - IQueryable AllAsNoTracking(); - } -} diff --git a/Data/LOVE.NET.Data/Repositories/Countries/CountriesRepository.cs b/Data/LOVE.NET.Data/Repositories/Countries/CountriesRepository.cs deleted file mode 100644 index db50832..0000000 --- a/Data/LOVE.NET.Data/Repositories/Countries/CountriesRepository.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace LOVE.NET.Data.Repositories.Countries -{ - using System; - using System.Linq; - using System.Linq.Expressions; - - using LOVE.NET.Data.Models; - - using Microsoft.EntityFrameworkCore; - - public class CountriesRepository : EfRepository, ICountriesRepository - { - public CountriesRepository(ApplicationDbContext context) - : base(context) - { - } - - public IQueryable WithAllInformation( - Expression> func) - { - return this.Context.Countries - .AsNoTracking() - .Include(c => c.Cities) - .Where(func); - } - } -} diff --git a/Data/LOVE.NET.Data/Repositories/Countries/ICountriesRepository.cs b/Data/LOVE.NET.Data/Repositories/Countries/ICountriesRepository.cs deleted file mode 100644 index bb18939..0000000 --- a/Data/LOVE.NET.Data/Repositories/Countries/ICountriesRepository.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace LOVE.NET.Data.Repositories.Countries -{ - using System; - using System.Linq; - using System.Linq.Expressions; - - using LOVE.NET.Data.Models; - - public interface ICountriesRepository - { - IQueryable WithAllInformation(Expression> func); - - IQueryable AllAsNoTracking(); - } -} diff --git a/Data/LOVE.NET.Data/Repositories/EfDeletableEntityRepository.cs b/Data/LOVE.NET.Data/Repositories/EfDeletableEntityRepository.cs deleted file mode 100644 index 7679c67..0000000 --- a/Data/LOVE.NET.Data/Repositories/EfDeletableEntityRepository.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace LOVE.NET.Data.Repositories -{ - using System; - using System.Linq; - - using LOVE.NET.Data.Common.Models; - using LOVE.NET.Data.Common.Repositories; - - using Microsoft.EntityFrameworkCore; - - public class EfDeletableEntityRepository : EfRepository, IDeletableEntityRepository - where TEntity : class, IDeletableEntity - { - public EfDeletableEntityRepository(ApplicationDbContext context) - : base(context) - { - } - - public override IQueryable All() => base.All().Where(x => !x.IsDeleted); - - public override IQueryable AllAsNoTracking() => base.AllAsNoTracking().Where(x => !x.IsDeleted); - - public IQueryable AllWithDeleted() => base.All().IgnoreQueryFilters(); - - public IQueryable AllAsNoTrackingWithDeleted() => base.AllAsNoTracking().IgnoreQueryFilters(); - - public void HardDelete(TEntity entity) => base.Delete(entity); - - public void Undelete(TEntity entity) - { - entity.IsDeleted = false; - entity.DeletedOn = null; - this.Update(entity); - } - - public override void Delete(TEntity entity) - { - entity.IsDeleted = true; - entity.DeletedOn = DateTime.UtcNow; - this.Update(entity); - } - } -} diff --git a/Data/LOVE.NET.Data/Repositories/EfRepository.cs b/Data/LOVE.NET.Data/Repositories/EfRepository.cs deleted file mode 100644 index d814413..0000000 --- a/Data/LOVE.NET.Data/Repositories/EfRepository.cs +++ /dev/null @@ -1,59 +0,0 @@ -namespace LOVE.NET.Data.Repositories -{ - using System; - using System.Linq; - using System.Threading.Tasks; - - using LOVE.NET.Data.Common.Repositories; - - using Microsoft.EntityFrameworkCore; - - public class EfRepository : IRepository - where TEntity : class - { - public EfRepository(ApplicationDbContext context) - { - this.Context = context ?? throw new ArgumentNullException(nameof(context)); - this.DbSet = this.Context.Set(); - } - - protected DbSet DbSet { get; set; } - - protected ApplicationDbContext Context { get; set; } - - public virtual IQueryable All() => this.DbSet; - - public virtual IQueryable AllAsNoTracking() => this.DbSet.AsNoTracking(); - - public virtual Task AddAsync(TEntity entity) => this.DbSet.AddAsync(entity).AsTask(); - - public virtual void Update(TEntity entity) - { - var entry = this.Context.Entry(entity); - if (entry.State == EntityState.Detached) - { - this.DbSet.Attach(entity); - } - - entry.State = EntityState.Modified; - } - - public virtual void Delete(TEntity entity) => this.DbSet.Remove(entity); - - public Task SaveChangesAsync() => this.Context.SaveChangesAsync(); - - public void Dispose() - { - this.Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - this.Context?.Dispose(); - } - } - } -} diff --git a/Data/LOVE.NET.Data/Repositories/Users/IUsersRepository.cs b/Data/LOVE.NET.Data/Repositories/Users/IUsersRepository.cs deleted file mode 100644 index 3c076aa..0000000 --- a/Data/LOVE.NET.Data/Repositories/Users/IUsersRepository.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace LOVE.NET.Data.Repositories.Users -{ - using System; - using System.Linq; - using System.Linq.Expressions; - - using LOVE.NET.Data.Common.Repositories; - - using LOVE.NET.Data.Models; - - public interface IUsersRepository : IDeletableEntityRepository - { - IQueryable WithAllInformation(); - - IQueryable WithAllInformation( - Expression> func); - } -} diff --git a/Data/LOVE.NET.Data/Repositories/Users/UsersRepository.cs b/Data/LOVE.NET.Data/Repositories/Users/UsersRepository.cs deleted file mode 100644 index facf0f8..0000000 --- a/Data/LOVE.NET.Data/Repositories/Users/UsersRepository.cs +++ /dev/null @@ -1,72 +0,0 @@ -namespace LOVE.NET.Data.Repositories.Users -{ - using System; - using System.Linq; - using System.Linq.Expressions; - - using LOVE.NET.Data.Models; - - using Microsoft.EntityFrameworkCore; - - public class UsersRepository : EfDeletableEntityRepository, IUsersRepository - { - public UsersRepository(ApplicationDbContext context) - : base(context) - { - } - - public IQueryable WithAllInformation() - { - return this.Context.Users - .Include(u => u.City) - .Include(u => u.Country) - .Include(u => u.Images - .Where(i => !i.IsDeleted) - .OrderByDescending(i => i.IsProfilePicture)) - .Include(u => u.LikesSent) - .ThenInclude(u => u.LikedUser) - .ThenInclude(lu => lu.City) - .ThenInclude(lu => lu.Country) - .Include(u => u.LikesSent) - .ThenInclude(u => u.LikedUser) - .ThenInclude(lu => lu.Gender) - .Include(u => u.LikesSent) - .ThenInclude(u => u.LikedUser) - .ThenInclude(lu => lu.Images) - .Include(u => u.LikesSent) - .Where(u => !u.IsDeleted) - .Include(u => u.RefreshTokens) - .Include(u => u.LikesReceived) - .Include(lu => lu.Gender) - .Include(u => u.Roles); - } - - public IQueryable WithAllInformation( - Expression> func) - { - return this.Context.Users - .Include(u => u.City) - .Include(u => u.Country) - .Include(u => u.Images - .Where(i => !i.IsDeleted) - .OrderByDescending(i => i.IsProfilePicture)) - .Include(u => u.LikesSent) - .ThenInclude(u => u.LikedUser) - .ThenInclude(lu => lu.City) - .ThenInclude(lu => lu.Country) - .Include(u => u.LikesSent) - .ThenInclude(u => u.LikedUser) - .ThenInclude(lu => lu.Gender) - .Include(u => u.LikesSent) - .ThenInclude(u => u.LikedUser) - .ThenInclude(lu => lu.Images) - .Include(u => u.LikesSent) - .Where(u => !u.IsDeleted) - .Include(u => u.RefreshTokens) - .Include(u => u.LikesReceived) - .Include(lu => lu.Gender) - .Include(u => u.Roles) - .Where(func); - } - } -} diff --git a/Data/LOVE.NET.Data/Seeding/ApplicationDbContextSeeder.cs b/Data/LOVE.NET.Data/Seeding/ApplicationDbContextSeeder.cs deleted file mode 100644 index 7d86039..0000000 --- a/Data/LOVE.NET.Data/Seeding/ApplicationDbContextSeeder.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace LOVE.NET.Data.Seeding -{ - using System; - using System.Collections.Generic; - using System.Threading.Tasks; - - using Microsoft.Extensions.DependencyInjection; - using Microsoft.Extensions.Logging; - - public class ApplicationDbContextSeeder : ISeeder - { - public async Task SeedAsync(ApplicationDbContext dbContext, IServiceProvider serviceProvider) - { - if (dbContext == null) - { - throw new ArgumentNullException(nameof(dbContext)); - } - - if (serviceProvider == null) - { - throw new ArgumentNullException(nameof(serviceProvider)); - } - - var logger = serviceProvider.GetService().CreateLogger(typeof(ApplicationDbContextSeeder)); - - var seeders = new List - { - new RolesSeeder(), - new CountriesSeeder(), - new GendersSeeder(), - new UsersSeeder(), - new ChatroomsSeeder(), - }; - - foreach (var seeder in seeders) - { - await seeder.SeedAsync(dbContext, serviceProvider); - await dbContext.SaveChangesAsync(); - logger.LogInformation($"Seeder {seeder.GetType().Name} done."); - } - } - } -} diff --git a/Data/LOVE.NET.Data/Seeding/ChatroomsSeeder.cs b/Data/LOVE.NET.Data/Seeding/ChatroomsSeeder.cs deleted file mode 100644 index b8d1ed0..0000000 --- a/Data/LOVE.NET.Data/Seeding/ChatroomsSeeder.cs +++ /dev/null @@ -1,41 +0,0 @@ -namespace LOVE.NET.Data.Seeding -{ - using System; - using System.IO; - using System.Linq; - using System.Threading.Tasks; - - using LOVE.NET.Data.Models; - - using Newtonsoft.Json; - - using static LOVE.NET.Common.GlobalConstants; - using static LOVE.NET.Common.GlobalConstants.FilePaths; - - public class ChatroomsSeeder : ISeeder - { - public async Task SeedAsync(ApplicationDbContext dbContext, IServiceProvider serviceProvider) - { - var isPopulated = dbContext.Chatrooms.Any(); - - if (isPopulated) - { - return; - } - - var filePath = Path.Combine( - OneDirectoryUp, - Src, - Data, - SystemNameData, - Files, - ChatroomsFileName); - - var jsonString = File.ReadAllText(filePath); - var users = JsonConvert.DeserializeObject(jsonString); - - await dbContext.Chatrooms.AddRangeAsync(users); - await dbContext.SaveChangesAsync(); - } - } -} diff --git a/Data/LOVE.NET.Data/Seeding/CountriesSeeder.cs b/Data/LOVE.NET.Data/Seeding/CountriesSeeder.cs deleted file mode 100644 index f420251..0000000 --- a/Data/LOVE.NET.Data/Seeding/CountriesSeeder.cs +++ /dev/null @@ -1,104 +0,0 @@ -namespace LOVE.NET.Data.Seeding -{ - using System; - using System.Globalization; - using System.IO; - using System.Linq; - using System.Threading.Tasks; - - using CsvHelper; - using CsvHelper.Configuration; - - using LOVE.NET.Data.Models; - - using static LOVE.NET.Common.GlobalConstants; - using static LOVE.NET.Common.GlobalConstants.FilePaths; - - internal class CountriesSeeder : ISeeder - { - public async Task SeedAsync(ApplicationDbContext dbContext, IServiceProvider serviceProvider) - { - var isPopulated = dbContext.Countries.Any(); - - if (isPopulated) - { - return; - } - - await this.SeedCountriesAsync(dbContext); - await this.SeedCitiesAsync(dbContext); - } - - private async Task SeedCountriesAsync(ApplicationDbContext dbContext) - { - var filePath = Path.Combine( - OneDirectoryUp, - Src, - Data, - SystemNameData, - Files, - CountriesFileName); - - var config = new CsvConfiguration(CultureInfo.InvariantCulture) - { - Delimiter = ";", - }; - - using (var reader = new StreamReader(filePath)) - using (var csv = new CsvReader(reader, config)) - { - csv.Context.RegisterClassMap(); - var countries = csv.GetRecords().ToList(); - - await dbContext.Countries.AddRangeAsync(countries); - await dbContext.SaveChangesAsync(); - } - } - - private async Task SeedCitiesAsync(ApplicationDbContext dbContext) - { - var filePath = Path.Combine( - OneDirectoryUp, - Src, - Data, - SystemNameData, - Files, - CitiesWithCountriesFileName); - - var config = new CsvConfiguration(CultureInfo.InvariantCulture) - { - Delimiter = ";", - }; - - using (var reader = new StreamReader(filePath)) - using (var csv = new CsvReader(reader, config)) - { - csv.Context.RegisterClassMap(); - var cities = csv.GetRecords().ToList(); - - await dbContext.Cities.AddRangeAsync(cities); - } - } - - private sealed class CountriesCsvMap : ClassMap - { - private CountriesCsvMap() - { - this.Map(m => m.Id).Ignore(); - this.Map(m => m.Name); - } - } - - private sealed class CitiesCsvMap : ClassMap - { - private CitiesCsvMap() - { - this.Map(m => m.Name); - this.Map(m => m.NameAscii); - this.Map(m => m.Latitude); - this.Map(m => m.Longitude); - this.Map(m => m.CountryId); - } - } - } -} diff --git a/Data/LOVE.NET.Data/Seeding/GendersSeeder.cs b/Data/LOVE.NET.Data/Seeding/GendersSeeder.cs deleted file mode 100644 index 7d3a1e7..0000000 --- a/Data/LOVE.NET.Data/Seeding/GendersSeeder.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace LOVE.NET.Data.Seeding -{ - using System; - using System.Linq; - using System.Threading.Tasks; - - using LOVE.NET.Data.Models; - - using static LOVE.NET.Common.GlobalConstants.GenderConstants; - - public class GendersSeeder : ISeeder - { - public async Task SeedAsync(ApplicationDbContext dbContext, IServiceProvider serviceProvider) - { - var isPopulated = dbContext.Genders.Any(); - - if (!isPopulated) - { - Gender[] genders = - { - new Gender() - { - Name = Male, - }, - new Gender() - { - Name = Female, - }, - new Gender() - { - Name = Other, - }, - }; - - await dbContext.Genders.AddRangeAsync(genders); - } - } - } -} diff --git a/Data/LOVE.NET.Data/Seeding/ISeeder.cs b/Data/LOVE.NET.Data/Seeding/ISeeder.cs deleted file mode 100644 index f17205e..0000000 --- a/Data/LOVE.NET.Data/Seeding/ISeeder.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace LOVE.NET.Data.Seeding -{ - using System; - using System.Threading.Tasks; - - public interface ISeeder - { - Task SeedAsync(ApplicationDbContext dbContext, IServiceProvider serviceProvider); - } -} diff --git a/Data/LOVE.NET.Data/Seeding/RolesSeeder.cs b/Data/LOVE.NET.Data/Seeding/RolesSeeder.cs deleted file mode 100644 index 1dff353..0000000 --- a/Data/LOVE.NET.Data/Seeding/RolesSeeder.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace LOVE.NET.Data.Seeding -{ - using System; - using System.Linq; - using System.Threading.Tasks; - - using LOVE.NET.Common; - using LOVE.NET.Data.Models; - - using Microsoft.AspNetCore.Identity; - using Microsoft.Extensions.DependencyInjection; - - internal class RolesSeeder : ISeeder - { - public async Task SeedAsync(ApplicationDbContext dbContext, IServiceProvider serviceProvider) - { - var roleManager = serviceProvider.GetRequiredService>(); - - await SeedRoleAsync(roleManager, GlobalConstants.AdministratorRoleName); - } - - private static async Task SeedRoleAsync(RoleManager roleManager, string roleName) - { - var role = await roleManager.FindByNameAsync(roleName); - if (role == null) - { - var result = await roleManager.CreateAsync(new ApplicationRole(roleName)); - if (!result.Succeeded) - { - throw new Exception(string.Join(Environment.NewLine, result.Errors.Select(e => e.Description))); - } - } - } - } -} diff --git a/Data/LOVE.NET.Data/Seeding/UsersSeeder.cs b/Data/LOVE.NET.Data/Seeding/UsersSeeder.cs deleted file mode 100644 index 6ce727b..0000000 --- a/Data/LOVE.NET.Data/Seeding/UsersSeeder.cs +++ /dev/null @@ -1,46 +0,0 @@ -namespace LOVE.NET.Data.Seeding -{ - using System; - using System.IO; - using System.Linq; - using System.Threading.Tasks; - - using LOVE.NET.Data.Models; - - using Microsoft.AspNetCore.Identity; - - using Newtonsoft.Json; - - using static LOVE.NET.Common.GlobalConstants; - using static LOVE.NET.Common.GlobalConstants.FilePaths; - - internal class UsersSeeder : ISeeder - { - public async Task SeedAsync(ApplicationDbContext dbContext, IServiceProvider serviceProvider) - { - var isPopulated = dbContext.Users.Any(); - - if (isPopulated) - { - return; - } - - var filePath = Path.Combine( - OneDirectoryUp, - Src, - Data, - SystemNameData, - Files, - UsersFileName); - - var jsonString = File.ReadAllText(filePath); - var users = JsonConvert.DeserializeObject(jsonString); - - await dbContext.Users.AddRangeAsync(users); - await dbContext.SaveChangesAsync(); - - var userManager = (UserManager)serviceProvider.GetService(typeof(UserManager)); - await userManager.AddToRoleAsync(users[0], AdministratorRoleName); - } - } -} diff --git a/Data/LOVE.NET.Data/appsettings.json b/Data/LOVE.NET.Data/appsettings.json deleted file mode 100644 index eea20b5..0000000 --- a/Data/LOVE.NET.Data/appsettings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "ConnectionStrings": { - "DefaultConnection": "Server=.;Database=LOVE.NET;Trusted_Connection=True;MultipleActiveResultSets=true" - } -} diff --git a/LOVE.NET.Common/GlobalConstants.cs b/LOVE.NET.Common/GlobalConstants.cs deleted file mode 100644 index 84a9eb6..0000000 --- a/LOVE.NET.Common/GlobalConstants.cs +++ /dev/null @@ -1,170 +0,0 @@ -namespace LOVE.NET.Common -{ - public static class GlobalConstants - { - public const string SystemName = "LOVE.NET"; - - public const string AdministratorRoleName = "Administrator"; - - public const string AdministratorEmail = "admin@admin.admin"; - - public const string Data = "Data"; - - public const string JWT = "JWT"; - - public const string AuthSettingsKey = "AuthSettings:Key"; - - public const string AuthSettingsIssuer = "AuthSettings:Issuer"; - - public const string AuthSettingsAudience = "AuthSettings:Audience"; - - public const string UrlBase = "Url:Base"; - - public const string SendGridApiKey = "SendGrid:ApiKey"; - - public const string CloudinaryCloudName = "Cloudinary:CloudName"; - - public const string CloudinaryKey = "Cloudinary:Key"; - - public const string CloudinarySecret = "Cloudinary:Secret"; - - public const string RefreshTokenValue = "refreshToken"; - - public const string Error = "Error"; - - public const string Unauthorized = "Unauthorized"; - - public const int CitiesMaxCountInDb = 42905; - - public const int CountriesMaxCountInDb = 239; - - public const int GendersMaxCountInDb = 3; - - public const int MinimalAge = 18; - - public const int MaxFileSizeInBytes = 20 * 1024 * 1024; - - public const int DefaultTake = 10; - - public const string DefaultProfilePictureUrl = "https://res.cloudinary.com/dojl8gfnd/image/upload/v1666714042/24-248253_user-profile-default-image-png-clipart-png-download_tuamuf.png"; - - public class JWTSecurityScheme - { - public const string JWTScheme = "bearer"; - public const string JWTName = "JWT Authentication"; - public const string JWTDescription = "Put **_ONLY_** your JWT Bearer token on textbox below!"; - } - - public class FilePaths - { - public const string OneDirectoryUp = ".."; - - public const string Src = "src"; - - public const string SystemNameData = $"{SystemName}.{Data}"; - - public const string Files = "Files"; - - public const string CitiesWithCountriesFileName = "CitiesWithCountries.csv"; - - public const string CountriesFileName = "Countries.csv"; - - public const string UsersFileName = "Users.json"; - - public const string ChatroomsFileName = "Chatrooms.json"; - } - - public class ControllerRoutesConstants - { - public const string Api = "api/"; - public const string HeaderOrigin = "origin"; - - public const string IdentityControllerName = Api + "identity"; - public const string RegisterRoute = "register"; - public const string LoginRoute = "login"; - public const string LogoutRoute = "logout"; - public const string RefreshTokenRoute = "refreshToken"; - public const string AccountRoute = "account/{id:guid}"; - - public const string EmailControllerName = Api + "email"; - public const string SendResetPasswordLinkLinkRoute = "sendResetPasswordLink"; - public const string ResetPasswordRoute = "resetPassword"; - public const string VerifyEmailRoute = "verify"; - public const string ResendEmailConfirmationLinkRoute = "resendEmailConfirmationLink"; - - public const string CountriesControllerName = Api + "countries"; - public const string ById = "{id:int}"; - - public const string GendersControllerName = Api + "genders"; - - public const string DatingControllerName = Api + "dating"; - public const string MatchesRoute = "matches"; - public const string LikeRoute = "like/{id:guid}"; - - public const string ChatControllerName = Api + "chat"; - public const string ByRoomId = "room/{id}"; - public const string ChatRooms = "chatrooms"; - - public const string DashboardControllerName = Api + "admin/dashboard"; - public const string UsersRoute = "users"; - public const string ModerateRoute = "moderate"; - } - - public class ControllerResponseMessages - { - public const string EmailAlreadyInUse = "Email already in use"; - public const string InvalidEmail = "Invalid email"; - public const string PasswordsDontMatch = "Password and confirm password must match"; - public const string UnderagedUser = "You need to be 18 years old to register"; - public const string LogoutSuccessfully = "Logout successfully"; - public const string FillAllTheInformation = "Fill all the information"; - public const string UserNotFound = "User not found"; - public const string UserCouldNotBeBanned = "User could not be banned"; - public const string CantBanUserInThePast = "Can't ban user in the past"; - public const string CantBanYourself = "You can't ban yourself"; - public const string YouCantLikeYourself = "You can't like yourself"; - public const string UserAlreadyLiked = "User is already liked"; - public const string WrongPassword = "Wrong password"; - public const string BannedUnitl = "You are banned until {0}"; - public const string WrongOldPassword = "Old password is invalid"; - public const string ChangePasswordsDontMatch = "New password and confirm password must match"; - public const string PasswordChangedSuccessfully = "Password changed successfully"; - public const string InvalidCity = "Invalid city"; - public const string InvalidCountry = "Invalid country"; - public const string UnsupportedFileType = "Unsupported file type"; - public const string MaxFileSizeReached = "Max file size reached"; - public const string InvalidGender = "Invalid gender"; - } - - public class EmailMessagesConstants - { - public const string IncorrectEmail = "Your email is incorrect"; - public const string EmailConfirmed = "Email confirmed - you can now login"; - public const string CheckYourEmail = "Check your email"; - public const string PasswordResetSuccessful = "Password reset is successful - you can now login"; - public const string EmailDoesntMatch = "This is not the email you registered with"; - public const string EmailNotConfirmed = "Please, check your email and verify your account"; - public const string EmailAlreadyVerified = "Your email is already verified"; - } - - public class EmailSenderConstants - { - public const string FromEmail = "parentassistantapi@abv.bg"; - public const string FromName = "ParentAssistantApi"; - public const string VerifyEmailSubject = "Verify Email"; - public const string PasswordResetEmailSubject = "Reset Password"; - public const string VerifyUrl = "{0}/{1}?token={2}&email={3}"; - public const string Templates = "templates"; - public const string Email = "email"; - public const string VerifyHtml = "verify.html"; - public const string ResetPasswordHtml = "resetPassword.html"; - } - - public class GenderConstants - { - public const string Male = "Male"; - public const string Female = "Female"; - public const string Other = "Other"; - } - } -} diff --git a/LOVE.NET.Common/Helpers/DateHelper.cs b/LOVE.NET.Common/Helpers/DateHelper.cs deleted file mode 100644 index 5b15e24..0000000 --- a/LOVE.NET.Common/Helpers/DateHelper.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace LOVE.NET.Web.Common.Helpers -{ - using System; - - public static class DateHelper - { - public static int AgeCalculator(DateTime birthdate) - { - var today = DateTime.UtcNow; - - var age = today.Year - birthdate.Year; - - // Leap years calculation - if (birthdate > today.AddYears(-age)) - { - age--; - } - - return age; - } - } -} diff --git a/LOVE.NET.Common/LOVE.NET.Common.csproj b/LOVE.NET.Common/LOVE.NET.Common.csproj deleted file mode 100644 index e64c009..0000000 --- a/LOVE.NET.Common/LOVE.NET.Common.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - net8.0 - latest - - - - ..\Rules.ruleset - - - - - - - - runtime; build; native; contentfiles; analyzers - - - - \ No newline at end of file diff --git a/LOVE.NET.Common/Result.cs b/LOVE.NET.Common/Result.cs deleted file mode 100644 index 0d41754..0000000 --- a/LOVE.NET.Common/Result.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace LOVE.NET.Common -{ - public class Result - { - public bool Succeeded { get; private set; } - - public bool Failure => !this.Succeeded; - - public string[] Errors { get; private set; } - - public static implicit operator Result(bool succeeded) - => new Result { Succeeded = succeeded }; - - public static implicit operator Result(string error) - => new Result - { - Succeeded = false, - Errors = new[] { error }, - }; - } -} diff --git a/LOVE.NET.Services.Tests/ChatServiceTests.cs b/LOVE.NET.Services.Tests/ChatServiceTests.cs deleted file mode 100644 index 75b5e72..0000000 --- a/LOVE.NET.Services.Tests/ChatServiceTests.cs +++ /dev/null @@ -1,83 +0,0 @@ -namespace LOVE.NET.Services.Tests -{ - using LOVE.NET.Data.Models; - using LOVE.NET.Data.Repositories.Chat; - using LOVE.NET.Services.Chats; - using LOVE.NET.Web.ViewModels.Chat; - - using Microsoft.EntityFrameworkCore; - - using static LOVE.NET.Common.GlobalConstants; - - public class ChatServiceTests : TestsSetUp - { - private IChatService chatService; - private IChatRepository chatRepository; - - [SetUp] - public async Task Setup() - { - await GlobalInitialization(); - - chatRepository = GetIChatRepository(); - - chatService = new ChatService(chatRepository); - } - - [OneTimeTearDown] - public void TearDown() - { - dbContext.Database.EnsureDeleted(); - } - - [Test] - public void SuccessGetFirstPage() - { - var request = new ChatRequestViewModel() - { - RoomId = "666666666", - Page = 1, - }; - - var result = chatService.GetChat(request); - - Assert.That(result.Messages.Count(), Is.EqualTo(DefaultTake)); - } - - [Test] - public void SuccessGetSecondPage() - { - var request = new ChatRequestViewModel() - { - RoomId = "666666666", - Page = 2, - }; - - var lastMessage = messages.First(); - var result = chatService.GetChat(request); - - Assert.Multiple(() => - { - Assert.That(1, Is.EqualTo(result.Messages.Count())); - Assert.That(lastMessage.Text, Is.EqualTo(result.Messages.First().Text)); - }); - } - - [Test] - public async Task SuccessSendMessage() - { - var request = new MessageDto() - { - RoomId = "666666666", - UserId = "6666", - Text = "text12", - CreatedOn = DateTime.Now.AddYears(12), - }; - - await chatService.SaveMessageAsync(request); - var latestMessage = await dbContext.Messages.OrderByDescending(m => m.CreatedOn).FirstOrDefaultAsync(); - - Assert.That(latestMessage?.Text, Is.EqualTo(request.Text)); - } - } -} diff --git a/LOVE.NET.Services.Tests/CountryServiceTests.cs b/LOVE.NET.Services.Tests/CountryServiceTests.cs deleted file mode 100644 index 54ab900..0000000 --- a/LOVE.NET.Services.Tests/CountryServiceTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -namespace LOVE.NET.Services.Tests -{ - using LOVE.NET.Data.Repositories.Countries; - using LOVE.NET.Services.Countries; - - public class CountryServiceTests : TestsSetUp - { - private ICountriesService countriesService; - private ICountriesRepository countriesRepository; - - [SetUp] - public async Task Setup() - { - await GlobalInitialization(); - - countriesRepository = GetICountriesRepository(); - - countriesService = new CountriesService(countriesRepository); - } - - [OneTimeTearDown] - public void TearDown() - { - dbContext.Database.EnsureDeleted(); - } - - [Test] - public void SuccessGetAll() - { - var countriesCount = countries.Count; - var result = countriesService.GetAll(); - - Assert.That(countriesCount + 1, Is.EqualTo(result.Count())); - } - - [Test] - [TestCase()] - public void SuccessGetById() - { - var country = countries[0]; - var result = countriesService.Get(country.Id); - - Assert.Multiple(() => - { - Assert.That(country.Id, Is.EqualTo(result.CountryId)); - Assert.That(country.Name, Is.EqualTo(result.CountryName)); - Assert.That(country.Cities.Count + 1, Is.EqualTo(result.Cities.Count())); - }); - } - } -} diff --git a/LOVE.NET.Services.Tests/DashboardServiceTests.cs b/LOVE.NET.Services.Tests/DashboardServiceTests.cs deleted file mode 100644 index 9c0d807..0000000 --- a/LOVE.NET.Services.Tests/DashboardServiceTests.cs +++ /dev/null @@ -1,387 +0,0 @@ -namespace LOVE.NET.Services.Tests -{ - using LOVE.NET.Data.Common.Repositories; - using LOVE.NET.Data.Models; - using LOVE.NET.Data.Repositories.Users; - using LOVE.NET.Services.Dashboard; - using LOVE.NET.Web.ViewModels.Dashboard; - - using Microsoft.AspNetCore.Identity; - - using Moq; - - using static LOVE.NET.Common.GlobalConstants; - using static LOVE.NET.Common.GlobalConstants.ControllerResponseMessages; - - public class DashboardServiceTests : TestsSetUp - { - private IDashboardService dashboardService; - private IUsersRepository usersRepository; - private IRepository imagesRepository; - private IRepository messagesRepository; - private Mock> userManagerMock; - - [SetUp] - public async Task Setup() - { - await GlobalInitialization(); - - usersRepository = GetIUsersRepository(); - imagesRepository = GetIRepository(); - messagesRepository = GetIRepository(); - userManagerMock = GetUserManagerMock(); - - - dashboardService = new DashboardService( - usersRepository, - imagesRepository, - messagesRepository, - userManagerMock?.Object); - } - - [OneTimeTearDown] - public void TearDown() - { - dbContext.Database.EnsureDeleted(); - } - - [Test] - public void SuccessGetStatistics() - { - var usersCount = dbContext.Users.Count(); - var bannedUsersCount = 1; - var matchesCount = 2; - var likedUsersCount = 2; - var notSwipedUsersCount = usersCount - 2; - var imagesCount = dbContext.Images.Count(); - var messagesCount = dbContext.Messages.Count(); - - var result = dashboardService.GetStatistics(); - - Assert.Multiple(() => - { - Assert.That(result.UsersCount, Is.EqualTo(usersCount)); - Assert.That(result.BannedUsersCount, Is.EqualTo(bannedUsersCount)); - Assert.That(result.MatchesCount, Is.EqualTo(matchesCount)); - Assert.That(result.LikedUsersCount, Is.EqualTo(likedUsersCount)); - Assert.That(result.NotSwipedUsersCount, Is.EqualTo(notSwipedUsersCount)); - Assert.That(result.ImagesCount, Is.EqualTo(imagesCount)); - Assert.That(result?.MessagesCount, Is.EqualTo(messagesCount)); - }); - } - - [Test] - public async Task SuccessGetUsersNoSearchFirstPage() - { - var users = dbContext.Users; - - var request = new DashboardUserRequestViewModel() - { - ShowBanned = false, - Page = 1, - }; - - var loggedUserId = users.FirstOrDefault(u => u.Id == "6666")?.Id; - - var resultUsers = users - .Where(u => - u.Email != AdministratorEmail && - u.Id != loggedUserId); - var totalUsersCount = resultUsers.Count(); - - var result = await dashboardService.GetUsersAsync(request, loggedUserId); - - Assert.Multiple(() => - { - Assert.That(result.TotalUsers, Is.EqualTo(totalUsersCount)); - Assert.That(result.Users.Any(u => u.Id == loggedUserId), Is.EqualTo(false)); - Assert.That(result.Users.Any(u => u.Email == AdministratorEmail), Is.EqualTo(false)); - Assert.That(result.Users.Count(), Is.LessThanOrEqualTo(DefaultTake)); - }); - } - - [Test] - public async Task SuccessGetUsersNoSearchSecondPage() - { - var users = dbContext.Users; - - var request = new DashboardUserRequestViewModel() - { - ShowBanned = false, - Page = 2, - }; - - var loggedUserId = users.FirstOrDefault(u => u.Id == "6666").Id; - - var resultUsers = users - .Where(u => - u.Email != AdministratorEmail && - u.Id != loggedUserId); - var totalUsersCount = resultUsers.Count(); - - var result = await dashboardService.GetUsersAsync(request, loggedUserId); - - Assert.Multiple(() => - { - Assert.That(result.TotalUsers, Is.EqualTo(totalUsersCount)); - Assert.That(result.Users.Count(u => u.Id == loggedUserId), Is.EqualTo(0)); - Assert.That(result.Users.Count(u => u.Email == AdministratorEmail), Is.EqualTo(0)); - Assert.That(result.Users.Count(), Is.EqualTo(0)); - }); - } - - [Test] - public async Task SuccessGetBannedUsers() - { - var users = dbContext.Users; - - var request = new DashboardUserRequestViewModel() - { - ShowBanned = true, - Page = 2, - }; - - var loggedUserId = users.FirstOrDefault(u => u.Id == "6666")?.Id; - - var result = await dashboardService.GetUsersAsync(request, loggedUserId); - - Assert.That(result.Users.All(u => u.IsBanned)); - } - - [Test] - public async Task SuccessGetUsersSearchSecondPage() - { - var users = dbContext.Users; - - var request = new DashboardUserRequestViewModel() - { - ShowBanned = false, - Search = "search", - Page = 2, - }; - - var loggedUserId = users.FirstOrDefault(u => u.Id == "6666").Id; - - var result = await dashboardService.GetUsersAsync(request, loggedUserId); - - Assert.That(result.Users.Count(), Is.EqualTo(0)); - } - - [Test] - [TestCase("Misa4@abv.bg")] - [TestCase("MISA4@ABV.BG")] - [TestCase("misa4@abv.bg")] - [TestCase(" misa4@abv.bg ")] - public async Task SuccessGetUsersSearchEmail(string email) - { - var users = dbContext.Users; - - var request = new DashboardUserRequestViewModel() - { - ShowBanned = false, - Search = email, - Page = 1, - }; - - var loggedUserId = users?.FirstOrDefault(u => u.Id == "6666").Id; - - var result = await dashboardService.GetUsersAsync(request, loggedUserId); - - Assert.That(result.Users.All(u => u.Email.ToLower().Contains(email.Trim().ToLower())), Is.True); - } - - [Test] - [TestCase("Misa ")] - [TestCase("MISA")] - [TestCase(" misa")] - public async Task SuccessGetUsersSearchUserName(string userName) - { - var users = dbContext.Users; - - var request = new DashboardUserRequestViewModel() - { - ShowBanned = false, - Search = userName, - Page = 1, - }; - - var loggedUserId = users.FirstOrDefault(u => u.Id == "6666").Id; - - var result = await dashboardService.GetUsersAsync(request, loggedUserId); - - Assert.That(result.Users.All(u => u.UserName.ToLower().Contains(userName.Trim().ToLower()))); - } - - [Test] - [TestCase("UwU ")] - [TestCase("Uwu")] - [TestCase(" UWU")] - public async Task SuccessGetUsersSearchBio(string bio) - { - var users = dbContext.Users; - - var request = new DashboardUserRequestViewModel() - { - ShowBanned = false, - Search = bio, - Page = 1, - }; - - var loggedUserId = users.FirstOrDefault(u => u.Id == "6666").Id; - - var result = await dashboardService.GetUsersAsync(request, loggedUserId); - - Assert.That(result.Users.All(u => u.Bio.ToLower().Contains(bio.Trim().ToLower()))); - } - - [Test] - [TestCase("Svil")] - [TestCase("Svilengrad ")] - [TestCase("Sv")] - public async Task SuccessGetUsersSearchCity(string cityName) - { - var users = dbContext.Users; - - var request = new DashboardUserRequestViewModel() - { - ShowBanned = false, - Search = cityName, - Page = 1, - }; - - var loggedUserId = users.FirstOrDefault(u => u.Id == "6666").Id; - - var result = await dashboardService.GetUsersAsync(request, loggedUserId); - - Assert.That(result.Users.All(u => u.CityName.ToLower().Contains(cityName.Trim().ToLower()))); - } - - [Test] - [TestCase("Bul")] - [TestCase("bul ")] - [TestCase("b")] - [TestCase("BUL")] - public async Task SuccessGetUsersSearchCountry(string countryName) - { - var users = dbContext.Users; - - var request = new DashboardUserRequestViewModel() - { - ShowBanned = false, - Search = countryName, - Page = 1, - }; - - var loggedUserId = users.FirstOrDefault(u => u.Id == "6666").Id; - - var result = await dashboardService.GetUsersAsync(request, loggedUserId); - - Assert.That(result.Users.All(u => u.CountryName.ToLower().Contains(countryName.Trim().ToLower()))); - } - - [Test] - [TestCase("Male")] - [TestCase("Male ")] - [TestCase("male ")] - [TestCase(" MALE ")] - [TestCase("Female")] - [TestCase("FEMALE ")] - [TestCase("female ")] - [TestCase("female ")] - [TestCase("Other")] - [TestCase("Other ")] - [TestCase("trans ")] - [TestCase("OTHER ")] - public async Task SuccessGetUsersSearchGender(string genderName) - { - var users = dbContext.Users; - - var request = new DashboardUserRequestViewModel() - { - ShowBanned = false, - Search = genderName, - Page = 1, - }; - - var loggedUserId = users.FirstOrDefault(u => u.Id == "6666")?.Id; - - var result = await dashboardService.GetUsersAsync(request, loggedUserId); - - Assert.That(result.Users.All(u => u.GenderName.ToLower().Contains(genderName.Trim().ToLower()))); - } - - [Test] - public async Task SuccessBanUser() - { - var user = users.FirstOrDefault(u => u.Id == "6666"); - - var request = new ModerateUserViewModel() - { - BannedUntil = DateTime.UtcNow.AddYears(666), - UserId = "6666", - }; - - var result = await dashboardService.ModerateAsync(request); - var userFromDb = dbContext.Users.FirstOrDefault(u => u.Id == user.Id); - - Assert.Multiple(() => - { - Assert.That(result.Succeeded); - Assert.That(userFromDb?.LockoutEnd, Is.Not.EqualTo(null)); - }); - } - - [Test] - public async Task SuccessUnbanUser() - { - var user = users.FirstOrDefault(u => u.Id == "666666"); - - var request = new ModerateUserViewModel() - { - BannedUntil = null, - UserId = "666666", - }; - - var result = await dashboardService.ModerateAsync(request); - var userFromDb = dbContext.Users.FirstOrDefault(u => u.Id == user.Id); - - Assert.Multiple(() => - { - Assert.That(result.Succeeded); - Assert.That(userFromDb?.LockoutEnd, Is.EqualTo(null)); - }); - } - - [Test] - public async Task FailBanUnbanUser() - { - var request = new ModerateUserViewModel() - { - BannedUntil = null, - UserId = "666666666666", - }; - - var result = await dashboardService.ModerateAsync(request); - - Assert.That(result?.Errors, Does.Contain(UserNotFound)); - } - - [Test] - public async Task FailSetBanEndingDate() - { - userManagerMock.Setup(x => - x.SetLockoutEndDateAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(IdentityResult.Failed(new IdentityError())); - - var request = new ModerateUserViewModel() - { - BannedUntil = DateTime.UtcNow, - UserId = "6666", - }; - - var result = await dashboardService.ModerateAsync(request); - - Assert.That(result?.Errors, Does.Contain(UserCouldNotBeBanned)); - } - } -} diff --git a/LOVE.NET.Services.Tests/DatingServiceTests.cs b/LOVE.NET.Services.Tests/DatingServiceTests.cs deleted file mode 100644 index aed0904..0000000 --- a/LOVE.NET.Services.Tests/DatingServiceTests.cs +++ /dev/null @@ -1,152 +0,0 @@ -namespace LOVE.NET.Services.Tests -{ - using LOVE.NET.Data.Repositories.Users; - using LOVE.NET.Services.Dating; - using LOVE.NET.Web.ViewModels.Dating; - - using Microsoft.EntityFrameworkCore; - - using static LOVE.NET.Common.GlobalConstants.ControllerResponseMessages; - - public class DatingServiceTests : TestsSetUp - { - private IDatingService datingService; - private IUsersRepository usersRepository; - - [SetUp] - public async Task Setup() - { - await GlobalInitialization(); - usersRepository = GetIUsersRepository(); - - datingService = new DatingService(usersRepository); - } - - [OneTimeTearDown] - public void TearDown() - { - dbContext.Database.EnsureDeleted(); - } - - [Test] - public void SuccessGetNotSwipedUsers() - { - var totalUsers = users.Count; - var notSwipedUsers = datingService.GetNotSwipedUsers("6666"); - - // Excluding itself and the admin and it's like - var expectedNotSwiped = users.Count - 3; - Assert.Multiple(() => - { - Assert.That(notSwipedUsers.Count(), Is.EqualTo(expectedNotSwiped)); - Assert.That(notSwipedUsers.Count(u => u.UserName == "admin"), Is.EqualTo(0)); - Assert.That(notSwipedUsers.Count(u => u.Id == "6666"), Is.EqualTo(0)); - }); - } - - [Test] - public void SuccessGetMatchesFirstPage() - { - var request = new MatchesRequestViewModel() - { - UserId = "6666", - Page = 1, - }; - - var result = datingService.GetMatches(request); - - Assert.Multiple(() => - { - Assert.That(result.TotalMatches, Is.EqualTo(1)); - Assert.That(result.Matches.Count(), Is.EqualTo(1)); - }); - } - - [Test] - public void SuccessGetMatchesSecondPage() - { - var request = new MatchesRequestViewModel() - { - UserId = "6666", - Page = 2, - }; - - var result = datingService.GetMatches(request); - - Assert.Multiple(() => - { - Assert.That(result.TotalMatches, Is.EqualTo(0)); - Assert.That(result.Matches.Count(), Is.EqualTo(0)); - }); - } - - [Test] - public void SuccessGetMatcheModelOnLike() - { - var result = datingService.GetCurrentMatch("66666"); - - Assert.That(result.Id, Is.EqualTo("66666")); - } - - [Test] - public async Task SuccessLikeUserNoMatch() - { - var users = dbContext.Users.Include(u => u.LikesSent).Where(u => u.Id == "6666" || u.Id == "77777"); - var result = await datingService.LikeAsync("6666", "77777"); - - var isUserLiked = users.FirstOrDefault(u => u.Id == "6666")?.LikesSent - .Any(u => u.LikedUserId == "77777"); - - Assert.Multiple(() => - { - Assert.That(result.Failure, Is.True); - Assert.That(isUserLiked, Is.True); - }); - } - - [Test] - public async Task SuccessLikeUserMatch() - { - var users = dbContext.Users.Include(u => u.LikesSent).Where(u => u.Id == "6666" || u.Id == "77777"); - var firstLike = await datingService.LikeAsync("6666", "77777"); - var matchLike = await datingService.LikeAsync("77777", "6666"); - - var isMatch = users?.FirstOrDefault(u => u.Id == "77777")?.LikesSent.Any(ls => ls.LikedUserId == "6666"); - Assert.Multiple(() => - { - Assert.That(firstLike.Failure, Is.True); - Assert.That(matchLike.Succeeded, Is.True); - Assert.That(isMatch, Is.True); - }); - } - - [Test] - public async Task FailLikeUserNotExisting() - { - var result = await datingService.LikeAsync("6666", "88888"); - Assert.Multiple(() => - { - Assert.That(result.Errors, Is.Not.Empty); - Assert.That(result.Errors, Does.Contain(UserNotFound)); - }); - } - - [Test] - public async Task FailLikeUserAgain() - { - var result = await datingService.LikeAsync("6666", "66666"); - - Assert.That(result.Errors, Is.Not.Empty); - Assert.That(result.Errors, Does.Contain(UserAlreadyLiked)); - } - - [Test] - public async Task FailLikeUserLikingYourself() - { - var result = await datingService.LikeAsync("6666", "6666"); - - Assert.That(result.Errors, Is.Not.Empty); - Assert.That(result.Errors, Does.Contain(YouCantLikeYourself)); - } - } -} diff --git a/LOVE.NET.Services.Tests/EmailServiceTests.cs b/LOVE.NET.Services.Tests/EmailServiceTests.cs deleted file mode 100644 index 357ed34..0000000 --- a/LOVE.NET.Services.Tests/EmailServiceTests.cs +++ /dev/null @@ -1,125 +0,0 @@ -namespace LOVE.NET.Services.Tests -{ - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Email; - using LOVE.NET.Services.Messaging; - - using Microsoft.AspNetCore.Hosting; - using Microsoft.AspNetCore.Identity; - - using Moq; - - - using static LOVE.NET.Common.GlobalConstants; - using static LOVE.NET.Common.GlobalConstants.EmailMessagesConstants; - - public class EmailServiceTests : TestsSetUp - { - private IEmailService emailService; - private Mock> userManagerMock; - private IEmailSender emailSender; - private IWebHostEnvironment environment; - - [SetUp] - public async Task Setup() - { - await GlobalInitialization(); - - userManagerMock = GetUserManagerMock(); - emailSender = new Mock(MockToken).Object; - var environmentMock = new Mock(); - - environmentMock.Setup(x => - x.WebRootPath) - .Returns(MockPath); - - environment = environmentMock.Object; - - emailService = new EmailService( - userManagerMock.Object, - emailSender, - environment); - } - - [OneTimeTearDown] - public void TearDown() - { - dbContext.Database.EnsureDeleted(); - } - - [Test] - public void SuccessSendEmailConfirmation() - { - var user = dbContext.Users.FirstOrDefault(); - - Assert.DoesNotThrowAsync(async () => await emailService.SendEmailConfirmationAsync("origin", user)); - } - - [Test] - public async Task SuccessVerifyEmail() - { - var user = dbContext.Users.FirstOrDefault(u => u.Id == "6666666"); - - var result = await emailService.VerifyEmailAsync(user?.Email, MockToken); - - var updatedUser = dbContext.Users.FirstOrDefault(u => u.Id == user.Id); - - Assert.Multiple(() => - { - Assert.That(result.Succeeded, Is.True); - Assert.That(updatedUser?.EmailConfirmed, Is.True); - }); - } - - [Test] - public async Task FailVerifyEmailUnauthorized() - { - var result = await emailService.VerifyEmailAsync("66666", MockToken); - - Assert.That(result.Errors, Is.Not.Empty); - Assert.That(result.Errors, Does.Contain(Unauthorized)); - } - - [Test] - public async Task FailVerifyEmailIncorrectEmail() - { - userManagerMock.Setup(x => - x.ConfirmEmailAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(IdentityResult.Failed(new IdentityError())); - - var user = dbContext.Users.FirstOrDefault(); - - var result = await emailService.VerifyEmailAsync(user?.Email, MockToken); - - Assert.That(result.Errors, Is.Not.Empty); - Assert.That(result.Errors, Does.Contain(IncorrectEmail)); - } - - [Test] - public async Task SuccessResendEmailConfirmationLink() - { - var user = dbContext.Users.FirstOrDefault(u => u.Id == "6666666"); - - var result = await emailService.ResendEmailConfirmationLinkAsync(user?.Email, "origin"); - Assert.That(result.Succeeded, Is.True); - } - - [Test] - public async Task FailResendEmailConfirmationLinkChangedEmailInTheUrl() - { - var result = await emailService.ResendEmailConfirmationLinkAsync("changedEmail", "origin"); - Assert.That(result.Errors, Is.Not.Empty); - Assert.That(result.Errors, Does.Contain(EmailDoesntMatch)); - } - - [Test] - public async Task FailResendEmailConfirmationLinkAlreadyVerified() - { - var user = dbContext.Users.FirstOrDefault(x => x.LockoutEnabled); - - var result = await emailService.ResendEmailConfirmationLinkAsync(user?.Email, "origin"); - Assert.That(result.Errors, Is.Not.Empty); - Assert.That(result.Errors, Does.Contain(EmailAlreadyVerified)); - } - } -} diff --git a/LOVE.NET.Services.Tests/GendersServiceTests.cs b/LOVE.NET.Services.Tests/GendersServiceTests.cs deleted file mode 100644 index e360186..0000000 --- a/LOVE.NET.Services.Tests/GendersServiceTests.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace LOVE.NET.Services.Tests -{ - using LOVE.NET.Data.Common.Repositories; - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Genders; - - public class GendersServiceTests : TestsSetUp - { - private IGendersService gendersService; - private IRepository gendersRepository; - - [SetUp] - public async Task Setup() - { - await GlobalInitialization(); - - gendersRepository = GetIRepository(); - - gendersService = new GendersService(gendersRepository); - } - - [OneTimeTearDown] - public void TearDown() - { - dbContext.Database.EnsureDeleted(); - } - - [Test] - public void SuccessGetAll() - { - var genders = dbContext.Genders; - - var result = gendersService.GetAll(); - - Assert.That(genders.Count(), Is.EqualTo(result.Count())); - - foreach (var gender in genders) - { - Assert.That(result.Select(x => x?.Id).Contains(gender.Id)); - } - } - } -} diff --git a/LOVE.NET.Services.Tests/IdentityServiceTests.cs b/LOVE.NET.Services.Tests/IdentityServiceTests.cs deleted file mode 100644 index c907c9a..0000000 --- a/LOVE.NET.Services.Tests/IdentityServiceTests.cs +++ /dev/null @@ -1,298 +0,0 @@ -namespace LOVE.NET.Services.Tests -{ - using LOVE.NET.Data.Models; - using LOVE.NET.Data.Repositories.Users; - using LOVE.NET.Services.Identity; - using LOVE.NET.Services.Images; - using LOVE.NET.Services.Mapping; - using LOVE.NET.Web.ViewModels.Identity; - - using Microsoft.AspNetCore.Http; - using Microsoft.AspNetCore.Identity; - using Microsoft.Extensions.Configuration; - - using Moq; - - using static LOVE.NET.Common.GlobalConstants; - using static LOVE.NET.Common.GlobalConstants.ControllerResponseMessages; - using static LOVE.NET.Common.GlobalConstants.EmailMessagesConstants; - - public class IdentityServiceTests : TestsSetUp - { - private IIdentityService identityService; - private Mock> userManagerMock; - private IConfigurationRoot configuration; - private IUsersRepository usersRepository; - private IImagesService imagesService; - - [SetUp] - public async Task Setup() - { - await GlobalInitialization(); - - userManagerMock = GetUserManagerMock(); - configuration = GetIConfiguration(); - usersRepository = GetIUsersRepository(); - imagesService = GetIImagesService(); - - identityService = new IdentityService( - userManagerMock.Object, - configuration, - usersRepository, - imagesService); - } - - [OneTimeTearDown] - public void TearDown() - { - dbContext.Database.EnsureDeleted(); - } - - [Test] - public async Task SuccessGenerateJwtToken() - { - var user = users[2]; - var token = await identityService.GenerateJwtToken(user); - - Assert.That(token, Is.Not.Null); - } - - [Test] - public async Task FailRegister() - { - userManagerMock.Setup(x => - x.CreateAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(IdentityResult.Failed(new IdentityError())); - - var request = new RegisterViewModel() - { - Email = "valid@email.com", - Password = "666", - ConfirmPassword = "666", - UserName = "valid@email.com", - Bio = "bio", - Birthdate = DateTime.UtcNow.AddYears(-19), - CityId = 5878, - CountryId = 30, - GenderId = 1, - }; - - var response = await identityService.RegisterAsync(request); - - Assert.That(response.Errors.Any(), Is.True); - } - - - //[TestCase("admin@admin.admin", "password", "password", "userName", "bio", "12.30.2000", 5878, 30, 1)] - //[TestCase("email", "password", "password", "admin", "bio", "12.30.2000", 5878, 30, 1)] - [Test] - public async Task SuccessRegister() - { - var request = new RegisterViewModel() - { - Email = "email@email.email", - Password = "666", - ConfirmPassword = "666", - UserName = "userName", - Bio = "bio", - Birthdate = DateTime.UtcNow.AddYears(-19), - CityId = 5878, - CountryId = 30, - GenderId = 1, - }; - - var response = await identityService.RegisterAsync(request); - - Assert.That(response.Succeeded, Is.True); - } - - [Test] - public async Task SuccessAddDefaultProfilePictureUrl() - { - var request = new RegisterViewModel() - { - Email = "email@email.email", - Password = "666", - ConfirmPassword = "666", - UserName = "userName", - Bio = "bio", - Birthdate = DateTime.UtcNow.AddYears(-19), - CityId = 5878, - CountryId = 30, - GenderId = 1, - }; - - await identityService.RegisterAsync(request); - - var users = usersRepository.WithAllInformation(); - var test = dbContext.Users; - var test2 = users; - var currentUser = users.FirstOrDefault(u => u.Email == "email@email.email"); - - Assert.That(currentUser?.Images.First().Url, Is.EqualTo(DefaultProfilePictureUrl)); - } - - [Test] - public async Task SuccessAddDifferentProfilePictureUrl() - { - var request = new RegisterViewModel() - { - Email = "email@email.email", - Password = "666", - ConfirmPassword = "666", - UserName = "userName", - Bio = "bio", - Birthdate = DateTime.UtcNow.AddYears(-19), - Image = new Mock().Object, - CityId = 5878, - CountryId = 30, - GenderId = 1, - }; - - await identityService.RegisterAsync(request); - - var users = usersRepository.WithAllInformation(); - var test = dbContext.Users; - var test2 = users; - var currentUser = users.FirstOrDefault(u => u.Email == "email@email.email"); - - Assert.That(currentUser?.Images.First().Url, Is.EqualTo(MockUrl1)); - } - - [Test] - public async Task SuccessLogin() - { - var request = new LoginViewModel() - { - Email = "Pet3r@abv.bg", - Password = "password", - }; - - var result = await identityService.LoginAsync(request); - - Assert.That(result, Is.Not.Null); - } - - - [Test] - public void FailLoginIncorrectPassword() - { - userManagerMock.Setup(x => - x.CheckPasswordAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(false); - - var request = new LoginViewModel() - { - Email = "Pet4r@abv.bg", - Password = "Wrong password", - }; - - var exception = Assert.ThrowsAsync(async () => await identityService.LoginAsync(request)); - Assert.That(exception.Message, Is.EqualTo(WrongPassword)); - } - - [Test] - public void FailLoginNotConfirmedEmail() - { - var request = new LoginViewModel() - { - Email = "Pet4r@abv.bg", - Password = "Wrong password", - }; - - var exception = Assert.ThrowsAsync(async () => await identityService.LoginAsync(request)); - Assert.That(exception.Message, Is.EqualTo(EmailNotConfirmed)); - } - - [Test] - public void FailLoginNotExistingEmail() - { - var request = new LoginViewModel() - { - Email = "aaaaaaaaaaaaar@abv.bg", - Password = "Wrong password", - }; - - var exception = Assert.ThrowsAsync(async () => await identityService.LoginAsync(request)); - Assert.That(exception.Message, Is.EqualTo(UserNotFound)); - } - - [Test] - public void FailLoginBannedUser() - { - var request = new LoginViewModel() - { - Email = "banned@banned.banned", - Password = "password", - }; - - var exception = Assert.ThrowsAsync(async () => await identityService.LoginAsync(request)); - Assert.That(exception.Message, Does.StartWith("You are banned")); - } - - [Test] - public void SuccessGenerateRefreshToken() - { - var result = identityService.GenerateRefreshToken(); - - Assert.That(result, Is.Not.Null); - } - - [Test] - [TestCase("6666")] - [TestCase("66666")] - public void SuccessGetUserDetails(string id) - { - var currentUser = users.FirstOrDefault(u => u.Id == id); - var mappedUser = AutoMapperConfig.MapperInstance.Map(currentUser); - var result = identityService.GetUserDetails(id); - - Assert.Multiple(() => - { - Assert.That(result.Email, Is.EqualTo(mappedUser.Email)); - Assert.That(result.UserName, Is.EqualTo(mappedUser.UserName)); - Assert.That(result.Bio, Is.EqualTo(mappedUser.Bio)); - Assert.That(result.Birthdate, Is.EqualTo(mappedUser.Birthdate)); - Assert.That(result.Matches, Has.Count.EqualTo(mappedUser.Matches.Count)); - Assert.That(result.Images, Has.Count.EqualTo(mappedUser.Images.Count)); - Assert.That(result.GenderId, Is.EqualTo(mappedUser.GenderId)); - Assert.That(result.CityId, Is.EqualTo(mappedUser.CityId)); - Assert.That(result.Latitude, Is.EqualTo(mappedUser.Latitude)); - Assert.That(result.Longitude, Is.EqualTo(mappedUser.Longitude)); - }); - } - - [Test] - public async Task SuccessEditUser() - { - var user = users.FirstOrDefault(u => u.Id == "6666"); - var newImages = new IFormFile[] { new Mock().Object, new Mock().Object }; - - var request = new EditUserViewModel() - { - Id = "6666", - Email = "email@email.email", - Password = "666", - ConfirmPassword = "666", - UserName = "userName1", - Bio = "bio2", - Birthdate = DateTime.UtcNow.AddYears(-19), - NewImages = newImages, - Images = new List(), - CityId = 5878, - CountryId = 30, - GenderId = 2, - }; - - await identityService.EditUserAsync(request); - var result = identityService.GetUserDetails(request.Id); - - Assert.Multiple(() => - { - Assert.That(result.UserName, Is.EqualTo(request.UserName)); - Assert.That(result.Bio, Is.EqualTo(request.Bio)); - Assert.That(result.Images, Has.Count.Not.EqualTo(request.Images.Count())); - }); - } - } -} diff --git a/LOVE.NET.Services.Tests/LOVE.NET.Services.Tests.csproj b/LOVE.NET.Services.Tests/LOVE.NET.Services.Tests.csproj deleted file mode 100644 index 95470b2..0000000 --- a/LOVE.NET.Services.Tests/LOVE.NET.Services.Tests.csproj +++ /dev/null @@ -1,51 +0,0 @@ - - - - net8.0 - enable - enable - - false - - - - - - - - - - - - - PreserveNewest - true - PreserveNewest - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - diff --git a/LOVE.NET.Services.Tests/TestsSetUp.cs b/LOVE.NET.Services.Tests/TestsSetUp.cs deleted file mode 100644 index d0b8b25..0000000 --- a/LOVE.NET.Services.Tests/TestsSetUp.cs +++ /dev/null @@ -1,436 +0,0 @@ -namespace LOVE.NET.Services.Tests -{ - using System.Linq.Expressions; - using System.Reflection; - - using LOVE.NET.Data; - using LOVE.NET.Data.Common.Repositories; - using LOVE.NET.Data.Models; - using LOVE.NET.Data.Repositories.Chat; - using LOVE.NET.Data.Repositories.Countries; - using LOVE.NET.Data.Repositories.Users; - using LOVE.NET.Services.Images; - using LOVE.NET.Services.Mapping; - using LOVE.NET.Web.ViewModels.Chat; - using LOVE.NET.Web.ViewModels.Identity; - - using Microsoft.AspNetCore.Http; - using Microsoft.AspNetCore.Identity; - using Microsoft.EntityFrameworkCore; - using Microsoft.Extensions.Configuration; - - using Moq; - - using static LOVE.NET.Common.GlobalConstants; - - public class TestsSetUp - { - protected const string MockUrl1 = "MockUrl1"; - protected const string MockUrl2 = "MockUrl2"; - protected string MockPath = $"{Directory.GetCurrentDirectory().Replace("\\LOVE.NET.Services.Tests", "\\Web\\LOVE.NET.Web")}\\..\\..\\..\\wwwroot"; - protected const string MockToken = "TW9ja1VybA"; - - protected ApplicationDbContext dbContext; - - protected List users; - protected List roles; - protected List cities; - protected List countries; - protected List genders; - protected List images; - protected List likes; - protected List messages; - - public async Task GlobalInitialization() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase("InMemoryDB").Options; - - if (dbContext != null) - { - dbContext.Database.EnsureDeleted(); - } - - dbContext = new ApplicationDbContext(options); - - images = new List() - { - new Image() - { - Url = "pfp", - IsProfilePicture = true, - }, - new Image() - { - Url = "image", - IsProfilePicture = false, - }, - }; - - roles = new List() - { - new ApplicationRole() - { - Id = "1", - Name = AdministratorRoleName, - } - }; - - users = new List - { - new ApplicationUser - { - Id = "666", - UserName = "admin", - Bio = "Admin", - Email = "admin@admin.admin", - EmailConfirmed = true, - GenderId = 1, - CountryId = 30, - CityId = 5878, - CreatedOn = DateTime.UtcNow, - Birthdate = Convert.ToDateTime("2004-10-17T00:00:00"), - Images = new List(images), - LockoutEnabled = false, - Roles = new List>() { - new IdentityUserRole() - { - RoleId = "1", - UserId = "666" - } - }, - }, - new ApplicationUser - { - Id = "6666", - UserName = "Pet3r", - Bio = "UwU, OwO 🌺", - Email = "Pet3r@abv.bg", - EmailConfirmed = true, - GenderId = 3, - CountryId = 30, - CityId = 5878, - CreatedOn = DateTime.UtcNow, - Birthdate = Convert.ToDateTime("2004-10-17T00:00:00"), - Images = new List(images), - }, - new ApplicationUser - { - Id = "66666", - UserName = "Misa Misa", - Bio = "UwU", - Email = "Misa4@abv.bg", - EmailConfirmed = true, - GenderId = 2, - CountryId = 30, - CityId = 5878, - CreatedOn = DateTime.UtcNow, - Birthdate = Convert.ToDateTime("2004-10-17T00:00:00"), - Images = new List(images), - LockoutEnabled = false, - }, - new ApplicationUser - { - Id = "77777", - UserName = "Pet4r", - Bio = "UwU, OwO 🌺", - Email = "Pet4r@abv.bg", - EmailConfirmed = false, - GenderId = 3, - CountryId = 30, - CityId = 5878, - CreatedOn = DateTime.UtcNow, - Birthdate = Convert.ToDateTime("2004-10-17T00:00:00"), - Images = new List(images), - }, - new ApplicationUser - { - Id = "666666", - UserName = "banned", - Bio = "banned", - Email = "banned@banned.banned", - EmailConfirmed = true, - GenderId = 1, - CountryId = 30, - CityId = 5878, - CreatedOn = DateTime.UtcNow, - Birthdate = Convert.ToDateTime("2004-10-17T00:00:00"), - Images = new List(images), - LockoutEnabled = true, - LockoutEnd = DateTime.UtcNow.AddDays(666), - }, - new ApplicationUser - { - Id = "6666666", - UserName = "notconfirmed", - Bio = "notconfirmed", - Email = "notconfirmed@notconfirmed.notconfirmed", - EmailConfirmed = false, - GenderId = 1, - CountryId = 30, - CityId = 5878, - CreatedOn = DateTime.UtcNow, - Birthdate = Convert.ToDateTime("2004-10-17T00:00:00"), - Images = new List(images), - LockoutEnabled = false, - }, - }; - - cities = new List() - { - new City() - { - Id = 5878, - CountryId = 30, - Name = "Svilengrad", - NameAscii = "Svilengrad", - Latitude = 41.7652, - Longitude = 26.203, - } - }; - - countries = new List() - { - new Country() - { - Id = 30, - Name = "Bulgaria", - Cities = cities, - } - }; - - genders = new List() - { - new Gender() - { - Id = 1, - Name = "Male", - }, - new Gender() - { - Id = 2, - Name = "Female", - }, - new Gender() - { - Id = 3, - Name = "Trans", - }, - }; - - likes = new List() - { - new Like() - { - Id = Guid.NewGuid().ToString(), - UserId = "6666", - LikedUserId = "66666", - }, - new Like() - { - Id = Guid.NewGuid().ToString(), - UserId = "66666", - LikedUserId = "6666", - }, - new Like() - { - Id = Guid.NewGuid().ToString(), - UserId = "666666", - LikedUserId = "6666", - }, - }; - - messages = new List(); - - for (int i = 1; i <= 11; i++) - { - messages.Add(new Message() - { - Id = Guid.NewGuid().ToString(), - RoomId = "666666666", - UserId = "6666", - Text = $"text{i}", - CreatedOn = DateTime.Now.AddDays(i), - }); - } - - await dbContext.AddRangeAsync(users); - await dbContext.AddRangeAsync(roles); - await dbContext.AddRangeAsync(countries); - await dbContext.AddRangeAsync(genders); - await dbContext.AddRangeAsync(images); - await dbContext.AddRangeAsync(likes); - await dbContext.AddRangeAsync(messages); - await dbContext.SaveChangesAsync(); - var test = dbContext.Users.Include(u => u.Roles).ToList(); - AutoMapperConfig.RegisterMappings( - typeof(BaseCredentialsModel).GetTypeInfo().Assembly, - typeof(MessageDto).GetTypeInfo().Assembly); - } - - public static IConfigurationRoot GetIConfiguration() - { - return new ConfigurationBuilder() - .AddJsonFile("appsettings.json") - .AddEnvironmentVariables() - .Build(); - } - - public Mock> GetUserManagerMock() - { - var store = new Mock>(); - var manager = new Mock>( - store.Object, null, null, null, null, null, null, null, null); - - manager.Setup(x => - x.CreateAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(IdentityResult.Success) - .Callback(async (x, y) => - { - await dbContext.Set().AddAsync(x); - await dbContext.SaveChangesAsync(); - }); - - manager.Setup(x => - x.CheckPasswordAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(true); - - manager.Setup(x => - x.GetRolesAsync(It.IsAny())) - .ReturnsAsync(dbContext.Set().Select(x => x.Name).ToList()); - - manager.Setup(x => - x.SetLockoutEndDateAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(IdentityResult.Success) - .Callback(async (x, y) => - { - var trackedUser = await dbContext.Set().FindAsync(x.Id); - trackedUser.LockoutEnd = y; - await dbContext.SaveChangesAsync(); - }); - - manager.Setup(x => - x.GenerateEmailConfirmationTokenAsync(It.IsAny())) - .ReturnsAsync(MockToken); - - manager.Setup(x => - x.FindByEmailAsync(It.IsAny())) - .ReturnsAsync((string email) => - dbContext - .Set() - .FirstOrDefault(u => u.Email == email)); - - manager.Setup(x => - x.ConfirmEmailAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(IdentityResult.Success) - .Callback(async (x, y) => - { - var trackedUser = await dbContext.Set().FindAsync(x.Id); - trackedUser.EmailConfirmed = true; - - await dbContext.SaveChangesAsync(); - }); - - return manager; - } - - public IUsersRepository GetIUsersRepository() - { - var mockRepository = new Mock(); - - mockRepository.Setup(x => - x.WithAllInformation(It.IsAny>>())) - .Returns((Expression> expression) => - dbContext - .Set() - .Where(expression) - .AsQueryable()); - - mockRepository.Setup(x => - x.WithAllInformation()) - .Returns(dbContext.Set().AsQueryable()); - - mockRepository.Setup(x => - x.All()) - .Returns(dbContext.Set().AsQueryable()); - - mockRepository.Setup(x => - x.AllAsNoTracking()) - .Returns(dbContext.Set().AsQueryable()); - - mockRepository.Setup(x => - x.SaveChangesAsync()) - .Callback(async () => await dbContext.SaveChangesAsync()); - - return mockRepository.Object; - } - - public ICountriesRepository GetICountriesRepository() - { - var mockRepository = new Mock(); - - mockRepository.Setup(x => - x.AllAsNoTracking()) - .Returns(dbContext.Set().AsQueryable()); - - mockRepository.Setup(x => - x.WithAllInformation(It.IsAny>>())) - .Returns((Expression> expression) => - dbContext - .Set() - .Where(expression) - .AsQueryable()); - - return mockRepository.Object; - } - - public IChatRepository GetIChatRepository() - { - var mockRepository = new Mock(); - - mockRepository.Setup(x => - x.AllAsNoTracking(It.IsAny>>())) - .Returns((Expression> expression) => - dbContext - .Set() - .Where(expression) - .AsQueryable()); - - mockRepository.Setup(x => - x.SaveMessageAsync(It.IsAny())) - .Callback(async (Message x) => - { - await dbContext.Set().AddAsync(x); - await dbContext.SaveChangesAsync(); - }); - - return mockRepository.Object; - } - - public IRepository GetIRepository() where T : class - { - var mockRepository = new Mock>(); - - mockRepository.Setup(x => - x.AllAsNoTracking()) - .Returns(dbContext.Set().AsQueryable()); - - return mockRepository.Object; - } - - public static IImagesService GetIImagesService() - { - var mockService = new Mock(); - - mockService.Setup(x => - x.UploadImagesAsync(It.IsAny>())) - .ReturnsAsync(new[] { MockUrl1, MockUrl2 }); - - mockService.Setup(x => - x.UploadImageAsync(It.IsAny())) - .ReturnsAsync(MockUrl1); - - return mockService.Object; - } - } -} diff --git a/LOVE.NET.Services.Tests/Usings.cs b/LOVE.NET.Services.Tests/Usings.cs deleted file mode 100644 index cefced4..0000000 --- a/LOVE.NET.Services.Tests/Usings.cs +++ /dev/null @@ -1 +0,0 @@ -global using NUnit.Framework; \ No newline at end of file diff --git a/LOVE.NET.Services.Tests/appsettings.json b/LOVE.NET.Services.Tests/appsettings.json deleted file mode 100644 index d35811e..0000000 --- a/LOVE.NET.Services.Tests/appsettings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - }, - "AuthSettings": { - "Key": "This key will be used in the encrypting keep it in appsettings.Production.json", - "Audience": "https://localhost:3000/", - "Issuer": "https://localhost:3000/" - } -} diff --git a/LOVE.NET.sln b/LOVE.NET.sln deleted file mode 100644 index 2a1587d..0000000 --- a/LOVE.NET.sln +++ /dev/null @@ -1,123 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.2.32602.215 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Web", "Web", "{E389DEB6-5F90-444B-A054-12A10D39C76F}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LOVE.NET.Web", "Web\LOVE.NET.Web\LOVE.NET.Web.csproj", "{B90D1067-FE4C-400E-BA78-5413E30AB9ED}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Data", "Data", "{ACBE44C4-E198-45E2-BA02-6655250A0006}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LOVE.NET.Data.Models", "Data\LOVE.NET.Data.Models\LOVE.NET.Data.Models.csproj", "{E9FDB52F-52DC-48B2-86E1-0176377BB53F}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Services", "Services", "{516E002B-9E6E-4456-B7F6-EAA353966DE6}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{7A731632-6714-44E2-A9E4-069EBA9E666C}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LOVE.NET.Services", "Services\LOVE.NET.Services\LOVE.NET.Services.csproj", "{8522212F-F01E-4E20-9975-FCF5E10D71DB}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LOVE.NET.Services.Messaging", "Services\LOVE.NET.Services.Messaging\LOVE.NET.Services.Messaging.csproj", "{43FDC0C4-F250-45DE-93F8-3CA8A9247CC9}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LOVE.NET.Data", "Data\LOVE.NET.Data\LOVE.NET.Data.csproj", "{4B844ECB-E845-49A6-8B31-85E577933598}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LOVE.NET.Data.Common", "Data\LOVE.NET.Data.Common\LOVE.NET.Data.Common.csproj", "{D0D59508-CF8B-4656-A344-396C530BB9DF}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LOVE.NET.Common", "LOVE.NET.Common\LOVE.NET.Common.csproj", "{A2288C46-0FBF-43B6-80B6-3072FD65BC32}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LOVE.NET.Web.Infrastructure", "Web\LOVE.NET.Web.Infrastructure\LOVE.NET.Web.Infrastructure.csproj", "{7A7A7986-1F5B-4EBD-9B17-8B41F8FABA58}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LOVE.NET.Services.Mapping", "Services\LOVE.NET.Services.Mapping\LOVE.NET.Services.Mapping.csproj", "{415FD7FA-0453-4FE1-9A29-C6E1D8B45C3D}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LOVE.NET.Web.Tests", "Tests\LOVE.NET.Web.Tests\LOVE.NET.Web.Tests.csproj", "{3EE06C6E-C8F3-418A-A309-8AC81976A074}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LOVE.NET.Web.ViewModels", "Web\LOVE.NET.Web.ViewModels\LOVE.NET.Web.ViewModels.csproj", "{13B7AACB-9C85-4409-8D4B-95A67D0DFFF7}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{BEE26F89-20FD-47F3-95F8-C12714C02F3F}" - ProjectSection(SolutionItems) = preProject - .editorconfig = .editorconfig - EndProjectSection -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LOVE.NET.Services.Tests", "LOVE.NET.Services.Tests\LOVE.NET.Services.Tests.csproj", "{D090CC3B-C4FA-4739-BC5E-C21E30909E53}" -EndProject -Project("{E53339B2-1760-4266-BCC7-CA923CBCF16C}") = "docker-compose", "docker-compose.dcproj", "{E12C4BEB-6F98-4B14-B805-30F94CB6BEBC}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {B90D1067-FE4C-400E-BA78-5413E30AB9ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B90D1067-FE4C-400E-BA78-5413E30AB9ED}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B90D1067-FE4C-400E-BA78-5413E30AB9ED}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B90D1067-FE4C-400E-BA78-5413E30AB9ED}.Release|Any CPU.Build.0 = Release|Any CPU - {E9FDB52F-52DC-48B2-86E1-0176377BB53F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E9FDB52F-52DC-48B2-86E1-0176377BB53F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E9FDB52F-52DC-48B2-86E1-0176377BB53F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E9FDB52F-52DC-48B2-86E1-0176377BB53F}.Release|Any CPU.Build.0 = Release|Any CPU - {8522212F-F01E-4E20-9975-FCF5E10D71DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8522212F-F01E-4E20-9975-FCF5E10D71DB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8522212F-F01E-4E20-9975-FCF5E10D71DB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8522212F-F01E-4E20-9975-FCF5E10D71DB}.Release|Any CPU.Build.0 = Release|Any CPU - {43FDC0C4-F250-45DE-93F8-3CA8A9247CC9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {43FDC0C4-F250-45DE-93F8-3CA8A9247CC9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {43FDC0C4-F250-45DE-93F8-3CA8A9247CC9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {43FDC0C4-F250-45DE-93F8-3CA8A9247CC9}.Release|Any CPU.Build.0 = Release|Any CPU - {4B844ECB-E845-49A6-8B31-85E577933598}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4B844ECB-E845-49A6-8B31-85E577933598}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4B844ECB-E845-49A6-8B31-85E577933598}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4B844ECB-E845-49A6-8B31-85E577933598}.Release|Any CPU.Build.0 = Release|Any CPU - {D0D59508-CF8B-4656-A344-396C530BB9DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D0D59508-CF8B-4656-A344-396C530BB9DF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D0D59508-CF8B-4656-A344-396C530BB9DF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D0D59508-CF8B-4656-A344-396C530BB9DF}.Release|Any CPU.Build.0 = Release|Any CPU - {A2288C46-0FBF-43B6-80B6-3072FD65BC32}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A2288C46-0FBF-43B6-80B6-3072FD65BC32}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A2288C46-0FBF-43B6-80B6-3072FD65BC32}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A2288C46-0FBF-43B6-80B6-3072FD65BC32}.Release|Any CPU.Build.0 = Release|Any CPU - {7A7A7986-1F5B-4EBD-9B17-8B41F8FABA58}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7A7A7986-1F5B-4EBD-9B17-8B41F8FABA58}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7A7A7986-1F5B-4EBD-9B17-8B41F8FABA58}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7A7A7986-1F5B-4EBD-9B17-8B41F8FABA58}.Release|Any CPU.Build.0 = Release|Any CPU - {415FD7FA-0453-4FE1-9A29-C6E1D8B45C3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {415FD7FA-0453-4FE1-9A29-C6E1D8B45C3D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {415FD7FA-0453-4FE1-9A29-C6E1D8B45C3D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {415FD7FA-0453-4FE1-9A29-C6E1D8B45C3D}.Release|Any CPU.Build.0 = Release|Any CPU - {3EE06C6E-C8F3-418A-A309-8AC81976A074}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3EE06C6E-C8F3-418A-A309-8AC81976A074}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3EE06C6E-C8F3-418A-A309-8AC81976A074}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3EE06C6E-C8F3-418A-A309-8AC81976A074}.Release|Any CPU.Build.0 = Release|Any CPU - {13B7AACB-9C85-4409-8D4B-95A67D0DFFF7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {13B7AACB-9C85-4409-8D4B-95A67D0DFFF7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {13B7AACB-9C85-4409-8D4B-95A67D0DFFF7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {13B7AACB-9C85-4409-8D4B-95A67D0DFFF7}.Release|Any CPU.Build.0 = Release|Any CPU - {D090CC3B-C4FA-4739-BC5E-C21E30909E53}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D090CC3B-C4FA-4739-BC5E-C21E30909E53}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D090CC3B-C4FA-4739-BC5E-C21E30909E53}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D090CC3B-C4FA-4739-BC5E-C21E30909E53}.Release|Any CPU.Build.0 = Release|Any CPU - {E12C4BEB-6F98-4B14-B805-30F94CB6BEBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E12C4BEB-6F98-4B14-B805-30F94CB6BEBC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E12C4BEB-6F98-4B14-B805-30F94CB6BEBC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E12C4BEB-6F98-4B14-B805-30F94CB6BEBC}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {B90D1067-FE4C-400E-BA78-5413E30AB9ED} = {E389DEB6-5F90-444B-A054-12A10D39C76F} - {E9FDB52F-52DC-48B2-86E1-0176377BB53F} = {ACBE44C4-E198-45E2-BA02-6655250A0006} - {8522212F-F01E-4E20-9975-FCF5E10D71DB} = {516E002B-9E6E-4456-B7F6-EAA353966DE6} - {43FDC0C4-F250-45DE-93F8-3CA8A9247CC9} = {516E002B-9E6E-4456-B7F6-EAA353966DE6} - {4B844ECB-E845-49A6-8B31-85E577933598} = {ACBE44C4-E198-45E2-BA02-6655250A0006} - {D0D59508-CF8B-4656-A344-396C530BB9DF} = {ACBE44C4-E198-45E2-BA02-6655250A0006} - {7A7A7986-1F5B-4EBD-9B17-8B41F8FABA58} = {E389DEB6-5F90-444B-A054-12A10D39C76F} - {415FD7FA-0453-4FE1-9A29-C6E1D8B45C3D} = {516E002B-9E6E-4456-B7F6-EAA353966DE6} - {3EE06C6E-C8F3-418A-A309-8AC81976A074} = {7A731632-6714-44E2-A9E4-069EBA9E666C} - {13B7AACB-9C85-4409-8D4B-95A67D0DFFF7} = {E389DEB6-5F90-444B-A054-12A10D39C76F} - {D090CC3B-C4FA-4739-BC5E-C21E30909E53} = {7A731632-6714-44E2-A9E4-069EBA9E666C} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {384484BE-4680-4529-9CC2-AD37BA4D12D1} - EndGlobalSection -EndGlobal diff --git a/LoveNet.sln b/LoveNet.sln new file mode 100644 index 0000000..76d43e2 --- /dev/null +++ b/LoveNet.sln @@ -0,0 +1,291 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BuildingBlocks", "BuildingBlocks", "{90298CBA-BD6F-3A0A-69D5-97CAD7B05E7E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoveNet.BuildingBlocks.Contracts", "src\BuildingBlocks\LoveNet.BuildingBlocks.Contracts\LoveNet.BuildingBlocks.Contracts.csproj", "{467E0E09-5E1E-4346-B777-EACC7766527A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoveNet.BuildingBlocks.EventBus", "src\BuildingBlocks\LoveNet.BuildingBlocks.EventBus\LoveNet.BuildingBlocks.EventBus.csproj", "{594FFC70-A6D8-4950-9828-4BD21070ED6B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoveNet.BuildingBlocks.Media", "src\BuildingBlocks\LoveNet.BuildingBlocks.Media\LoveNet.BuildingBlocks.Media.csproj", "{91928421-82AD-4E03-B4DF-7A7FBE97C622}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoveNet.BuildingBlocks.Web", "src\BuildingBlocks\LoveNet.BuildingBlocks.Web\LoveNet.BuildingBlocks.Web.csproj", "{2988F5A2-2CDB-4D4D-BF42-6B288D204D81}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Gateway", "Gateway", "{6306A8FB-679E-111F-6585-8F70E0EE6013}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoveNet.Gateway", "src\Gateway\LoveNet.Gateway\LoveNet.Gateway.csproj", "{CBADF163-BA2C-44FE-A691-DF40965662C6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Services", "Services", "{984BB9B3-3FA3-BE33-9484-CAC21695A33C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Chat", "Chat", "{DBEBE0B0-B5B0-423B-D75D-3F5D7183D7B8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoveNet.Chat.Api", "src\Services\Chat\LoveNet.Chat.Api\LoveNet.Chat.Api.csproj", "{558DB980-2DEB-4C72-8DCD-EFF9FDA0FBAD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoveNet.Chat.Tests", "src\Services\Chat\LoveNet.Chat.Tests\LoveNet.Chat.Tests.csproj", "{A4B19057-F5A3-4DE8-BBCA-1CA04EA1FA81}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Identity", "Identity", "{1BFC9479-12BF-35C3-06B8-42D89D95092B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoveNet.Identity.Api", "src\Services\Identity\LoveNet.Identity.Api\LoveNet.Identity.Api.csproj", "{7D95771B-BAB8-48F2-AED8-E4DA938C91D1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoveNet.Identity.Tests", "src\Services\Identity\LoveNet.Identity.Tests\LoveNet.Identity.Tests.csproj", "{2A32B786-7E57-4F8B-BC75-71D2539B49A8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Matching", "Matching", "{AE967555-3840-3EB4-DB3B-4B04E778B734}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoveNet.Matching.Api", "src\Services\Matching\LoveNet.Matching.Api\LoveNet.Matching.Api.csproj", "{54187D6F-5AE9-4D35-9487-8AB143F24982}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoveNet.Matching.Tests", "src\Services\Matching\LoveNet.Matching.Tests\LoveNet.Matching.Tests.csproj", "{3401E865-7D2B-466A-A91E-43E565CBF2D6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Notifications", "Notifications", "{40B59F69-022F-A9EE-E3B7-096C1E81294B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoveNet.Notifications.Api", "src\Services\Notifications\LoveNet.Notifications.Api\LoveNet.Notifications.Api.csproj", "{9049F983-A8DA-44D8-AA3E-712A8E033FB8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoveNet.Notifications.Tests", "src\Services\Notifications\LoveNet.Notifications.Tests\LoveNet.Notifications.Tests.csproj", "{05CFFD2A-C9E9-46D4-8D65-AF8AE11BFF4A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Profile", "Profile", "{1CDA0139-CFA2-727C-5B69-11DE8DF71EFC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoveNet.Profile.Api", "src\Services\Profile\LoveNet.Profile.Api\LoveNet.Profile.Api.csproj", "{CAB26695-B862-4704-9F4F-C9F680E7D1D7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoveNet.Profile.Tests", "src\Services\Profile\LoveNet.Profile.Tests\LoveNet.Profile.Tests.csproj", "{09A17DC3-A6F5-4C9A-BCBF-EA14366A9CDA}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Realtime", "Realtime", "{6DE8524A-F64D-DA28-6D85-5430E15088EC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LoveNet.Realtime.Api", "src\Services\Realtime\LoveNet.Realtime.Api\LoveNet.Realtime.Api.csproj", "{CA5FF76F-AD4D-4587-9813-9A7550E3E7C8}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {467E0E09-5E1E-4346-B777-EACC7766527A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {467E0E09-5E1E-4346-B777-EACC7766527A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {467E0E09-5E1E-4346-B777-EACC7766527A}.Debug|x64.ActiveCfg = Debug|Any CPU + {467E0E09-5E1E-4346-B777-EACC7766527A}.Debug|x64.Build.0 = Debug|Any CPU + {467E0E09-5E1E-4346-B777-EACC7766527A}.Debug|x86.ActiveCfg = Debug|Any CPU + {467E0E09-5E1E-4346-B777-EACC7766527A}.Debug|x86.Build.0 = Debug|Any CPU + {467E0E09-5E1E-4346-B777-EACC7766527A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {467E0E09-5E1E-4346-B777-EACC7766527A}.Release|Any CPU.Build.0 = Release|Any CPU + {467E0E09-5E1E-4346-B777-EACC7766527A}.Release|x64.ActiveCfg = Release|Any CPU + {467E0E09-5E1E-4346-B777-EACC7766527A}.Release|x64.Build.0 = Release|Any CPU + {467E0E09-5E1E-4346-B777-EACC7766527A}.Release|x86.ActiveCfg = Release|Any CPU + {467E0E09-5E1E-4346-B777-EACC7766527A}.Release|x86.Build.0 = Release|Any CPU + {594FFC70-A6D8-4950-9828-4BD21070ED6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {594FFC70-A6D8-4950-9828-4BD21070ED6B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {594FFC70-A6D8-4950-9828-4BD21070ED6B}.Debug|x64.ActiveCfg = Debug|Any CPU + {594FFC70-A6D8-4950-9828-4BD21070ED6B}.Debug|x64.Build.0 = Debug|Any CPU + {594FFC70-A6D8-4950-9828-4BD21070ED6B}.Debug|x86.ActiveCfg = Debug|Any CPU + {594FFC70-A6D8-4950-9828-4BD21070ED6B}.Debug|x86.Build.0 = Debug|Any CPU + {594FFC70-A6D8-4950-9828-4BD21070ED6B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {594FFC70-A6D8-4950-9828-4BD21070ED6B}.Release|Any CPU.Build.0 = Release|Any CPU + {594FFC70-A6D8-4950-9828-4BD21070ED6B}.Release|x64.ActiveCfg = Release|Any CPU + {594FFC70-A6D8-4950-9828-4BD21070ED6B}.Release|x64.Build.0 = Release|Any CPU + {594FFC70-A6D8-4950-9828-4BD21070ED6B}.Release|x86.ActiveCfg = Release|Any CPU + {594FFC70-A6D8-4950-9828-4BD21070ED6B}.Release|x86.Build.0 = Release|Any CPU + {91928421-82AD-4E03-B4DF-7A7FBE97C622}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {91928421-82AD-4E03-B4DF-7A7FBE97C622}.Debug|Any CPU.Build.0 = Debug|Any CPU + {91928421-82AD-4E03-B4DF-7A7FBE97C622}.Debug|x64.ActiveCfg = Debug|Any CPU + {91928421-82AD-4E03-B4DF-7A7FBE97C622}.Debug|x64.Build.0 = Debug|Any CPU + {91928421-82AD-4E03-B4DF-7A7FBE97C622}.Debug|x86.ActiveCfg = Debug|Any CPU + {91928421-82AD-4E03-B4DF-7A7FBE97C622}.Debug|x86.Build.0 = Debug|Any CPU + {91928421-82AD-4E03-B4DF-7A7FBE97C622}.Release|Any CPU.ActiveCfg = Release|Any CPU + {91928421-82AD-4E03-B4DF-7A7FBE97C622}.Release|Any CPU.Build.0 = Release|Any CPU + {91928421-82AD-4E03-B4DF-7A7FBE97C622}.Release|x64.ActiveCfg = Release|Any CPU + {91928421-82AD-4E03-B4DF-7A7FBE97C622}.Release|x64.Build.0 = Release|Any CPU + {91928421-82AD-4E03-B4DF-7A7FBE97C622}.Release|x86.ActiveCfg = Release|Any CPU + {91928421-82AD-4E03-B4DF-7A7FBE97C622}.Release|x86.Build.0 = Release|Any CPU + {2988F5A2-2CDB-4D4D-BF42-6B288D204D81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2988F5A2-2CDB-4D4D-BF42-6B288D204D81}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2988F5A2-2CDB-4D4D-BF42-6B288D204D81}.Debug|x64.ActiveCfg = Debug|Any CPU + {2988F5A2-2CDB-4D4D-BF42-6B288D204D81}.Debug|x64.Build.0 = Debug|Any CPU + {2988F5A2-2CDB-4D4D-BF42-6B288D204D81}.Debug|x86.ActiveCfg = Debug|Any CPU + {2988F5A2-2CDB-4D4D-BF42-6B288D204D81}.Debug|x86.Build.0 = Debug|Any CPU + {2988F5A2-2CDB-4D4D-BF42-6B288D204D81}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2988F5A2-2CDB-4D4D-BF42-6B288D204D81}.Release|Any CPU.Build.0 = Release|Any CPU + {2988F5A2-2CDB-4D4D-BF42-6B288D204D81}.Release|x64.ActiveCfg = Release|Any CPU + {2988F5A2-2CDB-4D4D-BF42-6B288D204D81}.Release|x64.Build.0 = Release|Any CPU + {2988F5A2-2CDB-4D4D-BF42-6B288D204D81}.Release|x86.ActiveCfg = Release|Any CPU + {2988F5A2-2CDB-4D4D-BF42-6B288D204D81}.Release|x86.Build.0 = Release|Any CPU + {CBADF163-BA2C-44FE-A691-DF40965662C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CBADF163-BA2C-44FE-A691-DF40965662C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CBADF163-BA2C-44FE-A691-DF40965662C6}.Debug|x64.ActiveCfg = Debug|Any CPU + {CBADF163-BA2C-44FE-A691-DF40965662C6}.Debug|x64.Build.0 = Debug|Any CPU + {CBADF163-BA2C-44FE-A691-DF40965662C6}.Debug|x86.ActiveCfg = Debug|Any CPU + {CBADF163-BA2C-44FE-A691-DF40965662C6}.Debug|x86.Build.0 = Debug|Any CPU + {CBADF163-BA2C-44FE-A691-DF40965662C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CBADF163-BA2C-44FE-A691-DF40965662C6}.Release|Any CPU.Build.0 = Release|Any CPU + {CBADF163-BA2C-44FE-A691-DF40965662C6}.Release|x64.ActiveCfg = Release|Any CPU + {CBADF163-BA2C-44FE-A691-DF40965662C6}.Release|x64.Build.0 = Release|Any CPU + {CBADF163-BA2C-44FE-A691-DF40965662C6}.Release|x86.ActiveCfg = Release|Any CPU + {CBADF163-BA2C-44FE-A691-DF40965662C6}.Release|x86.Build.0 = Release|Any CPU + {558DB980-2DEB-4C72-8DCD-EFF9FDA0FBAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {558DB980-2DEB-4C72-8DCD-EFF9FDA0FBAD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {558DB980-2DEB-4C72-8DCD-EFF9FDA0FBAD}.Debug|x64.ActiveCfg = Debug|Any CPU + {558DB980-2DEB-4C72-8DCD-EFF9FDA0FBAD}.Debug|x64.Build.0 = Debug|Any CPU + {558DB980-2DEB-4C72-8DCD-EFF9FDA0FBAD}.Debug|x86.ActiveCfg = Debug|Any CPU + {558DB980-2DEB-4C72-8DCD-EFF9FDA0FBAD}.Debug|x86.Build.0 = Debug|Any CPU + {558DB980-2DEB-4C72-8DCD-EFF9FDA0FBAD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {558DB980-2DEB-4C72-8DCD-EFF9FDA0FBAD}.Release|Any CPU.Build.0 = Release|Any CPU + {558DB980-2DEB-4C72-8DCD-EFF9FDA0FBAD}.Release|x64.ActiveCfg = Release|Any CPU + {558DB980-2DEB-4C72-8DCD-EFF9FDA0FBAD}.Release|x64.Build.0 = Release|Any CPU + {558DB980-2DEB-4C72-8DCD-EFF9FDA0FBAD}.Release|x86.ActiveCfg = Release|Any CPU + {558DB980-2DEB-4C72-8DCD-EFF9FDA0FBAD}.Release|x86.Build.0 = Release|Any CPU + {A4B19057-F5A3-4DE8-BBCA-1CA04EA1FA81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4B19057-F5A3-4DE8-BBCA-1CA04EA1FA81}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4B19057-F5A3-4DE8-BBCA-1CA04EA1FA81}.Debug|x64.ActiveCfg = Debug|Any CPU + {A4B19057-F5A3-4DE8-BBCA-1CA04EA1FA81}.Debug|x64.Build.0 = Debug|Any CPU + {A4B19057-F5A3-4DE8-BBCA-1CA04EA1FA81}.Debug|x86.ActiveCfg = Debug|Any CPU + {A4B19057-F5A3-4DE8-BBCA-1CA04EA1FA81}.Debug|x86.Build.0 = Debug|Any CPU + {A4B19057-F5A3-4DE8-BBCA-1CA04EA1FA81}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A4B19057-F5A3-4DE8-BBCA-1CA04EA1FA81}.Release|Any CPU.Build.0 = Release|Any CPU + {A4B19057-F5A3-4DE8-BBCA-1CA04EA1FA81}.Release|x64.ActiveCfg = Release|Any CPU + {A4B19057-F5A3-4DE8-BBCA-1CA04EA1FA81}.Release|x64.Build.0 = Release|Any CPU + {A4B19057-F5A3-4DE8-BBCA-1CA04EA1FA81}.Release|x86.ActiveCfg = Release|Any CPU + {A4B19057-F5A3-4DE8-BBCA-1CA04EA1FA81}.Release|x86.Build.0 = Release|Any CPU + {7D95771B-BAB8-48F2-AED8-E4DA938C91D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7D95771B-BAB8-48F2-AED8-E4DA938C91D1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7D95771B-BAB8-48F2-AED8-E4DA938C91D1}.Debug|x64.ActiveCfg = Debug|Any CPU + {7D95771B-BAB8-48F2-AED8-E4DA938C91D1}.Debug|x64.Build.0 = Debug|Any CPU + {7D95771B-BAB8-48F2-AED8-E4DA938C91D1}.Debug|x86.ActiveCfg = Debug|Any CPU + {7D95771B-BAB8-48F2-AED8-E4DA938C91D1}.Debug|x86.Build.0 = Debug|Any CPU + {7D95771B-BAB8-48F2-AED8-E4DA938C91D1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7D95771B-BAB8-48F2-AED8-E4DA938C91D1}.Release|Any CPU.Build.0 = Release|Any CPU + {7D95771B-BAB8-48F2-AED8-E4DA938C91D1}.Release|x64.ActiveCfg = Release|Any CPU + {7D95771B-BAB8-48F2-AED8-E4DA938C91D1}.Release|x64.Build.0 = Release|Any CPU + {7D95771B-BAB8-48F2-AED8-E4DA938C91D1}.Release|x86.ActiveCfg = Release|Any CPU + {7D95771B-BAB8-48F2-AED8-E4DA938C91D1}.Release|x86.Build.0 = Release|Any CPU + {2A32B786-7E57-4F8B-BC75-71D2539B49A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A32B786-7E57-4F8B-BC75-71D2539B49A8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A32B786-7E57-4F8B-BC75-71D2539B49A8}.Debug|x64.ActiveCfg = Debug|Any CPU + {2A32B786-7E57-4F8B-BC75-71D2539B49A8}.Debug|x64.Build.0 = Debug|Any CPU + {2A32B786-7E57-4F8B-BC75-71D2539B49A8}.Debug|x86.ActiveCfg = Debug|Any CPU + {2A32B786-7E57-4F8B-BC75-71D2539B49A8}.Debug|x86.Build.0 = Debug|Any CPU + {2A32B786-7E57-4F8B-BC75-71D2539B49A8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A32B786-7E57-4F8B-BC75-71D2539B49A8}.Release|Any CPU.Build.0 = Release|Any CPU + {2A32B786-7E57-4F8B-BC75-71D2539B49A8}.Release|x64.ActiveCfg = Release|Any CPU + {2A32B786-7E57-4F8B-BC75-71D2539B49A8}.Release|x64.Build.0 = Release|Any CPU + {2A32B786-7E57-4F8B-BC75-71D2539B49A8}.Release|x86.ActiveCfg = Release|Any CPU + {2A32B786-7E57-4F8B-BC75-71D2539B49A8}.Release|x86.Build.0 = Release|Any CPU + {54187D6F-5AE9-4D35-9487-8AB143F24982}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {54187D6F-5AE9-4D35-9487-8AB143F24982}.Debug|Any CPU.Build.0 = Debug|Any CPU + {54187D6F-5AE9-4D35-9487-8AB143F24982}.Debug|x64.ActiveCfg = Debug|Any CPU + {54187D6F-5AE9-4D35-9487-8AB143F24982}.Debug|x64.Build.0 = Debug|Any CPU + {54187D6F-5AE9-4D35-9487-8AB143F24982}.Debug|x86.ActiveCfg = Debug|Any CPU + {54187D6F-5AE9-4D35-9487-8AB143F24982}.Debug|x86.Build.0 = Debug|Any CPU + {54187D6F-5AE9-4D35-9487-8AB143F24982}.Release|Any CPU.ActiveCfg = Release|Any CPU + {54187D6F-5AE9-4D35-9487-8AB143F24982}.Release|Any CPU.Build.0 = Release|Any CPU + {54187D6F-5AE9-4D35-9487-8AB143F24982}.Release|x64.ActiveCfg = Release|Any CPU + {54187D6F-5AE9-4D35-9487-8AB143F24982}.Release|x64.Build.0 = Release|Any CPU + {54187D6F-5AE9-4D35-9487-8AB143F24982}.Release|x86.ActiveCfg = Release|Any CPU + {54187D6F-5AE9-4D35-9487-8AB143F24982}.Release|x86.Build.0 = Release|Any CPU + {3401E865-7D2B-466A-A91E-43E565CBF2D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3401E865-7D2B-466A-A91E-43E565CBF2D6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3401E865-7D2B-466A-A91E-43E565CBF2D6}.Debug|x64.ActiveCfg = Debug|Any CPU + {3401E865-7D2B-466A-A91E-43E565CBF2D6}.Debug|x64.Build.0 = Debug|Any CPU + {3401E865-7D2B-466A-A91E-43E565CBF2D6}.Debug|x86.ActiveCfg = Debug|Any CPU + {3401E865-7D2B-466A-A91E-43E565CBF2D6}.Debug|x86.Build.0 = Debug|Any CPU + {3401E865-7D2B-466A-A91E-43E565CBF2D6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3401E865-7D2B-466A-A91E-43E565CBF2D6}.Release|Any CPU.Build.0 = Release|Any CPU + {3401E865-7D2B-466A-A91E-43E565CBF2D6}.Release|x64.ActiveCfg = Release|Any CPU + {3401E865-7D2B-466A-A91E-43E565CBF2D6}.Release|x64.Build.0 = Release|Any CPU + {3401E865-7D2B-466A-A91E-43E565CBF2D6}.Release|x86.ActiveCfg = Release|Any CPU + {3401E865-7D2B-466A-A91E-43E565CBF2D6}.Release|x86.Build.0 = Release|Any CPU + {9049F983-A8DA-44D8-AA3E-712A8E033FB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9049F983-A8DA-44D8-AA3E-712A8E033FB8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9049F983-A8DA-44D8-AA3E-712A8E033FB8}.Debug|x64.ActiveCfg = Debug|Any CPU + {9049F983-A8DA-44D8-AA3E-712A8E033FB8}.Debug|x64.Build.0 = Debug|Any CPU + {9049F983-A8DA-44D8-AA3E-712A8E033FB8}.Debug|x86.ActiveCfg = Debug|Any CPU + {9049F983-A8DA-44D8-AA3E-712A8E033FB8}.Debug|x86.Build.0 = Debug|Any CPU + {9049F983-A8DA-44D8-AA3E-712A8E033FB8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9049F983-A8DA-44D8-AA3E-712A8E033FB8}.Release|Any CPU.Build.0 = Release|Any CPU + {9049F983-A8DA-44D8-AA3E-712A8E033FB8}.Release|x64.ActiveCfg = Release|Any CPU + {9049F983-A8DA-44D8-AA3E-712A8E033FB8}.Release|x64.Build.0 = Release|Any CPU + {9049F983-A8DA-44D8-AA3E-712A8E033FB8}.Release|x86.ActiveCfg = Release|Any CPU + {9049F983-A8DA-44D8-AA3E-712A8E033FB8}.Release|x86.Build.0 = Release|Any CPU + {05CFFD2A-C9E9-46D4-8D65-AF8AE11BFF4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {05CFFD2A-C9E9-46D4-8D65-AF8AE11BFF4A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {05CFFD2A-C9E9-46D4-8D65-AF8AE11BFF4A}.Debug|x64.ActiveCfg = Debug|Any CPU + {05CFFD2A-C9E9-46D4-8D65-AF8AE11BFF4A}.Debug|x64.Build.0 = Debug|Any CPU + {05CFFD2A-C9E9-46D4-8D65-AF8AE11BFF4A}.Debug|x86.ActiveCfg = Debug|Any CPU + {05CFFD2A-C9E9-46D4-8D65-AF8AE11BFF4A}.Debug|x86.Build.0 = Debug|Any CPU + {05CFFD2A-C9E9-46D4-8D65-AF8AE11BFF4A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {05CFFD2A-C9E9-46D4-8D65-AF8AE11BFF4A}.Release|Any CPU.Build.0 = Release|Any CPU + {05CFFD2A-C9E9-46D4-8D65-AF8AE11BFF4A}.Release|x64.ActiveCfg = Release|Any CPU + {05CFFD2A-C9E9-46D4-8D65-AF8AE11BFF4A}.Release|x64.Build.0 = Release|Any CPU + {05CFFD2A-C9E9-46D4-8D65-AF8AE11BFF4A}.Release|x86.ActiveCfg = Release|Any CPU + {05CFFD2A-C9E9-46D4-8D65-AF8AE11BFF4A}.Release|x86.Build.0 = Release|Any CPU + {CAB26695-B862-4704-9F4F-C9F680E7D1D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CAB26695-B862-4704-9F4F-C9F680E7D1D7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CAB26695-B862-4704-9F4F-C9F680E7D1D7}.Debug|x64.ActiveCfg = Debug|Any CPU + {CAB26695-B862-4704-9F4F-C9F680E7D1D7}.Debug|x64.Build.0 = Debug|Any CPU + {CAB26695-B862-4704-9F4F-C9F680E7D1D7}.Debug|x86.ActiveCfg = Debug|Any CPU + {CAB26695-B862-4704-9F4F-C9F680E7D1D7}.Debug|x86.Build.0 = Debug|Any CPU + {CAB26695-B862-4704-9F4F-C9F680E7D1D7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CAB26695-B862-4704-9F4F-C9F680E7D1D7}.Release|Any CPU.Build.0 = Release|Any CPU + {CAB26695-B862-4704-9F4F-C9F680E7D1D7}.Release|x64.ActiveCfg = Release|Any CPU + {CAB26695-B862-4704-9F4F-C9F680E7D1D7}.Release|x64.Build.0 = Release|Any CPU + {CAB26695-B862-4704-9F4F-C9F680E7D1D7}.Release|x86.ActiveCfg = Release|Any CPU + {CAB26695-B862-4704-9F4F-C9F680E7D1D7}.Release|x86.Build.0 = Release|Any CPU + {09A17DC3-A6F5-4C9A-BCBF-EA14366A9CDA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {09A17DC3-A6F5-4C9A-BCBF-EA14366A9CDA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09A17DC3-A6F5-4C9A-BCBF-EA14366A9CDA}.Debug|x64.ActiveCfg = Debug|Any CPU + {09A17DC3-A6F5-4C9A-BCBF-EA14366A9CDA}.Debug|x64.Build.0 = Debug|Any CPU + {09A17DC3-A6F5-4C9A-BCBF-EA14366A9CDA}.Debug|x86.ActiveCfg = Debug|Any CPU + {09A17DC3-A6F5-4C9A-BCBF-EA14366A9CDA}.Debug|x86.Build.0 = Debug|Any CPU + {09A17DC3-A6F5-4C9A-BCBF-EA14366A9CDA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {09A17DC3-A6F5-4C9A-BCBF-EA14366A9CDA}.Release|Any CPU.Build.0 = Release|Any CPU + {09A17DC3-A6F5-4C9A-BCBF-EA14366A9CDA}.Release|x64.ActiveCfg = Release|Any CPU + {09A17DC3-A6F5-4C9A-BCBF-EA14366A9CDA}.Release|x64.Build.0 = Release|Any CPU + {09A17DC3-A6F5-4C9A-BCBF-EA14366A9CDA}.Release|x86.ActiveCfg = Release|Any CPU + {09A17DC3-A6F5-4C9A-BCBF-EA14366A9CDA}.Release|x86.Build.0 = Release|Any CPU + {CA5FF76F-AD4D-4587-9813-9A7550E3E7C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CA5FF76F-AD4D-4587-9813-9A7550E3E7C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CA5FF76F-AD4D-4587-9813-9A7550E3E7C8}.Debug|x64.ActiveCfg = Debug|Any CPU + {CA5FF76F-AD4D-4587-9813-9A7550E3E7C8}.Debug|x64.Build.0 = Debug|Any CPU + {CA5FF76F-AD4D-4587-9813-9A7550E3E7C8}.Debug|x86.ActiveCfg = Debug|Any CPU + {CA5FF76F-AD4D-4587-9813-9A7550E3E7C8}.Debug|x86.Build.0 = Debug|Any CPU + {CA5FF76F-AD4D-4587-9813-9A7550E3E7C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CA5FF76F-AD4D-4587-9813-9A7550E3E7C8}.Release|Any CPU.Build.0 = Release|Any CPU + {CA5FF76F-AD4D-4587-9813-9A7550E3E7C8}.Release|x64.ActiveCfg = Release|Any CPU + {CA5FF76F-AD4D-4587-9813-9A7550E3E7C8}.Release|x64.Build.0 = Release|Any CPU + {CA5FF76F-AD4D-4587-9813-9A7550E3E7C8}.Release|x86.ActiveCfg = Release|Any CPU + {CA5FF76F-AD4D-4587-9813-9A7550E3E7C8}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {90298CBA-BD6F-3A0A-69D5-97CAD7B05E7E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {467E0E09-5E1E-4346-B777-EACC7766527A} = {90298CBA-BD6F-3A0A-69D5-97CAD7B05E7E} + {594FFC70-A6D8-4950-9828-4BD21070ED6B} = {90298CBA-BD6F-3A0A-69D5-97CAD7B05E7E} + {91928421-82AD-4E03-B4DF-7A7FBE97C622} = {90298CBA-BD6F-3A0A-69D5-97CAD7B05E7E} + {2988F5A2-2CDB-4D4D-BF42-6B288D204D81} = {90298CBA-BD6F-3A0A-69D5-97CAD7B05E7E} + {6306A8FB-679E-111F-6585-8F70E0EE6013} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {CBADF163-BA2C-44FE-A691-DF40965662C6} = {6306A8FB-679E-111F-6585-8F70E0EE6013} + {984BB9B3-3FA3-BE33-9484-CAC21695A33C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {DBEBE0B0-B5B0-423B-D75D-3F5D7183D7B8} = {984BB9B3-3FA3-BE33-9484-CAC21695A33C} + {558DB980-2DEB-4C72-8DCD-EFF9FDA0FBAD} = {DBEBE0B0-B5B0-423B-D75D-3F5D7183D7B8} + {A4B19057-F5A3-4DE8-BBCA-1CA04EA1FA81} = {DBEBE0B0-B5B0-423B-D75D-3F5D7183D7B8} + {1BFC9479-12BF-35C3-06B8-42D89D95092B} = {984BB9B3-3FA3-BE33-9484-CAC21695A33C} + {7D95771B-BAB8-48F2-AED8-E4DA938C91D1} = {1BFC9479-12BF-35C3-06B8-42D89D95092B} + {2A32B786-7E57-4F8B-BC75-71D2539B49A8} = {1BFC9479-12BF-35C3-06B8-42D89D95092B} + {AE967555-3840-3EB4-DB3B-4B04E778B734} = {984BB9B3-3FA3-BE33-9484-CAC21695A33C} + {54187D6F-5AE9-4D35-9487-8AB143F24982} = {AE967555-3840-3EB4-DB3B-4B04E778B734} + {3401E865-7D2B-466A-A91E-43E565CBF2D6} = {AE967555-3840-3EB4-DB3B-4B04E778B734} + {40B59F69-022F-A9EE-E3B7-096C1E81294B} = {984BB9B3-3FA3-BE33-9484-CAC21695A33C} + {9049F983-A8DA-44D8-AA3E-712A8E033FB8} = {40B59F69-022F-A9EE-E3B7-096C1E81294B} + {05CFFD2A-C9E9-46D4-8D65-AF8AE11BFF4A} = {40B59F69-022F-A9EE-E3B7-096C1E81294B} + {1CDA0139-CFA2-727C-5B69-11DE8DF71EFC} = {984BB9B3-3FA3-BE33-9484-CAC21695A33C} + {CAB26695-B862-4704-9F4F-C9F680E7D1D7} = {1CDA0139-CFA2-727C-5B69-11DE8DF71EFC} + {09A17DC3-A6F5-4C9A-BCBF-EA14366A9CDA} = {1CDA0139-CFA2-727C-5B69-11DE8DF71EFC} + {6DE8524A-F64D-DA28-6D85-5430E15088EC} = {984BB9B3-3FA3-BE33-9484-CAC21695A33C} + {CA5FF76F-AD4D-4587-9813-9A7550E3E7C8} = {6DE8524A-F64D-DA28-6D85-5430E15088EC} + EndGlobalSection +EndGlobal diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..765346e --- /dev/null +++ b/NuGet.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/README.md b/README.md index 04c9db9..4e4dd17 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,66 @@ - -

LOVE.NET

-

ReactJS SPA dating application -
+

ReactJS SPA dating application — now running on event-driven .NET 8 microservices +

## 📝 Table of Contents - [About](#about) +- [Architecture](#architecture) - [Getting Started](#getting_started) -- [Database Diagram](#database_diagram) +- [Database Placement](#database_placement) - [Tests](#tests) - [Built Using](#built_using) ## 🧐 About -`LOVE.NET `gives you opportunity to match with people around the world and chat easily in real time. -Some accounts, countries, cities, genders are loaded after the first start of the application. +`LOVE.NET` gives you opportunity to match with people around the world and chat easily in real time. +Some accounts, countries, cities, genders are loaded after the first start of the application — through the same Kafka event pipeline production traffic uses. + +This repository was refactored from a layered monolith into seven services built to scale the realtime chat workload horizontally. The original monolith is preserved on the [`monolith-snapshot`](../../tree/monolith-snapshot) branch. + +## 🏗️ Architecture + +``` +React SPA ──► Gateway (YARP) ──► Identity │ Profile │ Matching │ Chat │ Realtime (SignalR) │ Notifications + │ │ │ │ │ │ + Postgres Postgres Postgres Postgres Redis Postgres + └─────────┴────── Kafka (outbox → topics → inbox) ────────┘ +``` + +* Authorized SignalR hub with a **Redis backplane** and Redis presence — no in-process state +* **Durable-before-display** chat: Kafka ack (keyed by room for ordering) before any client renders the message +* **Transactional outbox + inbox dedupe** on every service boundary, dead-letter queue for poison messages +* **Database-per-service** with event-carried read models — no cross-service joins +* Gateway with rate limiting, websocket proxying, and a resilient admin-stats fan-out +* Structured logs + OpenTelemetry traces (optional Jaeger), correlation ids across HTTP and Kafka hops + +The full design — topics, delivery semantics, resiliency patterns, scaling story — is in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). ## 🏁 Getting Started ### Run the project ``` -npm install +cp .env.example .env +``` ``` +docker compose up -d --build ``` -npm start ``` +SPA: http://localhost:3000 · Gateway: http://localhost:8080 · Swagger: http://localhost:8081..8086/swagger ``` -start the LOVE.NET.Web project ``` +./scripts/smoke-test.ps1 (20-step end-to-end proof of the whole stack) +``` + +![Containers](docs/images/containers.png) ### Authentication and Authorization ``` -JWT Bearer token is being used for authentication and authorization +JWT Bearer token is being used for authentication and authorization (issued by Identity, validated by every service and the SignalR hub) ``` ``` -User is filling his mandatory data and uploading images. +User is filling his mandatory data and uploading images. ``` ``` Resend email verification if no email is received @@ -48,7 +72,6 @@ Verification - required step in order to use the app Reset password if forgotten ``` - ### User profile ``` @@ -59,13 +82,12 @@ Fast and easy edit of your profile information Add and remove photos ``` - ### Home Tab ``` All not swiped users ``` - + ``` Easy filtration by age, gender, distance in km calculated Latitude and Longitude of both users. ``` @@ -73,7 +95,7 @@ Easy filtration by age, gender, distance in km calculated Latitude and Longitude Easy swipe for mobile users, and buttons for PC master race fans ``` ``` -Upon successful match - notification is received +Upon successful match - notification is received (pushed over the hub to both users) ``` ### Matches Tab @@ -81,7 +103,7 @@ Upon successful match - notification is received ``` All matches you have got ``` - + ``` Easy chat with option to send images ``` @@ -94,9 +116,9 @@ Search your beloved ones ### Dashboard Tab ``` -Up-to-date statistics +Up-to-date statistics (aggregated by the gateway from every service) ``` - + ``` Infinite scrolling of users, with possibility to ban them ``` @@ -104,21 +126,27 @@ Infinite scrolling of users, with possibility to ban them Search for intruders of the site ``` -## 📈 Database diagram -![Imgur](https://i.imgur.com/Om9cTld.png) +## 📈 Database Placement +Every service owns its schema; the shared tables you see (`outbox_messages`, `inbox_messages`) are the reliable-messaging pattern, and `messages` in the chat database is a **partitioned table** with monthly partitions. + +![Database placement](docs/images/database-placement.png) + ## 🧪 Tests -* Over 70 tests -* Covering 90% of the services. -* ![Imgur](https://i.imgur.com/XElPNID.png) +* 37 NUnit tests across the five services (ported and adapted from the monolith suite) +* Plus a 20-step end-to-end smoke test against the live compose stack (`scripts/smoke-test.ps1`) +* ![Tests](docs/images/tests.png) ## ⛏️ Built Using * ReactJS -* .NET 6 API -* EF Core 6 with SQL Server -* AutoMapper -* Swashbuckle Swagger +* .NET 8 APIs (7 services) +* EF Core 8 with PostgreSQL (database-per-service) +* Apache Kafka (KRaft) +* Redis +* YARP API Gateway +* Swashbuckle Swagger * JWT Bearer -* SignalR +* SignalR (+ Redis backplane) +* Serilog + OpenTelemetry * Cloudinary * SendGrid * NUnit @@ -127,4 +155,4 @@ Search for intruders of the site * React Bootstrap * MomentJS * React Tinder Card - +* Docker Compose diff --git a/Rules.ruleset b/Rules.ruleset deleted file mode 100644 index 8757fc2..0000000 --- a/Rules.ruleset +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Services/LOVE.NET.Services.Data/ISettingsService.cs b/Services/LOVE.NET.Services.Data/ISettingsService.cs deleted file mode 100644 index bab84ce..0000000 --- a/Services/LOVE.NET.Services.Data/ISettingsService.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace LOVE.NET.Services.Data -{ - using System.Collections.Generic; - - public interface ISettingsService - { - int GetCount(); - - IEnumerable GetAll(); - } -} diff --git a/Services/LOVE.NET.Services.Data/LOVE.NET.Services.Data.csproj b/Services/LOVE.NET.Services.Data/LOVE.NET.Services.Data.csproj deleted file mode 100644 index 242f738..0000000 --- a/Services/LOVE.NET.Services.Data/LOVE.NET.Services.Data.csproj +++ /dev/null @@ -1,27 +0,0 @@ - - - - net6.0 - latest - - - - ..\..\Rules.ruleset - - - - - - - - runtime; build; native; contentfiles; analyzers - - - - - - - - - - \ No newline at end of file diff --git a/Services/LOVE.NET.Services.Data/SettingsService.cs b/Services/LOVE.NET.Services.Data/SettingsService.cs deleted file mode 100644 index 292ed06..0000000 --- a/Services/LOVE.NET.Services.Data/SettingsService.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace LOVE.NET.Services.Data -{ - using System.Collections.Generic; - using System.Linq; - - using LOVE.NET.Data.Common.Repositories; - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Mapping; - - public class SettingsService : ISettingsService - { - private readonly IDeletableEntityRepository settingsRepository; - - public SettingsService(IDeletableEntityRepository settingsRepository) - { - this.settingsRepository = settingsRepository; - } - - public int GetCount() - { - return this.settingsRepository.AllAsNoTracking().Count(); - } - - public IEnumerable GetAll() - { - return this.settingsRepository.All().To().ToList(); - } - } -} diff --git a/Services/LOVE.NET.Services.Mapping/AutoMapperConfig.cs b/Services/LOVE.NET.Services.Mapping/AutoMapperConfig.cs deleted file mode 100644 index 8efd05e..0000000 --- a/Services/LOVE.NET.Services.Mapping/AutoMapperConfig.cs +++ /dev/null @@ -1,108 +0,0 @@ -namespace LOVE.NET.Services.Mapping -{ - using System; - using System.Collections.Generic; - using System.Linq; - using System.Reflection; - - using AutoMapper; - using AutoMapper.Internal; - - public static class AutoMapperConfig - { - private static bool initialized; - - public static IMapper MapperInstance { get; set; } - - public static void RegisterMappings(params Assembly[] assemblies) - { - if (initialized) - { - return; - } - - initialized = true; - - var types = assemblies.SelectMany(a => a.GetExportedTypes()).ToList(); - - var config = new MapperConfigurationExpression(); - config.Internal().MethodMappingEnabled = false; - config.CreateProfile( - "ReflectionProfile", - configuration => - { - // IMapFrom<> - foreach (var map in GetFromMaps(types)) - { - configuration.CreateMap(map.Source, map.Destination); - } - - // IMapTo<> - foreach (var map in GetToMaps(types)) - { - configuration.CreateMap(map.Source, map.Destination); - } - - // IHaveCustomMappings - foreach (var map in GetCustomMappings(types)) - { - map.CreateMappings(configuration); - } - }); - MapperInstance = new Mapper(new MapperConfiguration(config)); - } - - private static IEnumerable GetFromMaps(IEnumerable types) - { - var fromMaps = from t in types - from i in t.GetTypeInfo().GetInterfaces() - where i.GetTypeInfo().IsGenericType && - i.GetGenericTypeDefinition() == typeof(IMapFrom<>) && - !t.GetTypeInfo().IsAbstract && - !t.GetTypeInfo().IsInterface - select new TypesMap - { - Source = i.GetTypeInfo().GetGenericArguments()[0], - Destination = t, - }; - - return fromMaps; - } - - private static IEnumerable GetToMaps(IEnumerable types) - { - var toMaps = from t in types - from i in t.GetTypeInfo().GetInterfaces() - where i.GetTypeInfo().IsGenericType && - i.GetTypeInfo().GetGenericTypeDefinition() == typeof(IMapTo<>) && - !t.GetTypeInfo().IsAbstract && - !t.GetTypeInfo().IsInterface - select new TypesMap - { - Source = t, - Destination = i.GetTypeInfo().GetGenericArguments()[0], - }; - - return toMaps; - } - - private static IEnumerable GetCustomMappings(IEnumerable types) - { - var customMaps = from t in types - from i in t.GetTypeInfo().GetInterfaces() - where typeof(IHaveCustomMappings).GetTypeInfo().IsAssignableFrom(t) && - !t.GetTypeInfo().IsAbstract && - !t.GetTypeInfo().IsInterface - select (IHaveCustomMappings)Activator.CreateInstance(t); - - return customMaps; - } - - private class TypesMap - { - public Type Source { get; set; } - - public Type Destination { get; set; } - } - } -} diff --git a/Services/LOVE.NET.Services.Mapping/IHaveCustomMappings.cs b/Services/LOVE.NET.Services.Mapping/IHaveCustomMappings.cs deleted file mode 100644 index 0813290..0000000 --- a/Services/LOVE.NET.Services.Mapping/IHaveCustomMappings.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace LOVE.NET.Services.Mapping -{ - using AutoMapper; - - public interface IHaveCustomMappings - { - void CreateMappings(IProfileExpression configuration); - } -} diff --git a/Services/LOVE.NET.Services.Mapping/IMapFrom.cs b/Services/LOVE.NET.Services.Mapping/IMapFrom.cs deleted file mode 100644 index a02283d..0000000 --- a/Services/LOVE.NET.Services.Mapping/IMapFrom.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace LOVE.NET.Services.Mapping -{ - // ReSharper disable once UnusedTypeParameter - public interface IMapFrom - { - } -} diff --git a/Services/LOVE.NET.Services.Mapping/IMapTo.cs b/Services/LOVE.NET.Services.Mapping/IMapTo.cs deleted file mode 100644 index 3a94d22..0000000 --- a/Services/LOVE.NET.Services.Mapping/IMapTo.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace LOVE.NET.Services.Mapping -{ - // ReSharper disable once UnusedTypeParameter - public interface IMapTo - { - } -} diff --git a/Services/LOVE.NET.Services.Mapping/LOVE.NET.Services.Mapping.csproj b/Services/LOVE.NET.Services.Mapping/LOVE.NET.Services.Mapping.csproj deleted file mode 100644 index 8b71e4f..0000000 --- a/Services/LOVE.NET.Services.Mapping/LOVE.NET.Services.Mapping.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net8.0 - latest - - - - ..\..\Rules.ruleset - - - - - - - - - runtime; build; native; contentfiles; analyzers - - - - diff --git a/Services/LOVE.NET.Services.Mapping/QueryableMappingExtensions.cs b/Services/LOVE.NET.Services.Mapping/QueryableMappingExtensions.cs deleted file mode 100644 index 8b4212c..0000000 --- a/Services/LOVE.NET.Services.Mapping/QueryableMappingExtensions.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace LOVE.NET.Services.Mapping -{ - using System; - using System.Linq; - using System.Linq.Expressions; - - using AutoMapper.QueryableExtensions; - - public static class QueryableMappingExtensions - { - public static IQueryable To( - this IQueryable source, - params Expression>[] membersToExpand) - { - if (source == null) - { - throw new ArgumentNullException(nameof(source)); - } - - return source.ProjectTo(AutoMapperConfig.MapperInstance.ConfigurationProvider, null, membersToExpand); - } - - public static IQueryable To( - this IQueryable source, - object parameters) - { - if (source == null) - { - throw new ArgumentNullException(nameof(source)); - } - - return source.ProjectTo(AutoMapperConfig.MapperInstance.ConfigurationProvider, parameters); - } - } -} diff --git a/Services/LOVE.NET.Services.Messaging/EmailAttachment.cs b/Services/LOVE.NET.Services.Messaging/EmailAttachment.cs deleted file mode 100644 index 3f51e6d..0000000 --- a/Services/LOVE.NET.Services.Messaging/EmailAttachment.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace LOVE.NET.Services.Messaging -{ - public class EmailAttachment - { - public byte[] Content { get; set; } - - public string FileName { get; set; } - - public string MimeType { get; set; } - } -} diff --git a/Services/LOVE.NET.Services.Messaging/IEmailSender.cs b/Services/LOVE.NET.Services.Messaging/IEmailSender.cs deleted file mode 100644 index 0210b53..0000000 --- a/Services/LOVE.NET.Services.Messaging/IEmailSender.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace LOVE.NET.Services.Messaging -{ - using System.Collections.Generic; - using System.Threading.Tasks; - - public interface IEmailSender - { - Task SendEmailAsync( - string from, - string fromName, - string to, - string subject, - string htmlContent, - IEnumerable attachments = null); - } -} diff --git a/Services/LOVE.NET.Services.Messaging/LOVE.NET.Services.Messaging.csproj b/Services/LOVE.NET.Services.Messaging/LOVE.NET.Services.Messaging.csproj deleted file mode 100644 index dab9bdf..0000000 --- a/Services/LOVE.NET.Services.Messaging/LOVE.NET.Services.Messaging.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net8.0 - latest - - - - ..\..\Rules.ruleset - - - - - - - - - runtime; build; native; contentfiles; analyzers - - - - \ No newline at end of file diff --git a/Services/LOVE.NET.Services.Messaging/SendGridEmailSender.cs b/Services/LOVE.NET.Services.Messaging/SendGridEmailSender.cs deleted file mode 100644 index 54fc5ad..0000000 --- a/Services/LOVE.NET.Services.Messaging/SendGridEmailSender.cs +++ /dev/null @@ -1,51 +0,0 @@ -namespace LOVE.NET.Services.Messaging -{ - using System; - using System.Collections.Generic; - using System.Linq; - using System.Threading.Tasks; - - using SendGrid; - using SendGrid.Helpers.Mail; - - public class SendGridEmailSender : IEmailSender - { - private readonly SendGridClient client; - - public SendGridEmailSender(string apiKey) - { - this.client = new SendGridClient(apiKey); - } - - public async Task SendEmailAsync(string from, string fromName, string to, string subject, string htmlContent, IEnumerable attachments = null) - { - if (string.IsNullOrWhiteSpace(subject) && string.IsNullOrWhiteSpace(htmlContent)) - { - throw new ArgumentException("Subject and message should be provided."); - } - - var fromAddress = new EmailAddress(from, fromName); - var toAddress = new EmailAddress(to); - var message = MailHelper.CreateSingleEmail(fromAddress, toAddress, subject, null, htmlContent); - if (attachments?.Any() == true) - { - foreach (var attachment in attachments) - { - message.AddAttachment(attachment.FileName, Convert.ToBase64String(attachment.Content), attachment.MimeType); - } - } - - try - { - var response = await this.client.SendEmailAsync(message); - Console.WriteLine(response.StatusCode); - Console.WriteLine(await response.Body.ReadAsStringAsync()); - } - catch (Exception e) - { - Console.WriteLine(e); - throw; - } - } - } -} diff --git a/Services/LOVE.NET.Services/Chats/ChatHub.cs b/Services/LOVE.NET.Services/Chats/ChatHub.cs deleted file mode 100644 index bdf3e07..0000000 --- a/Services/LOVE.NET.Services/Chats/ChatHub.cs +++ /dev/null @@ -1,81 +0,0 @@ -namespace LOVE.NET.Services.Chats -{ - using System; - using System.IO; - using System.Threading.Tasks; - - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Images; - using LOVE.NET.Web.ViewModels.Chat; - - using Microsoft.AspNetCore.Http; - using Microsoft.AspNetCore.SignalR; - - public class ChatHub : Hub - { - private readonly IChatService chatService; - private readonly IImagesService imagesService; - private readonly IUsersGroupService usersGroupService; - - public ChatHub( - IChatService chatService, - IImagesService imagesService, - IUsersGroupService usersGroupService) - { - this.chatService = chatService; - this.imagesService = imagesService; - this.usersGroupService = usersGroupService; - } - - public async Task JoinRoom(UserConnection userConnection) - { - await this.Groups - .AddToGroupAsync(this.Context.ConnectionId, userConnection.RoomId); - this.usersGroupService.AddUserToRoom(userConnection); - await this.Clients - .Group(userConnection.RoomId) - .SendAsync("RefreshUsersList", new - { - Users = this.usersGroupService.GetUsersInRoom(userConnection.RoomId), - HasLeft = false, - }); - } - - /// - /// Leave room on chat component unmount. - /// - /// User connection input. - public async Task LeaveRoom(UserConnection userConnection) - { - this.usersGroupService.RemoveUserFromRoom(userConnection); - await this.Clients - .Group(userConnection.RoomId) - .SendAsync("RefreshUsersList", new - { - Users = this.usersGroupService.GetUsersInRoom(userConnection.RoomId), - HasLeft = true, - }); - } - - public async Task SendMessage(MessageDto message) - { - message.CreatedOn = DateTime.UtcNow; - - if (message.Image != null) - { - byte[] bytes = Convert.FromBase64String(message.Image); - MemoryStream stream = new MemoryStream(bytes); - IFormFile file = new FormFile(stream, 0, bytes.Length, string.Empty, string.Empty); - - var imageUrl = await this.imagesService.UploadImageAsync(file); - message.ImageUrl = imageUrl; - } - - await this.Clients - .Group(message.RoomId) - .SendAsync("ReceiveMessage", message); - - await this.chatService.SaveMessageAsync(message); - } - } -} diff --git a/Services/LOVE.NET.Services/Chats/ChatService.cs b/Services/LOVE.NET.Services/Chats/ChatService.cs deleted file mode 100644 index 562ae91..0000000 --- a/Services/LOVE.NET.Services/Chats/ChatService.cs +++ /dev/null @@ -1,66 +0,0 @@ -namespace LOVE.NET.Services.Chats -{ - using System.Collections.Generic; - using System.Linq; - using System.Threading.Tasks; - - using LOVE.NET.Data.Models; - using LOVE.NET.Data.Repositories.Chat; - using LOVE.NET.Services.Mapping; - using LOVE.NET.Web.ViewModels.Chat; - - using Microsoft.EntityFrameworkCore; - - using static LOVE.NET.Common.GlobalConstants; - - public class ChatService : IChatService - { - private readonly IChatRepository chatRepository; - private readonly IChatroomRepository chatroomRepository; - - public ChatService( - IChatRepository chatRepository, - IChatroomRepository chatroomRepository) - { - this.chatRepository = chatRepository; - this.chatroomRepository = chatroomRepository; - } - - public ChatViewModel GetChat(ChatRequestViewModel request) - { - // Add skip and take - var messagesQuery = this.chatRepository - .AllAsNoTracking(m => m.RoomId == request.RoomId) - .Include(x => x.User) - .OrderByDescending(x => x.CreatedOn); - - var messages = messagesQuery - .Skip((request.Page - 1) * DefaultTake) - .Take(DefaultTake) - .To(); - - var count = messagesQuery.Count(); - - return new ChatViewModel() - { - Messages = messages, - TotalMessages = count, - }; - } - - public async Task SaveMessageAsync(MessageDto message) - { - var data = AutoMapperConfig.MapperInstance.Map(message); - - await this.chatRepository.SaveMessageAsync(data); - } - - public IEnumerable GetChatrooms() - { - var result = this.chatroomRepository - .AllAsNoTracking() - .To(); - return result; - } - } -} diff --git a/Services/LOVE.NET.Services/Chats/IChatService.cs b/Services/LOVE.NET.Services/Chats/IChatService.cs deleted file mode 100644 index 6304667..0000000 --- a/Services/LOVE.NET.Services/Chats/IChatService.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace LOVE.NET.Services.Chats -{ - using System.Collections.Generic; - using System.Threading.Tasks; - - using LOVE.NET.Data.Models; - using LOVE.NET.Web.ViewModels.Chat; - - public interface IChatService - { - ChatViewModel GetChat(ChatRequestViewModel request); - - Task SaveMessageAsync(MessageDto message); - - IEnumerable GetChatrooms(); - } -} diff --git a/Services/LOVE.NET.Services/Chats/IUsersGroupService.cs b/Services/LOVE.NET.Services/Chats/IUsersGroupService.cs deleted file mode 100644 index 7aa9661..0000000 --- a/Services/LOVE.NET.Services/Chats/IUsersGroupService.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace LOVE.NET.Services.Chats -{ - using System.Collections.Generic; - - using LOVE.NET.Data.Models; - using LOVE.NET.Web.ViewModels.Chat; - - public interface IUsersGroupService - { - void AddUserToRoom(UserConnection connection); - - void RemoveUserFromRoom(UserConnection connection); - - IEnumerable GetUsersInRoom(string roomId); - } -} diff --git a/Services/LOVE.NET.Services/Chats/UsersGroupService.cs b/Services/LOVE.NET.Services/Chats/UsersGroupService.cs deleted file mode 100644 index f41c0bf..0000000 --- a/Services/LOVE.NET.Services/Chats/UsersGroupService.cs +++ /dev/null @@ -1,57 +0,0 @@ -namespace LOVE.NET.Services.Chats -{ - using System.Collections.Generic; - using System.Linq; - - using LOVE.NET.Data.Models; - using LOVE.NET.Web.ViewModels.Chat; - - - public class UsersGroupService : IUsersGroupService - { - // Use Redis Cache for this - private readonly Dictionary> usersInRooms = - new(); - - public void AddUserToRoom(UserConnection connection) - { - if (!this.usersInRooms.ContainsKey(connection.RoomId)) - { - this.usersInRooms[connection.RoomId] = new HashSet(); - } - - if (this.usersInRooms[connection.RoomId] - .Any(x => x.Id == connection.UserId) == false) - { - this.usersInRooms[connection.RoomId].Add(new UserInRoomModel() - { - Id = connection.UserId, - ProfilePictureUrl = connection.ProfilePictureUrl, - UserName = connection.UserName, - }); - } - } - - public void RemoveUserFromRoom(UserConnection connection) - { - if (this.usersInRooms.ContainsKey(connection.RoomId)) - { - this.usersInRooms[connection.RoomId].RemoveWhere(x => x.Id == connection.UserId); - if (this.usersInRooms[connection.RoomId].Any() == false) - { - this.usersInRooms.Remove(connection.RoomId); - } - } - } - - public IEnumerable GetUsersInRoom(string roomId) - { - if (this.usersInRooms.ContainsKey(roomId) == false) - { - return Enumerable.Empty(); - } - - return this.usersInRooms[roomId]; - } - } -} diff --git a/Services/LOVE.NET.Services/Countries/CountriesService.cs b/Services/LOVE.NET.Services/Countries/CountriesService.cs deleted file mode 100644 index 7c70142..0000000 --- a/Services/LOVE.NET.Services/Countries/CountriesService.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace LOVE.NET.Services.Countries -{ - using System.Collections.Generic; - using System.Linq; - - using LOVE.NET.Data.Repositories.Countries; - using LOVE.NET.Services.Mapping; - using LOVE.NET.Web.ViewModels.Countries; - - public class CountriesService : ICountriesService - { - private readonly ICountriesRepository countriesRepository; - - public CountriesService(ICountriesRepository countriesRepository) - { - this.countriesRepository = countriesRepository; - } - - public IEnumerable GetAll() - { - var countriesWithCities = this.countriesRepository.AllAsNoTracking(); - - var result = AutoMapperConfig.MapperInstance.Map>(countriesWithCities).ToList(); - - result.Insert(0, new CountryViewModel() - { - CountryId = 0, - CountryName = "Chooce country here", - }); - - return result; - } - - public CountryCitiesViewModel Get(int id) - { - var country = this.countriesRepository - .WithAllInformation(x => x.Id == id) - .FirstOrDefault(); - - var result = AutoMapperConfig.MapperInstance.Map(country); - - var orderedCities = result.Cities.OrderBy(c => c.CityName).ToList(); - orderedCities.Insert(0, new CityViewModel() - { - CityId = 0, - CityName = "Choose city here", - }); - - result.Cities = orderedCities; - - return result; - } - } -} diff --git a/Services/LOVE.NET.Services/Countries/ICountriesService.cs b/Services/LOVE.NET.Services/Countries/ICountriesService.cs deleted file mode 100644 index 80c9dd4..0000000 --- a/Services/LOVE.NET.Services/Countries/ICountriesService.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace LOVE.NET.Services.Countries -{ - using System.Collections.Generic; - - using LOVE.NET.Web.ViewModels.Countries; - - public interface ICountriesService - { - IEnumerable GetAll(); - - CountryCitiesViewModel Get(int id); - } -} diff --git a/Services/LOVE.NET.Services/Dashboard/DashboardService.cs b/Services/LOVE.NET.Services/Dashboard/DashboardService.cs deleted file mode 100644 index 926eb56..0000000 --- a/Services/LOVE.NET.Services/Dashboard/DashboardService.cs +++ /dev/null @@ -1,137 +0,0 @@ -namespace LOVE.NET.Services.Dashboard -{ - using System.Linq; - using System.Threading.Tasks; - - using LOVE.NET.Common; - using LOVE.NET.Data.Common.Repositories; - using LOVE.NET.Data.Models; - using LOVE.NET.Data.Repositories.Users; - using LOVE.NET.Services.Mapping; - using LOVE.NET.Web.ViewModels.Dashboard; - using LOVE.NET.Web.ViewModels.Identity; - - using Microsoft.AspNetCore.Identity; - using Microsoft.EntityFrameworkCore; - - using static LOVE.NET.Common.GlobalConstants; - using static LOVE.NET.Common.GlobalConstants.ControllerResponseMessages; - - public class DashboardService : IDashboardService - { - private readonly IUsersRepository usersRepository; - private readonly IRepository imagesRepository; - private readonly IRepository messagesRepository; - private readonly UserManager userManager; - - public DashboardService( - IUsersRepository usersRepository, - IRepository imagesRepository, - IRepository messagesRepository, - UserManager userManager) - { - this.usersRepository = usersRepository; - this.imagesRepository = imagesRepository; - this.messagesRepository = messagesRepository; - this.userManager = userManager; - } - - public StatisticsViewModel GetStatistics() - { - var usersCount = this.usersRepository.AllAsNoTracking().Count(); - var bannedUsersCount = this.usersRepository - .WithAllInformation(u => u.LockoutEnd != null) - .Count(); - var matchesCount = this.usersRepository - .AllAsNoTracking().Count(user => - user.LikesSent.Any(l => - user.LikesReceived - .Select(lr => lr.UserId) - .Intersect( - user.LikesSent - .Select(ls => ls.LikedUserId)) - .Contains(l.LikedUserId))); - var likedUsersCount = this.usersRepository.AllAsNoTracking() - .Count(u => u.LikesReceived.Any()); - var notSwipedUsers = usersCount - likedUsersCount; - var imagesCount = this.imagesRepository.AllAsNoTracking().Count(); - var messagesCount = this.messagesRepository.AllAsNoTracking().Count(); - - var result = new StatisticsViewModel() - { - UsersCount = usersCount, - BannedUsersCount = bannedUsersCount, - MatchesCount = matchesCount, - LikedUsersCount = likedUsersCount, - NotSwipedUsersCount = notSwipedUsers, - ImagesCount = imagesCount, - MessagesCount = messagesCount, - }; - - return result; - } - - public async Task GetUsersAsync( - DashboardUserRequestViewModel request, - string loggedUserId) - { - var users = this.usersRepository.AllAsNoTracking() - .Where(u => - u.Email != AdministratorEmail && - u.Id != loggedUserId); - - if (!string.IsNullOrEmpty(request.Search)) - { - var searchTerm = $"%{request.Search.Trim().ToLower()}%"; - - users = users.Where(u => - EF.Functions.Like(u.Email.ToLower(), searchTerm) || - EF.Functions.Like(u.UserName.ToLower(), searchTerm) || - EF.Functions.Like(u.Bio.ToLower(), searchTerm) || - EF.Functions.Like(u.City.Name.ToLower(), searchTerm) || - EF.Functions.Like(u.Country.Name.ToLower(), searchTerm) || - EF.Functions.Like(u.Gender.Name.ToLower(), searchTerm)); - } - - if (request.ShowBanned) - { - users = users.Where(u => u.LockoutEnd != null); - } - - var totalUsers = users.Count(); - - users = users.OrderByDescending(u => u.CreatedOn) - .Skip((request.Page - 1) * DefaultTake) - .Take(DefaultTake); - - var mappedUsers = await users.To().ToListAsync(); - - var result = new DashboardUserViewModel() - { - Users = mappedUsers, - TotalUsers = totalUsers, - }; - - return result; - } - - public async Task ModerateAsync(ModerateUserViewModel request) - { - var user = this.usersRepository.All().FirstOrDefault(u => u.Id == request.UserId); - - if (user == null) - { - return UserNotFound; - } - - var result = await this.userManager.SetLockoutEndDateAsync(user, request.BannedUntil); - - if (!result.Succeeded) - { - return UserCouldNotBeBanned; - } - - return true; - } - } -} diff --git a/Services/LOVE.NET.Services/Dashboard/IDashboardService.cs b/Services/LOVE.NET.Services/Dashboard/IDashboardService.cs deleted file mode 100644 index bf79fb0..0000000 --- a/Services/LOVE.NET.Services/Dashboard/IDashboardService.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace LOVE.NET.Services.Dashboard -{ - using System.Threading.Tasks; - - using LOVE.NET.Common; - using LOVE.NET.Web.ViewModels.Dashboard; - - public interface IDashboardService - { - StatisticsViewModel GetStatistics(); - - Task GetUsersAsync( - DashboardUserRequestViewModel request, - string loggedUserId); - - Task ModerateAsync(ModerateUserViewModel request); - } -} diff --git a/Services/LOVE.NET.Services/Dating/DatingService.cs b/Services/LOVE.NET.Services/Dating/DatingService.cs deleted file mode 100644 index b9fa683..0000000 --- a/Services/LOVE.NET.Services/Dating/DatingService.cs +++ /dev/null @@ -1,152 +0,0 @@ -namespace LOVE.NET.Services.Dating -{ - using System; - using System.Collections.Generic; - using System.Linq; - using System.Threading.Tasks; - - using LOVE.NET.Common; - using LOVE.NET.Data.Models; - using LOVE.NET.Data.Repositories.Users; - using LOVE.NET.Services.Mapping; - using LOVE.NET.Web.ViewModels.Dating; - using LOVE.NET.Web.ViewModels.Identity; - - using Microsoft.EntityFrameworkCore; - - using static LOVE.NET.Common.GlobalConstants; - using static LOVE.NET.Common.GlobalConstants.ControllerResponseMessages; - - public class DatingService : IDatingService - { - private readonly IUsersRepository usersRepository; - - public DatingService(IUsersRepository usersRepository) - { - this.usersRepository = usersRepository; - } - - public IEnumerable GetNotSwipedUsers(string userId) - { - var notSwipedUsers = this.usersRepository - .WithAllInformation(u => - u.Id != userId && - u.Roles.Any() == false && // Excluding admins - u.LikesReceived.Any(lr => lr.UserId == userId) == false) - .To(); - - foreach (var user in notSwipedUsers) - { - user.Images = user.Images.OrderByDescending(i => i.IsProfilePicture).ToList(); - } - - return notSwipedUsers; - } - - public MatchesViewModel GetMatches(MatchesRequestViewModel request) - { - var user = this.usersRepository.WithAllInformation(u => u.Id == request.UserId).FirstOrDefault(); - - var matchesIds = user.LikesSent - .Where(l => - user.LikesReceived - .Select(lr => lr.UserId) - .Intersect( - user.LikesSent - .Select(ls => ls.LikedUserId)) - .Contains(l.LikedUserId)) - .OrderByDescending(ls => ls.CreatedOn) - .Select(x => x.LikedUserId) - .ToList(); - - var matches = this.usersRepository - .WithAllInformation() - .To(); - - if (!string.IsNullOrEmpty(request.Search)) - { - var searchTerm = $"%{request.Search.Trim().ToLower()}%"; - - matches = matches.Where(u => - EF.Functions.Like(u.UserName.ToLower(), searchTerm) || - EF.Functions.Like(u.Bio.ToLower(), searchTerm) || - EF.Functions.Like(u.CityName.ToLower(), searchTerm) || - EF.Functions.Like(u.CountryName.ToLower(), searchTerm) || - EF.Functions.Like(u.GenderName.ToLower(), searchTerm)); - } - - var matchesResult = matches - .Where(m => matchesIds.Contains(m.Id)) - .Skip((request.Page - 1) * DefaultTake) - .Take(DefaultTake) - .ToList(); - - var totalMatches = matchesResult.Count(); - - foreach (var match in matchesResult) - { - var roomId = string.Join(string.Empty, new[] { match.Id, request.UserId }.OrderBy(id => id)); - match.RoomId = roomId; - match.Images = match.Images.OrderByDescending(i => i.IsProfilePicture).ToList(); - } - - var result = new MatchesViewModel() - { - Matches = matchesResult, - TotalMatches = totalMatches, - }; - - return result; - } - - public UserMatchViewModel GetCurrentMatch(string userId) - { - var match = this.usersRepository - .WithAllInformation(u => - u.Id == userId) - .FirstOrDefault(); - - var result = AutoMapperConfig.MapperInstance.Map(match); - result.Images = result.Images.OrderByDescending(i => i.IsProfilePicture).ToList(); - - return result; - } - - public async Task LikeAsync(string userId, string likedUserId) - { - var users = this.usersRepository.WithAllInformation( - u => new[] { userId, likedUserId }.Any(id => id == u.Id)); - - var likedUser = await users.FirstOrDefaultAsync(u => u.Id == likedUserId); - - if (likedUser == null) - { - return UserNotFound; - } - - if (users.Count() == 1) - { - return YouCantLikeYourself; - } - - var user = await users.FirstOrDefaultAsync(u => u.Id == userId); - - if (user.LikesSent.Any(ls => ls.LikedUserId == likedUserId)) - { - return UserAlreadyLiked; - } - - user.LikesSent.Add(new Like() - { - LikedUserId = likedUserId, - CreatedOn = DateTime.UtcNow, - }); - - await this.usersRepository.SaveChangesAsync(); - - var isMatch = likedUser.LikesSent.Any(ls => ls.LikedUserId == userId); - - return isMatch; - } - } -} diff --git a/Services/LOVE.NET.Services/Dating/IDatingService.cs b/Services/LOVE.NET.Services/Dating/IDatingService.cs deleted file mode 100644 index 52ee499..0000000 --- a/Services/LOVE.NET.Services/Dating/IDatingService.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace LOVE.NET.Services.Dating -{ - using System.Collections.Generic; - using System.Threading.Tasks; - - using LOVE.NET.Common; - using LOVE.NET.Web.ViewModels.Dating; - using LOVE.NET.Web.ViewModels.Identity; - - public interface IDatingService - { - IEnumerable GetNotSwipedUsers(string userId); - - UserMatchViewModel GetCurrentMatch(string userId); - - MatchesViewModel GetMatches(MatchesRequestViewModel request); - - Task LikeAsync(string userId, string likedUserId); - } -} diff --git a/Services/LOVE.NET.Services/Email/EmailService.cs b/Services/LOVE.NET.Services/Email/EmailService.cs deleted file mode 100644 index 656b7b4..0000000 --- a/Services/LOVE.NET.Services/Email/EmailService.cs +++ /dev/null @@ -1,179 +0,0 @@ -namespace LOVE.NET.Services.Email -{ - using System.IO; - using System.Linq; - using System.Text; - using System.Threading.Tasks; - - using LOVE.NET.Common; - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Messaging; - using LOVE.NET.Web.ViewModels.Identity; - - using Microsoft.AspNetCore.Hosting; - using Microsoft.AspNetCore.Identity; - using Microsoft.AspNetCore.WebUtilities; - - using static LOVE.NET.Common.GlobalConstants; - using static LOVE.NET.Common.GlobalConstants.ControllerRoutesConstants; - using static LOVE.NET.Common.GlobalConstants.EmailMessagesConstants; - using static LOVE.NET.Common.GlobalConstants.EmailSenderConstants; - - public class EmailService : IEmailService - { - private readonly UserManager userManager; - private readonly IEmailSender emailSender; - private readonly IWebHostEnvironment environment; // Add in .csproj Microsoft.AspNetCore.App - - public EmailService( - UserManager userManager, - IEmailSender emailSender, - IWebHostEnvironment environment) - { - this.userManager = userManager; - this.emailSender = emailSender; - this.environment = environment; - } - - public async Task SendEmailConfirmationAsync(string origin, ApplicationUser user) - { - var token = await this.userManager.GenerateEmailConfirmationTokenAsync(user); - token = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token)); - - string emailUrl = this.BuildEmailUrl(origin, user.Email, VerifyEmailRoute, token); - string path = this.GetEmailTemplatePath(VerifyHtml); - var subject = File.ReadAllText(path); - var content = string.Format(subject, emailUrl); - - await this.emailSender.SendEmailAsync( - FromEmail, - FromName, - user.Email, - VerifyEmailSubject, - content); - } - - public async Task SendResetPasswordEmailAsync(string origin, ApplicationUser user) - { - var token = await this.userManager.GeneratePasswordResetTokenAsync(user); - token = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token)); - - string emailUrl = this.BuildEmailUrl(origin, user.Email, ResetPasswordRoute, token); - string path = this.GetEmailTemplatePath(ResetPasswordHtml); - var subject = File.ReadAllText(path); - var content = string.Format(subject, emailUrl); - - await this.emailSender.SendEmailAsync( - FromEmail, - FromName, - user.Email, - PasswordResetEmailSubject, - content); - } - - public async Task VerifyEmailAsync(string email, string token) - { - var user = await this.userManager.FindByEmailAsync(email); - - if (user == null) - { - return Unauthorized; - } - - var decodedTokenBytes = WebEncoders.Base64UrlDecode(token); - - var decodedToken = Encoding.UTF8.GetString(decodedTokenBytes); - - var result = await this.userManager.ConfirmEmailAsync(user, decodedToken); - - if (!result.Succeeded) - { - return IncorrectEmail; - } - - user.LockoutEnabled = true; - await this.userManager.UpdateAsync(user); - - return true; - } - - public async Task ResetPasswordAsync(ResetPasswordViewModel model) - { - var user = await this.userManager.FindByEmailAsync(model.Email); - - if (user == null) - { - return Unauthorized; - } - - var decodedTokenBytes = WebEncoders.Base64UrlDecode(model.Token); - - var decodedToken = Encoding.UTF8.GetString(decodedTokenBytes); - - var result = await this.userManager.ResetPasswordAsync(user, decodedToken, model.Password); - - if (!result.Succeeded) - { - return string.Join("\n", result.Errors.Select(x => x.Description)); - } - - return true; - } - - public async Task SendResetPasswordLinkAsync(string email, string origin) - { - var user = await this.userManager.FindByEmailAsync(email); - - if (user == null) - { - return EmailDoesntMatch; - } - - await this.SendResetPasswordEmailAsync(origin, user); - - return true; - } - - public async Task ResendEmailConfirmationLinkAsync(string email, string origin) - { - var user = await this.userManager.FindByEmailAsync(email); - - if (user == null) - { - return EmailDoesntMatch; - } - - if (user.LockoutEnabled) - { - return EmailAlreadyVerified; - } - - await this.SendEmailConfirmationAsync(origin, user); - - return true; - } - - private string GetEmailTemplatePath(string templateFileName) - { - return Path.Combine( - this.environment.WebRootPath, - Templates, - Email, - templateFileName); - } - - private string BuildEmailUrl( - string origin, - string email, - string route, - string token) - { - return string.Format( - VerifyUrl, - origin, - route, - token, - email); - } - } -} diff --git a/Services/LOVE.NET.Services/Email/IEmailService.cs b/Services/LOVE.NET.Services/Email/IEmailService.cs deleted file mode 100644 index c84916e..0000000 --- a/Services/LOVE.NET.Services/Email/IEmailService.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace LOVE.NET.Services.Email -{ - using System.Threading.Tasks; - - using LOVE.NET.Common; - using LOVE.NET.Data.Models; - using LOVE.NET.Web.ViewModels.Identity; - - public interface IEmailService - { - Task SendEmailConfirmationAsync(string origin, ApplicationUser user); - - Task VerifyEmailAsync(string email, string token); - - Task ResetPasswordAsync(ResetPasswordViewModel model); - - Task ResendEmailConfirmationLinkAsync(string email, string origin); - - Task SendResetPasswordLinkAsync(string email, string origin); - } -} diff --git a/Services/LOVE.NET.Services/Genders/GendersService.cs b/Services/LOVE.NET.Services/Genders/GendersService.cs deleted file mode 100644 index 9b4ce0a..0000000 --- a/Services/LOVE.NET.Services/Genders/GendersService.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace LOVE.NET.Services.Genders -{ - using System.Collections.Generic; - - using LOVE.NET.Data.Common.Repositories; - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Mapping; - using LOVE.NET.Web.ViewModels.Genders; - - public class GendersService : IGendersService - { - private readonly IRepository gendersRepository; - - public GendersService(IRepository gendersRepository) - { - this.gendersRepository = gendersRepository; - } - - public IEnumerable GetAll() - { - var genders = this.gendersRepository.AllAsNoTracking(); - - var result = AutoMapperConfig.MapperInstance.Map>(genders); - - return result; - } - } -} diff --git a/Services/LOVE.NET.Services/Genders/IGendersService.cs b/Services/LOVE.NET.Services/Genders/IGendersService.cs deleted file mode 100644 index 4057c8f..0000000 --- a/Services/LOVE.NET.Services/Genders/IGendersService.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace LOVE.NET.Services.Genders -{ - using System.Collections.Generic; - - using LOVE.NET.Web.ViewModels.Genders; - - public interface IGendersService - { - IEnumerable GetAll(); - } -} diff --git a/Services/LOVE.NET.Services/Identity/IIdentityService.cs b/Services/LOVE.NET.Services/Identity/IIdentityService.cs deleted file mode 100644 index ce1acb1..0000000 --- a/Services/LOVE.NET.Services/Identity/IIdentityService.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace LOVE.NET.Services.Identity -{ - using System.Threading.Tasks; - - using LOVE.NET.Data.Models; - using LOVE.NET.Web.ViewModels.Identity; - - using Microsoft.AspNetCore.Identity; - - public interface IIdentityService - { - Task GenerateJwtToken(ApplicationUser user); - - Task LoginAsync(LoginViewModel model); - - Task RegisterAsync(RegisterViewModel model); - - RefreshToken GenerateRefreshToken(); - - UserDetailsViewModel GetUserDetails(string id); - - Task EditUserAsync(EditUserViewModel model); - } -} diff --git a/Services/LOVE.NET.Services/Identity/IdentityService.cs b/Services/LOVE.NET.Services/Identity/IdentityService.cs deleted file mode 100644 index d02c159..0000000 --- a/Services/LOVE.NET.Services/Identity/IdentityService.cs +++ /dev/null @@ -1,258 +0,0 @@ -namespace LOVE.NET.Services.Identity -{ - using System; - using System.Collections.Generic; - using System.IdentityModel.Tokens.Jwt; - using System.Linq; - using System.Security.Claims; - using System.Security.Cryptography; - using System.Text; - using System.Threading.Tasks; - - using LOVE.NET.Data.Models; - using LOVE.NET.Data.Repositories.Users; - using LOVE.NET.Services.Images; - using LOVE.NET.Services.Mapping; - using LOVE.NET.Web.ViewModels.Identity; - - using Microsoft.AspNetCore.Http; - using Microsoft.AspNetCore.Identity; - using Microsoft.Extensions.Configuration; - using Microsoft.IdentityModel.Tokens; - - using Newtonsoft.Json; - - using static LOVE.NET.Common.GlobalConstants; - using static LOVE.NET.Common.GlobalConstants.ControllerResponseMessages; - using static LOVE.NET.Common.GlobalConstants.EmailMessagesConstants; - - public class IdentityService : IIdentityService - { - private readonly UserManager userManager; - private readonly IConfiguration configuration; - private readonly IUsersRepository usersRepository; - private readonly IImagesService imagesService; - - public IdentityService( - UserManager userManager, - IConfiguration configuration, - IUsersRepository usersRepository, - IImagesService imagesService) - { - this.userManager = userManager; - this.configuration = configuration; - this.usersRepository = usersRepository; - this.imagesService = imagesService; - } - - public async Task GenerateJwtToken(ApplicationUser user) - { - var claims = new List - { - new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), - new Claim(ClaimTypes.Email, user.Email), - new Claim(ClaimTypes.Name, user.UserName), - new Claim(ClaimTypes.NameIdentifier, user.Id), - }; - - var roles = await this.userManager.GetRolesAsync(user); - - foreach (var role in roles) - { - claims.Add(new Claim(ClaimTypes.Role, role)); - } - - var key = new SymmetricSecurityKey( - Encoding.UTF8.GetBytes(this.configuration[AuthSettingsKey])); - - var signingCredentials = new SigningCredentials( - key, - SecurityAlgorithms.HmacSha512); - - var securityToken = new JwtSecurityToken( - issuer: this.configuration[AuthSettingsIssuer], - audience: this.configuration[AuthSettingsAudience], - claims: claims, - expires: DateTime.UtcNow.AddHours(1), - signingCredentials: signingCredentials); - - string token = new JwtSecurityTokenHandler().WriteToken(securityToken); - - return token; - } - - public async Task RegisterAsync(RegisterViewModel model) - { - var images = new List(); - - if (model.Image != null) - { - images.Add(model.Image); - } - - if (model?.NewImages?.Any() == true) - { - images.AddRange(model.NewImages); - } - - var imageUrls = new List(await this.imagesService.UploadImagesAsync(images)); - - if (model.Image == null) - { - imageUrls.Insert(0, DefaultProfilePictureUrl); - } - - var user = AutoMapperConfig.MapperInstance.Map(model); - - for (int i = 0; i < imageUrls.Count; i++) - { - var image = new Image() - { - CreatedOn = DateTime.UtcNow, - Url = imageUrls[i], - }; - - if ((imageUrls.Contains(DefaultProfilePictureUrl) || model.Image != null) && i == 0) - { - image.IsProfilePicture = true; - } - - user.Images.Add(image); - } - - var result = await this.userManager.CreateAsync(user, model.Password); - - return result; - } - - public async Task LoginAsync(LoginViewModel model) - { - var user = this.usersRepository - .WithAllInformation() - .Where(u => u.Email == model.Email) - .SingleOrDefault(); - - if (user == null) - { - throw new ArgumentException(UserNotFound); - } - - var isValidPassword = await this.userManager.CheckPasswordAsync(user, model.Password); - - if (!isValidPassword) - { - throw new ArgumentException(WrongPassword); - } - - if (!user.EmailConfirmed) - { - throw new ArgumentException(EmailNotConfirmed); - } - - if (user.LockoutEnd != null) - { - throw new ArgumentException(string.Format(BannedUnitl, user.LockoutEnd)); - } - - // https://jwt.io/ for debug - var token = await this.GenerateJwtToken(user); - - this.usersRepository.Update(user); - await this.usersRepository.SaveChangesAsync(); - - var userRoles = await this.userManager.GetRolesAsync(user); - - var response = AutoMapperConfig.MapperInstance.Map(user); - response.IsAdmin = userRoles.Any(r => r == AdministratorRoleName); - response.Token = token; - - return response; - } - - public RefreshToken GenerateRefreshToken() - { - var randomNumber = new byte[32]; - using var rng = RandomNumberGenerator.Create(); - - rng.GetBytes(randomNumber); - - var now = DateTime.UtcNow; - - return new RefreshToken - { - Token = Convert.ToBase64String(randomNumber), - Expires = DateTime.UtcNow.AddHours(1.5), - CreatedOn = now, - }; - } - - public UserDetailsViewModel GetUserDetails(string id) - { - var user = this.usersRepository.WithAllInformation(u => u.Id == id).FirstOrDefault(); - - var result = AutoMapperConfig.MapperInstance.Map(user); - result.Images = result.Images - .Where(x => !x.IsDeleted) - .OrderByDescending(i => i.IsProfilePicture).ToList(); - - return result; - } - - public async Task EditUserAsync(EditUserViewModel model) - { - var user = this.usersRepository.WithAllInformation(u => u.Id == model.Id).FirstOrDefault(); - - var images = new List(); - - if (model?.NewImages?.Any() == true) - { - images.AddRange(model.NewImages); - } - - var imageUrls = new List(await this.imagesService.UploadImagesAsync(images)); - - var updatedImages = new List(); - - if (model.Images?.Any() == true) - { - foreach (var item in model.Images) - { - updatedImages.Add(JsonConvert.DeserializeObject(item)); - } - } - - foreach (var image in user.Images) - { - var updatedImage = updatedImages.FirstOrDefault(i => i.Id == image.Id); - - if (updatedImage == null) - { - image.IsDeleted = true; - } - else - { - image.IsProfilePicture = updatedImage.IsProfilePicture; - } - } - - for (int i = 0; i < imageUrls.Count; i++) - { - user.Images.Add(new Image() - { - CreatedOn = DateTime.UtcNow, - Url = imageUrls[i], - IsProfilePicture = false, - }); - } - - user.UserName = model.UserName; - user.Bio = model.Bio; - user.Birthdate = model.Birthdate; - user.CountryId = model.CountryId; - user.CityId = model.CityId; - user.GenderId = model.GenderId; - - await this.usersRepository.SaveChangesAsync(); - } - } -} diff --git a/Services/LOVE.NET.Services/Images/IImagesService.cs b/Services/LOVE.NET.Services/Images/IImagesService.cs deleted file mode 100644 index 5494a35..0000000 --- a/Services/LOVE.NET.Services/Images/IImagesService.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace LOVE.NET.Services.Images -{ - using System.Collections.Generic; - using System.Threading.Tasks; - - using Microsoft.AspNetCore.Http; - - public interface IImagesService - { - Task UploadImageAsync(IFormFile image); - - Task> UploadImagesAsync(IEnumerable images); - } -} diff --git a/Services/LOVE.NET.Services/Images/ImagesService.cs b/Services/LOVE.NET.Services/Images/ImagesService.cs deleted file mode 100644 index 3c22266..0000000 --- a/Services/LOVE.NET.Services/Images/ImagesService.cs +++ /dev/null @@ -1,74 +0,0 @@ -namespace LOVE.NET.Services.Images -{ - using System; - using System.Collections.Generic; - using System.IO; - using System.Linq; - using System.Threading.Tasks; - - using CloudinaryDotNet; - using CloudinaryDotNet.Actions; - - using Microsoft.AspNetCore.Http; - - using static LOVE.NET.Common.GlobalConstants; - - public class ImagesService : IImagesService - { - private readonly Cloudinary cloudinary; - - public ImagesService(Cloudinary cloudinary) - { - this.cloudinary = cloudinary; - } - - public async Task UploadImageAsync(IFormFile image) - { - byte[] imageBytes; - string imageUrl; - - using (var memoryStream = new MemoryStream()) - { - await image.CopyToAsync(memoryStream); - imageBytes = memoryStream.ToArray(); - } - - using (var memoryStream = new MemoryStream(imageBytes)) - { - var uploadParams = new ImageUploadParams() - { - File = new FileDescription( - Guid.NewGuid().ToString(), - memoryStream), - }; - - var result = await this.cloudinary.UploadAsync(uploadParams); - - imageUrl = result.SecureUrl.AbsoluteUri; - } - - return imageUrl; - } - - public async Task> UploadImagesAsync(IEnumerable images) - { - var imagesArray = images.ToArray(); - var imagesCount = imagesArray.Length; - var imageTasks = new Task[imagesCount]; - - for (int i = 0; i < imagesCount; i++) - { - if (imagesArray[i] != null) - { - imageTasks[i] = this.UploadImageAsync(imagesArray[i]); - } - } - - await Task.WhenAll(imageTasks); - - var urls = imageTasks.Select(x => x.Result)?.ToList() ?? Enumerable.Empty(); - - return urls; - } - } -} diff --git a/Services/LOVE.NET.Services/LOVE.NET.Services.csproj b/Services/LOVE.NET.Services/LOVE.NET.Services.csproj deleted file mode 100644 index a998914..0000000 --- a/Services/LOVE.NET.Services/LOVE.NET.Services.csproj +++ /dev/null @@ -1,37 +0,0 @@ - - - - net8.0 - latest - - - - ..\..\Rules.ruleset - - - - - - - - - - runtime; build; native; contentfiles; analyzers - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Settings.StyleCop b/Settings.StyleCop deleted file mode 100644 index 61c6cfd..0000000 --- a/Settings.StyleCop +++ /dev/null @@ -1,80 +0,0 @@ - - - - - True - - - - - 2000 - - Json - - - - - - - - False - - - - - False - - - - - False - - - - - False - - - - - False - - - - - False - - - - - False - - - - - True - - - - - - - - - at - db - x - y - or - up - iq - it - un - bg - ip - id - - - - - \ No newline at end of file diff --git a/Tests/LOVE.NET.Services.Data.Tests/LOVE.NET.Services.TestsX.csproj b/Tests/LOVE.NET.Services.Data.Tests/LOVE.NET.Services.TestsX.csproj deleted file mode 100644 index 039fa82..0000000 --- a/Tests/LOVE.NET.Services.Data.Tests/LOVE.NET.Services.TestsX.csproj +++ /dev/null @@ -1,34 +0,0 @@ - - - - net6.0 - latest - - - - ..\..\Rules.ruleset - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers - - - runtime; build; native; contentfiles; analyzers - - - - - - - - - diff --git a/Tests/LOVE.NET.Services.Data.Tests/SettingsServiceTests.cs b/Tests/LOVE.NET.Services.Data.Tests/SettingsServiceTests.cs deleted file mode 100644 index 3e36dbc..0000000 --- a/Tests/LOVE.NET.Services.Data.Tests/SettingsServiceTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -namespace LOVE.NET.Services.Data.Tests -{ - using System.Collections.Generic; - using System.Linq; - using System.Threading.Tasks; - - using LOVE.NET.Data; - using LOVE.NET.Data.Common.Repositories; - using LOVE.NET.Data.Models; - using LOVE.NET.Data.Repositories; - - using Microsoft.EntityFrameworkCore; - - using Moq; - - using Xunit; - - public class SettingsServiceTests - { - [Fact] - public void GetCountShouldReturnCorrectNumber() - { - var repository = new Mock>(); - repository.Setup(r => r.AllAsNoTracking()).Returns(new List - { - new Setting(), - new Setting(), - new Setting(), - }.AsQueryable()); - var service = new SettingsService(repository.Object); - Assert.Equal(3, service.GetCount()); - repository.Verify(x => x.AllAsNoTracking(), Times.Once); - } - - [Fact] - public async Task GetCountShouldReturnCorrectNumberUsingDbContext() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(databaseName: "SettingsTestDb").Options; - using var dbContext = new ApplicationDbContext(options); - dbContext.Settings.Add(new Setting()); - dbContext.Settings.Add(new Setting()); - dbContext.Settings.Add(new Setting()); - await dbContext.SaveChangesAsync(); - - using var repository = new EfDeletableEntityRepository(dbContext); - var service = new SettingsService(repository); - Assert.Equal(3, service.GetCount()); - } - } -} diff --git a/Tests/LOVE.NET.Services.Data.Tests/TestsSetUp.cs b/Tests/LOVE.NET.Services.Data.Tests/TestsSetUp.cs deleted file mode 100644 index 16075b9..0000000 --- a/Tests/LOVE.NET.Services.Data.Tests/TestsSetUp.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace LOVE.NET.Services.Data.Tests -{ - public class TestsSetUp - { - } -} diff --git a/Tests/LOVE.NET.Web.Tests/LOVE.NET.Web.Tests.csproj b/Tests/LOVE.NET.Web.Tests/LOVE.NET.Web.Tests.csproj deleted file mode 100644 index 1e15202..0000000 --- a/Tests/LOVE.NET.Web.Tests/LOVE.NET.Web.Tests.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - - net8.0 - latest - - - - ..\..\Rules.ruleset - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers - - - runtime; build; native; contentfiles; analyzers - - - - - - - - diff --git a/Tests/LOVE.NET.Web.Tests/Properties/launchSettings.json b/Tests/LOVE.NET.Web.Tests/Properties/launchSettings.json deleted file mode 100644 index 12cffad..0000000 --- a/Tests/LOVE.NET.Web.Tests/Properties/launchSettings.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:61093/", - "sslPort": 0 - } - }, - "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "LOVE.NET.Web.Tests": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "http://localhost:61094/" - } - } -} \ No newline at end of file diff --git a/Tests/LOVE.NET.Web.Tests/WebTests.cs b/Tests/LOVE.NET.Web.Tests/WebTests.cs deleted file mode 100644 index 8044870..0000000 --- a/Tests/LOVE.NET.Web.Tests/WebTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace LOVE.NET.Web.Tests -{ - using System.Net; - using System.Threading.Tasks; - - using Microsoft.AspNetCore.Mvc.Testing; - - using Xunit; - - public class WebTests : IClassFixture> - { - private readonly WebApplicationFactory server; - - public WebTests(WebApplicationFactory server) - { - this.server = server; - } - - [Fact] - public async Task IndexPageShouldReturnStatusCode200WithTitle() - { - var client = this.server.CreateClient(); - var response = await client.GetAsync("/"); - response.EnsureSuccessStatusCode(); - var responseContent = await response.Content.ReadAsStringAsync(); - Assert.Contains("", responseContent); - } - - [Fact] - public async Task AccountManagePageRequiresAuthorization() - { - var client = this.server.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); - var response = await client.GetAsync("Identity/Account/Manage"); - Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); - } - } -} diff --git a/Tests/Sandbox/Program.cs b/Tests/Sandbox/Program.cs deleted file mode 100644 index 165cbb5..0000000 --- a/Tests/Sandbox/Program.cs +++ /dev/null @@ -1,85 +0,0 @@ -namespace Sandbox -{ - using System; - using System.Diagnostics; - using System.IO; - using System.Threading.Tasks; - - using CommandLine; - using LOVE.NET.Data; - using LOVE.NET.Data.Common; - using LOVE.NET.Data.Common.Repositories; - using LOVE.NET.Data.Models; - using LOVE.NET.Data.Repositories; - using LOVE.NET.Data.Seeding; - using LOVE.NET.Services.Data; - using LOVE.NET.Services.Messaging; - using Microsoft.EntityFrameworkCore; - using Microsoft.Extensions.Configuration; - using Microsoft.Extensions.DependencyInjection; - using Microsoft.Extensions.Logging; - - public static class Program - { - public static int Main(string[] args) - { - Console.WriteLine($"{typeof(Program).Namespace} ({string.Join(" ", args)}) starts working..."); - var serviceCollection = new ServiceCollection(); - ConfigureServices(serviceCollection); - IServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(true); - - // Seed data on application startup - using (var serviceScope = serviceProvider.CreateScope()) - { - var dbContext = serviceScope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); - dbContext.Database.Migrate(); - new ApplicationDbContextSeeder().SeedAsync(dbContext, serviceScope.ServiceProvider).GetAwaiter().GetResult(); - } - - using (var serviceScope = serviceProvider.CreateScope()) - { - serviceProvider = serviceScope.ServiceProvider; - - return Parser.Default.ParseArguments<SandboxOptions>(args).MapResult( - opts => SandboxCode(opts, serviceProvider).GetAwaiter().GetResult(), - _ => 255); - } - } - - private static async Task<int> SandboxCode(SandboxOptions options, IServiceProvider serviceProvider) - { - var sw = Stopwatch.StartNew(); - - var settingsService = serviceProvider.GetService<ISettingsService>(); - Console.WriteLine($"Count of settings: {settingsService.GetCount()}"); - - Console.WriteLine(sw.Elapsed); - return await Task.FromResult(0); - } - - private static void ConfigureServices(ServiceCollection services) - { - var configuration = new ConfigurationBuilder().SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json", false, true) - .AddEnvironmentVariables() - .Build(); - - services.AddSingleton<IConfiguration>(configuration); - - services.AddDbContext<ApplicationDbContext>( - options => options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")) - .UseLoggerFactory(new LoggerFactory())); - - services.AddDefaultIdentity<ApplicationUser>(IdentityOptionsProvider.GetIdentityOptions) - .AddRoles<ApplicationRole>().AddEntityFrameworkStores<ApplicationDbContext>(); - - services.AddScoped(typeof(IDeletableEntityRepository<>), typeof(EfDeletableEntityRepository<>)); - services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>)); - services.AddScoped<IDbQueryRunner, DbQueryRunner>(); - - // Application services - services.AddTransient<IEmailSender, NullMessageSender>(); - services.AddTransient<ISettingsService, SettingsService>(); - } - } -} diff --git a/Tests/Sandbox/Sandbox.csproj b/Tests/Sandbox/Sandbox.csproj deleted file mode 100644 index 5b50127..0000000 --- a/Tests/Sandbox/Sandbox.csproj +++ /dev/null @@ -1,46 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk"> - - <PropertyGroup> - <OutputType>Exe</OutputType> - <TargetFramework>net6.0</TargetFramework> - <LangVersion>latest</LangVersion> - </PropertyGroup> - - <PropertyGroup> - <CodeAnalysisRuleSet>..\..\Rules.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> - <AdditionalFiles Include="..\..\stylecop.json" /> - </ItemGroup> - - <ItemGroup> - <Content Include="appsettings.json"> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory> - </Content> - </ItemGroup> - - <ItemGroup> - <PackageReference Include="CommandLineParser" Version="2.9.1" /> - <PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.1" /> - <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="6.0.1" /> - <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" /> - <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="6.0.0" /> - <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="6.0.5" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="all"> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - </ItemGroup> - - <ItemGroup> - <ProjectReference Include="..\..\LOVE.NET.Common\LOVE.NET.Common.csproj" /> - <ProjectReference Include="..\..\Data\LOVE.NET.Data.Common\LOVE.NET.Data.Common.csproj" /> - <ProjectReference Include="..\..\Data\LOVE.NET.Data.Models\LOVE.NET.Data.Models.csproj" /> - <ProjectReference Include="..\..\Data\LOVE.NET.Data\LOVE.NET.Data.csproj" /> - <ProjectReference Include="..\..\Services\LOVE.NET.Services.Data\LOVE.NET.Services.Data.csproj" /> - <ProjectReference Include="..\..\Services\LOVE.NET.Services.Mapping\LOVE.NET.Services.Mapping.csproj" /> - <ProjectReference Include="..\..\Services\LOVE.NET.Services.Messaging\LOVE.NET.Services.Messaging.csproj" /> - <ProjectReference Include="..\..\Services\LOVE.NET.Services\LOVE.NET.Services.csproj" /> - </ItemGroup> - -</Project> diff --git a/Tests/Sandbox/SandboxOptions.cs b/Tests/Sandbox/SandboxOptions.cs deleted file mode 100644 index 5c0c165..0000000 --- a/Tests/Sandbox/SandboxOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Sandbox -{ - using CommandLine; - - [Verb("sandbox", HelpText = "Run sandbox code.")] - public class SandboxOptions - { - } -} diff --git a/Tests/Sandbox/appsettings.json b/Tests/Sandbox/appsettings.json deleted file mode 100644 index eea20b5..0000000 --- a/Tests/Sandbox/appsettings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "ConnectionStrings": { - "DefaultConnection": "Server=.;Database=LOVE.NET;Trusted_Connection=True;MultipleActiveResultSets=true" - } -} diff --git a/Web-FrontEnd/src/components/ChatRoom/ChatRoom.js b/Web-FrontEnd/src/components/ChatRoom/ChatRoom.js index 5e53831..84cc779 100644 --- a/Web-FrontEnd/src/components/ChatRoom/ChatRoom.js +++ b/Web-FrontEnd/src/components/ChatRoom/ChatRoom.js @@ -10,6 +10,7 @@ import AwayMessage from "../Modals/Chat/Messages/AwayMessage"; import HomeMessage from "../Modals/Chat/Messages/HomeMessage"; import { useIdentityContext } from "../../hooks/useIdentityContext"; import { useChat } from "../../hooks/useChat"; +import * as chatService from "../../services/chatService"; import { useEffect, useState } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { @@ -49,25 +50,27 @@ export default function ChatRoom(props) { }; }, []); - const onSendingMessage = (e) => { + const onSendingMessage = async (e) => { e.preventDefault(); if (!currentMessage.text && !currentMessage.image) { return; } - sendMessage({ - roomId, - userId: user.id, - text: currentMessage.text, - profilePicture: user.profilePicture, - }); + if (currentMessage.text) { + sendMessage({ + roomId, + text: currentMessage.text, + profilePicture: user.profilePicture, + }); + } if (currentMessage.image) { + // Upload over REST first; only the URL travels over the websocket. + const imageUrl = await chatService.uploadChatImage(currentMessage.image); sendMessage({ roomId, - userId: user.id, - image: currentMessage.image, + imageUrl, profilePicture: user.profilePicture, }); } @@ -89,7 +92,6 @@ export default function ChatRoom(props) { const onImagePaste = (e) => { const clipboardItems = e.clipboardData.items; const items = [].slice.call(clipboardItems).filter(function (item) { - // Filter the image items only return /^image\//.test(item.type); }); @@ -111,17 +113,11 @@ export default function ChatRoom(props) { "utf-8" ); - getBase64(file).then((res) => { - let text = res; - let skipCharactersCount = text.indexOf(",") + 1; - text = text.slice(skipCharactersCount); - - setCurrentMessage((prevStae) => { - return { - ...prevStae, - image: text, - }; - }); + setCurrentMessage((prevState) => { + return { + ...prevState, + image: file, + }; }); }; @@ -250,12 +246,3 @@ export default function ChatRoom(props) { </Container> ); } - -function getBase64(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => resolve(reader.result); - reader.onerror = (error) => reject(error); - }); -} diff --git a/Web-FrontEnd/src/components/Matches.js b/Web-FrontEnd/src/components/Matches.js index f199339..72bfbdf 100644 --- a/Web-FrontEnd/src/components/Matches.js +++ b/Web-FrontEnd/src/components/Matches.js @@ -132,6 +132,7 @@ export default function Matches() { chat={chat} sendMessage={chatState.sendMessage} fetchMessages={fetchMessages} + hasMoreMessagesToLoad={chatState.hasMoreMessagesToLoad} /> <Search search={() => fetchUsers(1)} diff --git a/Web-FrontEnd/src/components/Modals/Chat/ChatModal.js b/Web-FrontEnd/src/components/Modals/Chat/ChatModal.js index bbaa47f..87dd3a4 100644 --- a/Web-FrontEnd/src/components/Modals/Chat/ChatModal.js +++ b/Web-FrontEnd/src/components/Modals/Chat/ChatModal.js @@ -5,7 +5,7 @@ import { useState } from "react"; import { useIdentityContext } from "../../../hooks/useIdentityContext"; import HomeMessage from "./Messages/HomeMessage"; import AwayMessage from "./Messages/AwayMessage"; -import { useChat } from "../../../hooks/useChat"; +import * as chatService from "../../../services/chatService"; import InfiniteScroll from "react-infinite-scroll-component"; import styles from "./ChatModal.module.css"; @@ -13,7 +13,7 @@ import { Fragment } from "react"; export default function ChatModal(props) { const { user } = useIdentityContext(); - const { hasMoreMessagesToLoad } = useChat(); + const hasMoreMessagesToLoad = props.hasMoreMessagesToLoad; const defaultMessage = { text: "", image: null, @@ -31,24 +31,28 @@ export default function ChatModal(props) { (i) => i.isProfilePicture )?.url; - const onSendingMessage = (e) => { + const onSendingMessage = async (e) => { e.preventDefault(); if (!currentMessage.text && !currentMessage.image) { return; } - sendMessage({ - roomId: currentUser.roomId, - userId: user.id, - text: currentMessage.text, - }); + if (currentMessage.text) { + sendMessage({ + roomId: currentUser.roomId, + text: currentMessage.text, + profilePicture: user.profilePicture, + }); + } if (currentMessage.image) { + // Upload over REST first; only the URL travels over the websocket. + const imageUrl = await chatService.uploadChatImage(currentMessage.image); sendMessage({ roomId: currentUser.roomId, - userId: user.id, - image: currentMessage.image, + imageUrl, + profilePicture: user.profilePicture, }); } @@ -69,7 +73,6 @@ export default function ChatModal(props) { const onImagePaste = (e) => { const clipboardItems = e.clipboardData.items; const items = [].slice.call(clipboardItems).filter(function (item) { - // Filter the image items only return /^image\//.test(item.type); }); @@ -91,17 +94,11 @@ export default function ChatModal(props) { "utf-8" ); - getBase64(file).then((res) => { - let text = res; - let skipCharactersCount = text.indexOf(",") + 1; - text = text.slice(skipCharactersCount); - - setCurrentMessage((prevStae) => { - return { - ...prevStae, - image: text, - }; - }); + setCurrentMessage((prevState) => { + return { + ...prevState, + image: file, + }; }); }; @@ -217,12 +214,3 @@ export default function ChatModal(props) { </Modal> ); } - -function getBase64(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => resolve(reader.result); - reader.onerror = (error) => reject(error); - }); -} diff --git a/Web-FrontEnd/src/hooks/useChat.js b/Web-FrontEnd/src/hooks/useChat.js index 74aba21..93e11a6 100644 --- a/Web-FrontEnd/src/hooks/useChat.js +++ b/Web-FrontEnd/src/hooks/useChat.js @@ -17,7 +17,12 @@ export const useChat = () => { useEffect(() => { if (userConnection) { const newConnection = new HubConnectionBuilder() - .withUrl(`${globalConstants.BASE_URL}/chat`) + .withUrl(`${globalConstants.BASE_URL}/chat`, { + // The hub is [Authorize]d now; sender identity comes from this + // token's claims, not from the payload. + accessTokenFactory: () => + JSON.parse(localStorage.getItem("auth") || "{}")?.token, + }) .configureLogging(LogLevel.Information) .withAutomaticReconnect() .build(); diff --git a/Web-FrontEnd/src/services/api.js b/Web-FrontEnd/src/services/api.js index 3c69f75..2f832b6 100644 --- a/Web-FrontEnd/src/services/api.js +++ b/Web-FrontEnd/src/services/api.js @@ -46,7 +46,8 @@ instance.interceptors.response.use( const response = await instance.post("/identity/refreshToken"); const { token } = response.data; - localStorage.setItem("auth", JSON.stringify(token)); + const auth = JSON.parse(localStorage.getItem("auth")) || {}; + localStorage.setItem("auth", JSON.stringify({ ...auth, token })); originalConfig.headers["Authorization"] = `Bearer ${token}`; diff --git a/Web-FrontEnd/src/services/chatService.js b/Web-FrontEnd/src/services/chatService.js index dd35ec3..f5a1e48 100644 --- a/Web-FrontEnd/src/services/chatService.js +++ b/Web-FrontEnd/src/services/chatService.js @@ -23,3 +23,18 @@ export async function getChatrooms() { console.log(error); } } + +// Chat images are uploaded over REST first; the websocket message then +// carries only the returned URL (no more base64 over the socket). +export async function uploadChatImage(file) { + const formData = new FormData(); + formData.append("file", file); + + const response = await instance.post(`${baseUrl}/images`, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + + return response.data.url; +} diff --git a/Web-FrontEnd/src/services/dashboardService.js b/Web-FrontEnd/src/services/dashboardService.js index 9c76ef1..1e52b27 100644 --- a/Web-FrontEnd/src/services/dashboardService.js +++ b/Web-FrontEnd/src/services/dashboardService.js @@ -1,11 +1,12 @@ import { globalConstants } from "../utils/constants"; import { instance } from "./api"; -const baseUrl = globalConstants.API_URL + "admin/dashboard"; +// Stats are aggregated by the gateway; users live in Profile, moderation in Identity. +const adminBaseUrl = globalConstants.API_URL + "admin"; export async function getStatistics() { try { - const response = await instance.get(baseUrl); + const response = await instance.get(`${adminBaseUrl}/dashboard`); return response.data; } catch (error) { @@ -15,7 +16,7 @@ export async function getStatistics() { export async function getUsers(request) { try { - const response = await instance.post(`${baseUrl}/users`, request); + const response = await instance.post(`${adminBaseUrl}/users`, request); return response.data; } catch (error) { @@ -28,7 +29,7 @@ export async function moderateUser(request) { if (request.bannedUntil && new Date(request.bannedUntil) < new Date()) { throw new Error("Can't ban user in the past"); } - await instance.post(`${baseUrl}/moderate`, request); + await instance.post(`${adminBaseUrl}/moderate`, request); } catch (error) { throw new Error(error?.response?.data || error?.message); } diff --git a/Web-FrontEnd/src/services/identityService.js b/Web-FrontEnd/src/services/identityService.js index a8167e4..423a9ee 100644 --- a/Web-FrontEnd/src/services/identityService.js +++ b/Web-FrontEnd/src/services/identityService.js @@ -4,6 +4,8 @@ import { getErrorMessage } from "../utils/errors" import * as date from "../utils/date"; const baseUrl = globalConstants.API_URL + "identity"; +// Account/profile data is owned by the Profile service now. +const profileBaseUrl = globalConstants.API_URL + "profile"; export async function login(user) { try { @@ -120,7 +122,7 @@ export async function logout() { export async function getAccount(id) { try { - const response = await instance.get(`${baseUrl}/account/${id}`); + const response = await instance.get(`${profileBaseUrl}/account/${id}`); return response.data; } catch (error) { @@ -192,7 +194,7 @@ export async function editAccount(user) { } const response = await instance.put( - `${baseUrl}/account/${user.id}`, + `${profileBaseUrl}/account/${user.id}`, formData, { headers: { diff --git a/Web-FrontEnd/src/utils/constants.js b/Web-FrontEnd/src/utils/constants.js index 888f949..fdd847a 100644 --- a/Web-FrontEnd/src/utils/constants.js +++ b/Web-FrontEnd/src/utils/constants.js @@ -1,8 +1,7 @@ +// The API gateway keeps the monolith's old port, so these stay unchanged. export const globalConstants = { BASE_URL: "http://localhost:8080", API_URL: "http://localhost:8080/api/", - // BASE_URL: "https://lovenet.azurewebsites.net", - // API_URL: "https://lovenet.azurewebsites.net/api/", }; export const identityConstants = { diff --git a/Web/LOVE.NET.Web.Infrastructure/Attributes/AllowedFileExtensionsAttribute.cs b/Web/LOVE.NET.Web.Infrastructure/Attributes/AllowedFileExtensionsAttribute.cs deleted file mode 100644 index 670f8ac..0000000 --- a/Web/LOVE.NET.Web.Infrastructure/Attributes/AllowedFileExtensionsAttribute.cs +++ /dev/null @@ -1,61 +0,0 @@ -namespace LOVE.NET.Web.Infrastructure.Attributes -{ - using System; - using System.ComponentModel.DataAnnotations; - using System.IO; - using System.Linq; - - using Microsoft.AspNetCore.Http; - - using static LOVE.NET.Common.GlobalConstants.ControllerResponseMessages; - - public class AllowedFileExtensionsAttribute : ValidationAttribute - { - private readonly string[] extensions; - - public AllowedFileExtensionsAttribute(string[] extensions) - { - this.extensions = extensions; - } - - public string GetErrorMessage() => UnsupportedFileType; - - protected override ValidationResult IsValid( - object value, ValidationContext validationContext) - { - Type type = value?.GetType(); - - if (type?.IsArray == true) - { - var files = value as IFormFile[] ?? Enumerable.Empty<IFormFile>(); - - foreach (var file in files) - { - if (file != null) - { - var extension = Path.GetExtension(file.FileName); - if (!this.extensions.Contains(extension.ToLower())) - { - return new ValidationResult(this.GetErrorMessage()); - } - } - } - } - else - { - var file = value as IFormFile; - - if (file != null) - { - var extension = Path.GetExtension(file.FileName); - if (!this.extensions.Contains(extension.ToLower())) - { - return new ValidationResult(this.GetErrorMessage()); - } - } - } - - return ValidationResult.Success; - } - } -} diff --git a/Web/LOVE.NET.Web.Infrastructure/Attributes/MaxFileSizeAttribute.cs b/Web/LOVE.NET.Web.Infrastructure/Attributes/MaxFileSizeAttribute.cs deleted file mode 100644 index 54574ad..0000000 --- a/Web/LOVE.NET.Web.Infrastructure/Attributes/MaxFileSizeAttribute.cs +++ /dev/null @@ -1,53 +0,0 @@ -namespace LOVE.NET.Web.Infrastructure.Attributes -{ - using System; - using System.ComponentModel.DataAnnotations; - using System.Linq; - - using Microsoft.AspNetCore.Http; - - using static LOVE.NET.Common.GlobalConstants.ControllerResponseMessages; - - public class MaxFileSizeAttribute : ValidationAttribute - { - private readonly int maxFileSize; - - public MaxFileSizeAttribute(int maxFileSize) - { - this.maxFileSize = maxFileSize; - } - - public string GetErrorMessage() => MaxFileSizeReached; - - protected override ValidationResult IsValid( - object value, - ValidationContext validationContext) - { - Type type = value?.GetType(); - - if (type?.IsArray == true) - { - var files = value as IFormFile[] ?? Enumerable.Empty<IFormFile>(); - - foreach (var file in files) - { - if (file != null && file.Length > this.maxFileSize) - { - return new ValidationResult(this.GetErrorMessage()); - } - } - } - else - { - var file = value as IFormFile; - - if (file != null && file.Length > this.maxFileSize) - { - return new ValidationResult(this.GetErrorMessage()); - } - } - - return ValidationResult.Success; - } - } -} diff --git a/Web/LOVE.NET.Web.Infrastructure/LOVE.NET.Web.Infrastructure.csproj b/Web/LOVE.NET.Web.Infrastructure/LOVE.NET.Web.Infrastructure.csproj deleted file mode 100644 index 374d754..0000000 --- a/Web/LOVE.NET.Web.Infrastructure/LOVE.NET.Web.Infrastructure.csproj +++ /dev/null @@ -1,26 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk"> - - <PropertyGroup> - <TargetFramework>net8.0</TargetFramework> - <LangVersion>latest</LangVersion> - </PropertyGroup> - - <PropertyGroup> - <CodeAnalysisRuleSet>..\..\Rules.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> - <AdditionalFiles Include="..\..\stylecop.json" /> - </ItemGroup> - - <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="all"> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - </ItemGroup> - - <ItemGroup> - <ProjectReference Include="..\..\LOVE.NET.Common\LOVE.NET.Common.csproj" /> - </ItemGroup> - -</Project> \ No newline at end of file diff --git a/Web/LOVE.NET.Web.ViewModels/Chat/ChatRequestViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Chat/ChatRequestViewModel.cs deleted file mode 100644 index 3eec467..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Chat/ChatRequestViewModel.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Chat -{ - using System.ComponentModel.DataAnnotations; - - public class ChatRequestViewModel - { - [Required] - public string RoomId { get; set; } - - [Required] - public int Page { get; set; } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Chat/ChatViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Chat/ChatViewModel.cs deleted file mode 100644 index 7f1572f..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Chat/ChatViewModel.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Chat -{ - using System.Collections.Generic; - - public class ChatViewModel - { - public IEnumerable<MessageDto> Messages { get; set; } - - public int TotalMessages { get; set; } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Chat/ChatroomViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Chat/ChatroomViewModel.cs deleted file mode 100644 index 4c9228f..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Chat/ChatroomViewModel.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Chat -{ - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Mapping; - - public class ChatroomViewModel : IMapFrom<Chatroom>, IMapTo<Chatroom> - { - public string Id { get; set; } - - public string Title { get; set; } - - public string Url { get; set; } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Chat/MessageData.cs b/Web/LOVE.NET.Web.ViewModels/Chat/MessageData.cs deleted file mode 100644 index 239d414..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Chat/MessageData.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Chat -{ - using Microsoft.AspNetCore.Http; - - public class MessageData - { - public string Text { get; set; } - - public IFormFile? Image { get; set; } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Chat/MessageDto.cs b/Web/LOVE.NET.Web.ViewModels/Chat/MessageDto.cs deleted file mode 100644 index ae5cbe9..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Chat/MessageDto.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Chat -{ - using System; - using System.Linq; - - using AutoMapper; - - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Mapping; - - public class MessageDto : IMapFrom<Message>, IMapTo<Message>, IHaveCustomMappings - { - public string RoomId { get; set; } - - public string UserId { get; set; } - - public string Text { get; set; } - - /// <summary> - /// Profile picture of the user who sent the message. - /// </summary> - public string ProfilePicture { get; set; } - - /// <summary> - /// Image as chat message. - /// </summary> - public string? ImageUrl { get; set; } - - public string Image { get; set; } - - public DateTime CreatedOn { get; set; } - - public void CreateMappings(IProfileExpression configuration) - { - configuration.CreateMap<Message, MessageDto>() - .ForMember(m => m.ProfilePicture, opt => opt - .MapFrom(x => x.User.Images - .OrderByDescending(i => i.IsProfilePicture) - .FirstOrDefault() - .Url)); - } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Chat/UserInRoomModel.cs b/Web/LOVE.NET.Web.ViewModels/Chat/UserInRoomModel.cs deleted file mode 100644 index ade91f6..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Chat/UserInRoomModel.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Chat -{ - public class UserInRoomModel - { - public string Id { get; set; } - - public string ProfilePictureUrl { get; set; } - - public string UserName { get; set; } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Countries/CityViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Countries/CityViewModel.cs deleted file mode 100644 index b5d096c..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Countries/CityViewModel.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Countries -{ - using AutoMapper; - - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Mapping; - - public class CityViewModel : IMapFrom<City>, IHaveCustomMappings - { - public int CityId { get; set; } - - public string CityName { get; set; } - - public void CreateMappings(IProfileExpression configuration) - { - configuration.CreateMap<City, CityViewModel>() - .ForMember(m => m.CityId, opt => opt.MapFrom(x => x.Id)) - .ForMember(m => m.CityName, opt => opt.MapFrom(x => x.NameAscii)); - } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Countries/CountryCitiesViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Countries/CountryCitiesViewModel.cs deleted file mode 100644 index 7b2a06c..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Countries/CountryCitiesViewModel.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Countries -{ - using System.Collections.Generic; - - using AutoMapper; - - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Mapping; - - public class CountryCitiesViewModel : IMapFrom<Country>, IHaveCustomMappings - { - public int CountryId { get; set; } - - public string CountryName { get; set; } - - public IEnumerable<CityViewModel> Cities { get; set; } - - public void CreateMappings(IProfileExpression configuration) - { - configuration.CreateMap<Country, CountryCitiesViewModel>() - .ForMember(m => m.CountryId, opt => opt.MapFrom(x => x.Id)) - .ForMember(m => m.CountryName, opt => opt.MapFrom(x => x.Name)); - } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Countries/CountryViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Countries/CountryViewModel.cs deleted file mode 100644 index 44f15ba..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Countries/CountryViewModel.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Countries -{ - using AutoMapper; - - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Mapping; - - public class CountryViewModel : IMapFrom<Country>, IHaveCustomMappings - { - public int CountryId { get; set; } - - public string CountryName { get; set; } - - public void CreateMappings(IProfileExpression configuration) - { - configuration.CreateMap<Country, CountryViewModel>() - .ForMember(m => m.CountryId, opt => opt.MapFrom(x => x.Id)) - .ForMember(m => m.CountryName, opt => opt.MapFrom(x => x.Name)); - } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Dashboard/DashboardUserRequestViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Dashboard/DashboardUserRequestViewModel.cs deleted file mode 100644 index 993c6e1..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Dashboard/DashboardUserRequestViewModel.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Dashboard -{ - public class DashboardUserRequestViewModel - { - public bool ShowBanned { get; set; } - - public string Search { get; set; } - - public int Page { get; set; } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Dashboard/DashboardUserViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Dashboard/DashboardUserViewModel.cs deleted file mode 100644 index d9e8596..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Dashboard/DashboardUserViewModel.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Dashboard -{ - using System.Collections.Generic; - - using LOVE.NET.Web.ViewModels.Identity; - - public class DashboardUserViewModel - { - public IEnumerable<UserDetailsViewModel> Users { get; set; } - - public int TotalUsers { get; set; } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Dashboard/ModerateUserViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Dashboard/ModerateUserViewModel.cs deleted file mode 100644 index 88245cd..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Dashboard/ModerateUserViewModel.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Dashboard -{ - using System; - - public class ModerateUserViewModel - { - public string UserId { get; set; } - - public DateTime? BannedUntil { get; set; } - } -} \ No newline at end of file diff --git a/Web/LOVE.NET.Web.ViewModels/Dashboard/StatisticsViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Dashboard/StatisticsViewModel.cs deleted file mode 100644 index fbb8ad9..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Dashboard/StatisticsViewModel.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Dashboard -{ - public class StatisticsViewModel - { - public int UsersCount { get; set; } - - public int BannedUsersCount { get; set; } - - public int MatchesCount { get; set; } - - public int LikedUsersCount { get; set; } - - public int NotSwipedUsersCount { get; set; } - - public int ImagesCount { get; set; } - - public int MessagesCount { get; set; } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Dating/MatchesRequestViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Dating/MatchesRequestViewModel.cs deleted file mode 100644 index a421fb6..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Dating/MatchesRequestViewModel.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Dating -{ - using System.ComponentModel.DataAnnotations; - - public class MatchesRequestViewModel - { - [Required] - public string UserId { get; set; } - - public string Search { get; set; } - - [Required] - public int Page { get; set; } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Dating/MatchesViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Dating/MatchesViewModel.cs deleted file mode 100644 index 6e84ca4..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Dating/MatchesViewModel.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Dating -{ - using System.Collections.Generic; - - using LOVE.NET.Web.ViewModels.Identity; - - public class MatchesViewModel - { - public IEnumerable<UserMatchViewModel> Matches { get; set; } - - public int TotalMatches { get; set; } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Genders/GenderViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Genders/GenderViewModel.cs deleted file mode 100644 index 874dc2f..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Genders/GenderViewModel.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Genders -{ - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Mapping; - - public class GenderViewModel : IMapFrom<Gender> - { - public int Id { get; set; } - - public string Name { get; set; } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Identity/BaseCredentialsModel.cs b/Web/LOVE.NET.Web.ViewModels/Identity/BaseCredentialsModel.cs deleted file mode 100644 index 6e20bc2..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Identity/BaseCredentialsModel.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Identity -{ - using System.ComponentModel.DataAnnotations; - - public class BaseCredentialsModel - { - - [Required] - [StringLength(100)] - [EmailAddress] - public string Email { get; set; } - - [Required] - [MinLength(5)] - public string Password { get; set; } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Identity/EditUserViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Identity/EditUserViewModel.cs deleted file mode 100644 index ed277c6..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Identity/EditUserViewModel.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Identity -{ - using System.Collections.Generic; - - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Mapping; - - public class EditUserViewModel : RegisterViewModel, IMapTo<ApplicationUser> - { - public string Id { get; set; } - - public IEnumerable<string> Images { get; set; } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Identity/LoginResponseModel.cs b/Web/LOVE.NET.Web.ViewModels/Identity/LoginResponseModel.cs deleted file mode 100644 index c7eaa29..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Identity/LoginResponseModel.cs +++ /dev/null @@ -1,50 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Identity -{ - using System; - using System.Linq; - using System.Text.Json.Serialization; - - using AutoMapper; - - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Mapping; - - public class LoginResponseModel : IMapFrom<ApplicationUser>, IHaveCustomMappings - { - public string Id { get; set; } - - public string Token { get; set; } - - public string Email { get; set; } - - public string UserName { get; set; } - - public bool IsAdmin { get; set; } - - public string Bio { get; set; } - - public DateTime Birthdate { get; set; } - - public string CountryName { get; set; } - - public string CityName { get; set; } - - public double Latitude { get; set; } - - public double Longitude { get; set; } - - public string ProfilePicture { get; set; } - - [JsonIgnore] - public string RefreshToken { get; set; } - - - public void CreateMappings(IProfileExpression configuration) - { - configuration.CreateMap<ApplicationUser, LoginResponseModel>() - .ForMember(m => m.Latitude, opt => opt.MapFrom(x => x.City.Latitude)) - .ForMember(m => m.Longitude, opt => opt.MapFrom(x => x.City.Longitude)) - .ForMember(m => m.ProfilePicture, opt => opt.MapFrom(x => x.Images.FirstOrDefault(i => i.IsProfilePicture).Url)); - } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Identity/LoginViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Identity/LoginViewModel.cs deleted file mode 100644 index 758049c..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Identity/LoginViewModel.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Identity -{ - public class LoginViewModel : BaseCredentialsModel - { - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Identity/MatchViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Identity/MatchViewModel.cs deleted file mode 100644 index 29ac06e..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Identity/MatchViewModel.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Identity -{ - public class MatchViewModel - { - public bool IsMatch { get; set; } - - public UserMatchViewModel User { get; set; } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Identity/RegisterViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Identity/RegisterViewModel.cs deleted file mode 100644 index 6313ad1..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Identity/RegisterViewModel.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Identity -{ - using System; - using System.ComponentModel.DataAnnotations; - - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Mapping; - using LOVE.NET.Web.Infrastructure.Attributes; - - using Microsoft.AspNetCore.Http; - - using static LOVE.NET.Common.GlobalConstants; - - public class RegisterViewModel : BaseCredentialsModel, IMapTo<ApplicationUser> - { - [Required] - [StringLength(50, MinimumLength = 5)] - public string ConfirmPassword { get; set; } - - [Required] - [StringLength(100)] - public string UserName { get; set; } - - [Required] - [MaxLength(255)] - public string Bio { get; set; } - - public DateTime Birthdate { get; set; } - - public int CountryId { get; set; } - - public int GenderId { get; set; } - - public int CityId { get; set; } - - [MaxFileSize(MaxFileSizeInBytes)] - [AllowedFileExtensions(new string[] { ".jpg", ".png" })] - public IFormFile Image { get; set; } - - [MaxFileSize(MaxFileSizeInBytes)] - [AllowedFileExtensions(new string[] { ".jpg", ".png" })] - public IFormFile[] NewImages { get; set; } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Identity/ResetPasswordViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Identity/ResetPasswordViewModel.cs deleted file mode 100644 index 475afd2..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Identity/ResetPasswordViewModel.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Identity -{ - using System.ComponentModel.DataAnnotations; - - public class ResetPasswordViewModel - { - [Required] - public string Token { get; set; } - - [Required] - public string Email { get; set; } - - [Required] - [MinLength(5)] - public string Password { get; set; } - - [Required] - [MinLength(5)] - public string ConfirmPassword { get; set; } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Identity/UserDetailsViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Identity/UserDetailsViewModel.cs deleted file mode 100644 index 54f0fb8..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Identity/UserDetailsViewModel.cs +++ /dev/null @@ -1,61 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Identity -{ - using System; - using System.Collections.Generic; - using System.Linq; - - using AutoMapper; - - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Mapping; - using LOVE.NET.Web.ViewModels.Images; - - public class UserDetailsViewModel : IMapFrom<ApplicationUser>, IHaveCustomMappings - { - public string Id { get; set; } - - public string Email { get; set; } - - public string UserName { get; set; } - - public string Bio { get; set; } - - public DateTime Birthdate { get; set; } - - public ICollection<UserMatchViewModel> Matches { get; set; } - - public ICollection<ImageViewModel> Images { get; set; } - - public int GenderId { get; set; } - - public string GenderName { get; set; } - - public int CityId { get; set; } - - public string CityName { get; set; } - - public int CountryId { get; set; } - - public string CountryName { get; set; } - - public double Latitude { get; set; } - - public double Longitude { get; set; } - - public bool IsBanned { get; set; } - - public void CreateMappings(IProfileExpression configuration) - { - // Getting intersection of both users likes, and select the liked user - configuration.CreateMap<ApplicationUser, UserDetailsViewModel>() - .ForMember(m => m.Latitude, opt => opt.MapFrom(x => x.City.Latitude)) - .ForMember(m => m.Longitude, opt => opt.MapFrom(x => x.City.Longitude)) - .ForMember(m => m.IsBanned, opt => opt.MapFrom(x => x.LockoutEnd != null)) - .ForMember(m => m.Matches, opt => opt.MapFrom(x => - x.LikesSent - .Where(l => - x.LikesReceived.Select(lr => lr.UserId).Intersect(x.LikesSent.Select(ls => ls.LikedUserId)).Contains(l.LikedUserId)) - .Select(x => x.LikedUser))); - } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Identity/UserMatchViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Identity/UserMatchViewModel.cs deleted file mode 100644 index 51c427c..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Identity/UserMatchViewModel.cs +++ /dev/null @@ -1,52 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Identity -{ - using System; - using System.Collections.Generic; - - using AutoMapper; - - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Mapping; - using LOVE.NET.Web.Common.Helpers; - using LOVE.NET.Web.ViewModels.Images; - - public class UserMatchViewModel : IMapFrom<ApplicationUser>, IHaveCustomMappings - { - public string Id { get; set; } - - public string UserName { get; set; } - - public string Bio { get; set; } - - public DateTime Birthdate { get; set; } - - public int Age => DateHelper.AgeCalculator(this.Birthdate); - - public ICollection<ImageViewModel> Images { get; set; } - - public int GenderId { get; set; } - - public string GenderName { get; set; } - - public int CityId { get; set; } - - public string CityName { get; set; } - - public int CountryId { get; set; } - - public string CountryName { get; set; } - - public double Latitude { get; set; } - - public double Longitude { get; set; } - - public string RoomId { get; set; } - - public void CreateMappings(IProfileExpression configuration) - { - configuration.CreateMap<ApplicationUser, UserMatchViewModel>() - .ForMember(m => m.Latitude, opt => opt.MapFrom(x => x.City.Latitude)) - .ForMember(m => m.Longitude, opt => opt.MapFrom(x => x.City.Longitude)); - } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/Images/ImageViewModel.cs b/Web/LOVE.NET.Web.ViewModels/Images/ImageViewModel.cs deleted file mode 100644 index a2370c8..0000000 --- a/Web/LOVE.NET.Web.ViewModels/Images/ImageViewModel.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace LOVE.NET.Web.ViewModels.Images -{ - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Mapping; - - public class ImageViewModel : IMapFrom<Image>, IMapTo<Image> - { - public int Id { get; set; } - - public string Url { get; set; } - - public bool IsProfilePicture { get; set; } - - public bool IsDeleted { get; set; } - } -} diff --git a/Web/LOVE.NET.Web.ViewModels/LOVE.NET.Web.ViewModels.csproj b/Web/LOVE.NET.Web.ViewModels/LOVE.NET.Web.ViewModels.csproj deleted file mode 100644 index 5b7f7f9..0000000 --- a/Web/LOVE.NET.Web.ViewModels/LOVE.NET.Web.ViewModels.csproj +++ /dev/null @@ -1,28 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk"> - - <PropertyGroup> - <TargetFramework>net8.0</TargetFramework> - <LangVersion>latest</LangVersion> - </PropertyGroup> - - <PropertyGroup> - <CodeAnalysisRuleSet>..\..\Rules.ruleset</CodeAnalysisRuleSet> - </PropertyGroup> - <ItemGroup> - <AdditionalFiles Include="..\..\stylecop.json" /> - </ItemGroup> - - <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="all"> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - </ItemGroup> - - <ItemGroup> - <ProjectReference Include="..\..\Data\LOVE.NET.Data.Models\LOVE.NET.Data.Models.csproj" /> - <ProjectReference Include="..\..\Services\LOVE.NET.Services.Mapping\LOVE.NET.Services.Mapping.csproj" /> - <ProjectReference Include="..\LOVE.NET.Web.Infrastructure\LOVE.NET.Web.Infrastructure.csproj" /> - </ItemGroup> - -</Project> \ No newline at end of file diff --git a/Web/LOVE.NET.Web/Controllers/ChatController.cs b/Web/LOVE.NET.Web/Controllers/ChatController.cs deleted file mode 100644 index 3bfd7e3..0000000 --- a/Web/LOVE.NET.Web/Controllers/ChatController.cs +++ /dev/null @@ -1,60 +0,0 @@ -namespace LOVE.NET.Web.Controllers -{ - using System.Collections.Generic; - using System.Linq; - using System.Security.Claims; - - using LOVE.NET.Services.Chats; - using LOVE.NET.Web.ViewModels.Chat; - - using Microsoft.AspNetCore.Authorization; - using Microsoft.AspNetCore.Http; - using Microsoft.AspNetCore.Mvc; - - using static LOVE.NET.Common.GlobalConstants.ControllerRoutesConstants; - - [Route(ChatControllerName)] - [ApiController] - public class ChatController : ControllerBase - { - private readonly IChatService chatService; - - public ChatController(IChatService chatService) - { - this.chatService = chatService; - } - - [Authorize] - [HttpPost] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(ChatViewModel))] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public IActionResult GetChatMessages([FromBody] ChatRequestViewModel request) - { - var loggedUserId = this.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier).Value; - - var chatRooms = this.chatService.GetChatrooms(); - - if (request?.RoomId.Contains(loggedUserId) == false && - chatRooms.Any(x => x.Id == request.RoomId) == false) - { - return this.Forbid(); - } - - var chat = this.chatService.GetChat(request); - - return this.Ok(chat); - } - - [Authorize] - [HttpGet] - [Route(ChatRooms)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<ChatroomViewModel>))] - public IActionResult GetChatRooms() - { - var chatrooms = this.chatService.GetChatrooms(); - - return this.Ok(chatrooms); - } - } -} diff --git a/Web/LOVE.NET.Web/Controllers/CountriesController.cs b/Web/LOVE.NET.Web/Controllers/CountriesController.cs deleted file mode 100644 index b8cc415..0000000 --- a/Web/LOVE.NET.Web/Controllers/CountriesController.cs +++ /dev/null @@ -1,49 +0,0 @@ -namespace LOVE.NET.Web.Controllers -{ - using System.Collections.Generic; - - using LOVE.NET.Services.Countries; - using LOVE.NET.Web.ViewModels.Countries; - - using Microsoft.AspNetCore.Http; - using Microsoft.AspNetCore.Mvc; - - using static LOVE.NET.Common.GlobalConstants.ControllerRoutesConstants; - - [Route(CountriesControllerName)] - [ApiController] - public class CountriesController : ControllerBase - { - private readonly ICountriesService countriesService; - - public CountriesController(ICountriesService countriesService) - { - this.countriesService = countriesService; - } - - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<CountryViewModel>))] - public IActionResult GetAll() - { - var countryCities = this.countriesService.GetAll(); - - return this.Ok(countryCities); - } - - [HttpGet] - [Route(ById)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(CountryCitiesViewModel))] - public IActionResult GetAllWithCities(int id) - { - var countryCities = this.countriesService.Get(id); - - if (countryCities == null) - { - return this.NotFound(); - } - - return this.Ok(countryCities); - } - } -} diff --git a/Web/LOVE.NET.Web/Controllers/DashboardController.cs b/Web/LOVE.NET.Web/Controllers/DashboardController.cs deleted file mode 100644 index 73295e6..0000000 --- a/Web/LOVE.NET.Web/Controllers/DashboardController.cs +++ /dev/null @@ -1,85 +0,0 @@ -namespace LOVE.NET.Web.Controllers -{ - using System; - using System.Security.Claims; - using System.Threading.Tasks; - - using LOVE.NET.Common; - using LOVE.NET.Services.Dashboard; - using LOVE.NET.Web.ViewModels.Dashboard; - - using Microsoft.AspNetCore.Authorization; - using Microsoft.AspNetCore.Http; - using Microsoft.AspNetCore.Mvc; - - using static LOVE.NET.Common.GlobalConstants; - using static LOVE.NET.Common.GlobalConstants.ControllerResponseMessages; - using static LOVE.NET.Common.GlobalConstants.ControllerRoutesConstants; - - [Authorize(Roles = AdministratorRoleName)] - [Route(DashboardControllerName)] - [ApiController] - public class DashboardController : ControllerBase - { - private readonly IDashboardService dashboardService; - - public DashboardController(IDashboardService dashboardService) - { - this.dashboardService = dashboardService; - } - - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(StatisticsViewModel))] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public IActionResult GetStatistics() - { - var result = this.dashboardService.GetStatistics(); - - return this.Ok(result); - } - - [HttpPost] - [Route(UsersRoute)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(DashboardUserViewModel))] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task<IActionResult> GetUsers([FromBody] DashboardUserRequestViewModel request) - { - var loggedUserId = this.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier).Value; - - var result = await this.dashboardService.GetUsersAsync(request, loggedUserId); - - return this.Ok(result); - } - - [HttpPost] - [Route(ModerateRoute)] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task<IActionResult> ModerateUserAsync([FromBody] ModerateUserViewModel request) - { - var loggedUserId = this.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier).Value; - - if (loggedUserId == request.UserId) - { - return this.BadRequest(CantBanYourself); - } - - if (request.BannedUntil.HasValue && request.BannedUntil.Value.Date < DateTime.UtcNow.Date) - { - return this.BadRequest(CantBanUserInThePast); - } - - var serviceResult = await this.dashboardService.ModerateAsync(request); - - if (serviceResult.Errors != null) - { - return this.BadRequest((Result)string.Join('\n', serviceResult.Errors)); - } - - return this.NoContent(); - } - } -} diff --git a/Web/LOVE.NET.Web/Controllers/DatingController.cs b/Web/LOVE.NET.Web/Controllers/DatingController.cs deleted file mode 100644 index 0fe0332..0000000 --- a/Web/LOVE.NET.Web/Controllers/DatingController.cs +++ /dev/null @@ -1,111 +0,0 @@ -namespace LOVE.NET.Web.Controllers -{ - using System; - using System.Security.Claims; - using System.Threading.Tasks; - - using LOVE.NET.Common; - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Dating; - using LOVE.NET.Web.ViewModels.Dating; - using LOVE.NET.Web.ViewModels.Identity; - - using Microsoft.AspNetCore.Authorization; - using Microsoft.AspNetCore.Http; - using Microsoft.AspNetCore.Identity; - using Microsoft.AspNetCore.Mvc; - - using static LOVE.NET.Common.GlobalConstants.ControllerRoutesConstants; - - [Route(DatingControllerName)] - [ApiController] - public class DatingController : ControllerBase - { - private readonly UserManager<ApplicationUser> userManager; - private readonly IDatingService datingService; - - public DatingController( - UserManager<ApplicationUser> userManager, - IDatingService datingService) - { - this.userManager = userManager; - this.datingService = datingService; - } - - [HttpGet] - [Authorize] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserMatchViewModel[]))] - public async Task<IActionResult> GetNotSwipedUsersAsync() - { - var loggedUserId = this.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier).Value; - - var user = await this.userManager.FindByIdAsync(loggedUserId); - - if (user == null) - { - return this.Unauthorized(); - } - - var notSwipedUsers = this.datingService.GetNotSwipedUsers(loggedUserId); - - return this.Ok(notSwipedUsers); - } - - [HttpPost] - [Authorize] - [Route(MatchesRoute)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MatchesViewModel))] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public IActionResult GetMatches([FromBody] MatchesRequestViewModel request) - { - if (string.IsNullOrEmpty(request.UserId) || !Guid.TryParse(request.UserId, out _)) - { - return this.BadRequest(); - } - - var loggedUserId = this.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier).Value; - - if (loggedUserId != request.UserId) - { - return this.Forbid(); - } - - var result = this.datingService.GetMatches(request); - - return this.Ok(result); - } - - [HttpPost] - [Authorize] - [Route(LikeRoute)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MatchViewModel))] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(Result))] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public async Task<IActionResult> LikeAsync(string id) - { - var loggedUserId = this.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier).Value; - - var serviceResult = await this.datingService.LikeAsync(loggedUserId, id); - - if (serviceResult.Errors != null) - { - return this.BadRequest((Result)string.Join('\n', serviceResult.Errors)); - } - - var result = new MatchViewModel() - { - IsMatch = serviceResult.Succeeded, - }; - - if (result.IsMatch) - { - var match = this.datingService.GetCurrentMatch(id); - result.User = match; - } - - return this.Ok(result); - } - } -} diff --git a/Web/LOVE.NET.Web/Controllers/EmailController.cs b/Web/LOVE.NET.Web/Controllers/EmailController.cs deleted file mode 100644 index 1cdb49a..0000000 --- a/Web/LOVE.NET.Web/Controllers/EmailController.cs +++ /dev/null @@ -1,90 +0,0 @@ -namespace LOVE.NET.Web.Controllers -{ - using System.Threading.Tasks; - - using LOVE.NET.Services.Email; - using LOVE.NET.Web.ViewModels.Identity; - - using Microsoft.AspNetCore.Authorization; - using Microsoft.AspNetCore.Mvc; - - using static LOVE.NET.Common.GlobalConstants.ControllerRoutesConstants; - using static LOVE.NET.Common.GlobalConstants.EmailMessagesConstants; - - [Route(EmailControllerName)] - [ApiController] - public class EmailController : ControllerBase - { - private readonly IEmailService emailService; - - public EmailController(IEmailService emailService) - { - this.emailService = emailService; - } - - [HttpGet] - [AllowAnonymous] - [Route(SendResetPasswordLinkLinkRoute)] - public async Task<IActionResult> SendResetPasswordLinkAsync(string email) - { - var origin = this.Request.Headers[HeaderOrigin]; - - var result = await this.emailService.SendResetPasswordLinkAsync(email, origin); - - if (result.Failure) - { - return this.BadRequest(result.Errors); - } - - return this.Ok(CheckYourEmail); - } - - [HttpPost] - [Route(ResetPasswordRoute)] - public async Task<IActionResult> ResetPasswordAsync(ResetPasswordViewModel model) - { - var result = await this.emailService.ResetPasswordAsync(model); - - if (result.Failure) - { - return this.BadRequest(result.Errors); - } - - return this.Ok(PasswordResetSuccessful); - } - - [HttpGet] - [AllowAnonymous] - [Route(VerifyEmailRoute)] - public async Task<IActionResult> VerifyEmail( - [FromQuery] string email, - [FromQuery] string token) - { - var result = await this.emailService.VerifyEmailAsync(email, token); - - if (result.Failure) - { - return this.BadRequest(result.Errors); - } - - return this.Ok(EmailConfirmed); - } - - [HttpGet] - [AllowAnonymous] - [Route(ResendEmailConfirmationLinkRoute)] - public async Task<IActionResult> ResendEmailConfirmationLink(string email) - { - var origin = this.Request.Headers[HeaderOrigin]; - - var result = await this.emailService.ResendEmailConfirmationLinkAsync(email, origin); - - if (result.Failure) - { - return this.BadRequest(result.Errors); - } - - return this.Ok(CheckYourEmail); - } - } -} diff --git a/Web/LOVE.NET.Web/Controllers/GendersController.cs b/Web/LOVE.NET.Web/Controllers/GendersController.cs deleted file mode 100644 index 3ba514b..0000000 --- a/Web/LOVE.NET.Web/Controllers/GendersController.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace LOVE.NET.Web.Controllers -{ - using System.Collections.Generic; - - using LOVE.NET.Services.Genders; - using LOVE.NET.Web.ViewModels.Genders; - - using Microsoft.AspNetCore.Http; - using Microsoft.AspNetCore.Mvc; - - using static LOVE.NET.Common.GlobalConstants.ControllerRoutesConstants; - - [Route(GendersControllerName)] - [ApiController] - public class GendersController : ControllerBase - { - private readonly IGendersService gendersService; - - public GendersController(IGendersService gendersService) - { - this.gendersService = gendersService; - } - - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<GenderViewModel>))] - public IActionResult GetAll() - { - var genders = this.gendersService.GetAll(); - - return this.Ok(genders); - } - } -} diff --git a/Web/LOVE.NET.Web/Controllers/IdentityController.cs b/Web/LOVE.NET.Web/Controllers/IdentityController.cs deleted file mode 100644 index a9e6d65..0000000 --- a/Web/LOVE.NET.Web/Controllers/IdentityController.cs +++ /dev/null @@ -1,321 +0,0 @@ -namespace LOVE.NET.Web.Controllers -{ - using System; - using System.Linq; - using System.Security.Claims; - using System.Threading.Tasks; - - using LOVE.NET.Common; - using LOVE.NET.Data.Models; - using LOVE.NET.Services.Countries; - using LOVE.NET.Services.Email; - using LOVE.NET.Services.Identity; - using LOVE.NET.Web.Common.Helpers; - using LOVE.NET.Web.ViewModels.Identity; - - using Microsoft.AspNetCore.Authorization; - using Microsoft.AspNetCore.Http; - using Microsoft.AspNetCore.Identity; - using Microsoft.AspNetCore.Mvc; - using Microsoft.EntityFrameworkCore; - - using static LOVE.NET.Common.GlobalConstants; - using static LOVE.NET.Common.GlobalConstants.ControllerResponseMessages; - using static LOVE.NET.Common.GlobalConstants.ControllerRoutesConstants; - - [Route(IdentityControllerName)] - [ApiController] - public class IdentityController : ControllerBase - { - private readonly UserManager<ApplicationUser> userManager; - private readonly IIdentityService userService; - private readonly IEmailService emailService; - private readonly ICountriesService countriesService; - - public IdentityController( - IIdentityService userService, - UserManager<ApplicationUser> userManager, - IEmailService emailService, - ICountriesService countriesService) - { - this.userManager = userManager; - this.userService = userService; - this.emailService = emailService; - this.countriesService = countriesService; - } - - [HttpPost] - [AllowAnonymous] - [Route(RegisterRoute)] - [ProducesResponseType(StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(Result))] - public async Task<IActionResult> RegisterAsync([FromForm] RegisterViewModel model) - { - await this.ValidateRegisterModelAsync(model); - - if (!this.ModelState.IsValid) - { - return this.BadRequest(this.ModelState); - } - - var result = await this.userService.RegisterAsync(model); - - if (!result.Succeeded) - { - var errorMessages = string.Join("\n", result.Errors.Select(x => x.Description)); - return this.BadRequest((Result)errorMessages); - } - - await this.EmailConfirmation(model.Email); - - var user = await this.userManager.FindByEmailAsync(model.Email); - - await this.SetRefreshToken(user); - - return this.StatusCode(201); - } - - [HttpPost] - [AllowAnonymous] - [Route(LoginRoute)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(LoginResponseModel))] - [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(Result))] - public async Task<IActionResult> LoginAsync([FromBody] LoginViewModel model) - { - if (!this.ModelState.IsValid) - { - return this.BadRequest((Result)FillAllTheInformation); - } - - LoginResponseModel token; - - try - { - token = await this.userService.LoginAsync(model); - } - catch (Exception e) - { - return this.BadRequest((Result)e.Message); - } - - var user = await this.userManager.FindByEmailAsync(model.Email); - - await this.SetRefreshToken(user); - - return this.Ok(token); - } - - [HttpPost] - [Route(LogoutRoute)] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<IActionResult> Logout() - { - var refreshToken = this.Request.Cookies[RefreshTokenValue]; - this.Response.Cookies.Delete(RefreshTokenValue); - - var user = await this.userManager.Users - .Include(u => u.RefreshTokens) - .FirstOrDefaultAsync(x => x.RefreshTokens.Any(t => t.Token == refreshToken)); - - var token = user?.RefreshTokens.FirstOrDefault(t => t.Token == refreshToken); - - if (token != null) - { - token.Revoked = DateTime.UtcNow; - await this.userManager.UpdateAsync(user); - } - - return this.Ok(); - } - - [HttpPost(RefreshTokenRoute)] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(LoginResponseModel))] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public async Task<IActionResult> RefreshToken() - { - var refreshToken = this.Request.Cookies[RefreshTokenValue]; - - var user = await this.userManager.Users - .Include(u => u.RefreshTokens) - .FirstOrDefaultAsync(x => x.RefreshTokens.Any(t => t.Token == refreshToken)); - - if (user == null) - { - return this.Unauthorized(); - } - - var oldToken = user.RefreshTokens.FirstOrDefault(t => t.Token == refreshToken); - - if (oldToken != null && !oldToken.IsActive) - { - return this.Unauthorized(); - } - - var userRoles = await this.userManager.GetRolesAsync(user); - var newToken = await this.userService.GenerateJwtToken(user); - - await this.SetRefreshToken(user, oldToken); - - var response = new LoginResponseModel() - { - Id = user.Id, - Token = newToken, - Email = user.Email, - UserName = user.UserName, - IsAdmin = userRoles.Any(r => r == AdministratorRoleName), - }; - - return this.Ok(response); - } - - [HttpGet(AccountRoute)] - [Authorize] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserDetailsViewModel))] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public IActionResult GetAccount(string id) - { - var loggedUserId = this.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier).Value; - - if (loggedUserId != id) - { - return this.Forbid(); - } - - var user = this.userService.GetUserDetails(id); - - return this.Ok(user); - } - - [HttpPut(AccountRoute)] - [Authorize] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(UserDetailsViewModel))] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task<IActionResult> EditAccount([FromForm] EditUserViewModel model) - { - var loggedUserId = this.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier).Value; - - if (loggedUserId != model.Id) - { - return this.Forbid(); - } - - await this.ValidateEditModelAsync(model); - - if (!this.ModelState.IsValid) - { - return this.BadRequest(this.ModelState); - } - - await this.userService.EditUserAsync(model); - var user = this.userService.GetUserDetails(model.Id); - - return this.Ok(user); - } - - private async Task SetRefreshToken( - ApplicationUser user, - RefreshToken oldRefreshToken = null) - { - var refreshToken = this.userService.GenerateRefreshToken(); - - if (oldRefreshToken != null) - { - oldRefreshToken.Revoked = DateTime.UtcNow; - oldRefreshToken.ReplacedByToken = refreshToken.Token; - } - - user.RefreshTokens.Add(refreshToken); - - await this.userManager.UpdateAsync(user); - - var cookieOptions = new CookieOptions - { - HttpOnly = true, - Expires = DateTime.UtcNow.AddHours(1.5), - SameSite = SameSiteMode.None, - Secure = true, - }; - - this.Response.Cookies.Append(RefreshTokenValue, refreshToken.Token, cookieOptions); - } - - private async Task EmailConfirmation(string email) - { - var user = await this.userManager.FindByEmailAsync(email); - - var origin = this.Request.Headers[HeaderOrigin]; - - await this.emailService.SendEmailConfirmationAsync(origin, user); - } - - private async Task ValidateRegisterModelAsync(RegisterViewModel model) - { - if (await this.userManager.Users.AnyAsync(x => x.Email == model.Email)) - { - this.ModelState.AddModelError(Error, EmailAlreadyInUse); - } - - this.BaseModelValidation(model); - } - - private async Task ValidateEditModelAsync(EditUserViewModel model) - { - if (!await this.userManager.Users.AnyAsync(x => x.Email == model.Email)) - { - this.ModelState.AddModelError(Error, InvalidEmail); - } - - this.BaseModelValidation(model); - } - - private void BaseModelValidation(RegisterViewModel model) - { - if (model.Password != model.ConfirmPassword) - { - this.ModelState.AddModelError(Error, PasswordsDontMatch); - } - - this.ValidateBirthday(model); - - if (model.GenderId < 1 || model.GenderId > GendersMaxCountInDb) - { - this.ModelState.AddModelError(Error, InvalidGender); - } - - this.ValidateCountryAndCity(model); - } - - private void ValidateCountryAndCity(RegisterViewModel model) - { - if (model.CityId < 1 || model.CityId > CitiesMaxCountInDb) - { - this.ModelState.AddModelError(Error, InvalidCity); - } - - if (model.CountryId < 1 || model.CountryId > CountriesMaxCountInDb) - { - this.ModelState.AddModelError(Error, InvalidCountry); - } - - var countryCities = this.countriesService.Get(model.CountryId); - - if (countryCities.Cities.Any(c => c.CityId == model.CityId) == false) - { - this.ModelState.AddModelError(Error, InvalidCity); - } - } - - private void ValidateBirthday(RegisterViewModel model) - { - var age = DateHelper.AgeCalculator(model.Birthdate); - - if (age < MinimalAge) - { - this.ModelState.AddModelError(Error, UnderagedUser); - } - } - } -} diff --git a/Web/LOVE.NET.Web/Dockerfile b/Web/LOVE.NET.Web/Dockerfile deleted file mode 100644 index bb10cd4..0000000 --- a/Web/LOVE.NET.Web/Dockerfile +++ /dev/null @@ -1,35 +0,0 @@ -#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. - -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base -USER app -WORKDIR /app -EXPOSE 8080 -EXPOSE 8081 - -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build -ARG BUILD_CONFIGURATION=Release -WORKDIR /src -COPY ["Web/LOVE.NET.Web/LOVE.NET.Web.csproj", "Web/LOVE.NET.Web/"] -COPY ["LOVE.NET.Common/LOVE.NET.Common.csproj", "LOVE.NET.Common/"] -COPY ["Data/LOVE.NET.Data.Models/LOVE.NET.Data.Models.csproj", "Data/LOVE.NET.Data.Models/"] -COPY ["Services/LOVE.NET.Services.Mapping/LOVE.NET.Services.Mapping.csproj", "Services/LOVE.NET.Services.Mapping/"] -COPY ["Data/LOVE.NET.Data.Common/LOVE.NET.Data.Common.csproj", "Data/LOVE.NET.Data.Common/"] -COPY ["Data/LOVE.NET.Data/Files/*", "YourDestinationPathInDockerImage/"] -COPY ["Data/LOVE.NET.Data/LOVE.NET.Data.csproj", "Data/LOVE.NET.Data/"] -COPY ["Services/LOVE.NET.Services.Messaging/LOVE.NET.Services.Messaging.csproj", "Services/LOVE.NET.Services.Messaging/"] -COPY ["Services/LOVE.NET.Services/LOVE.NET.Services.csproj", "Services/LOVE.NET.Services/"] -COPY ["Web/LOVE.NET.Web.ViewModels/LOVE.NET.Web.ViewModels.csproj", "Web/LOVE.NET.Web.ViewModels/"] -COPY ["Web/LOVE.NET.Web.Infrastructure/LOVE.NET.Web.Infrastructure.csproj", "Web/LOVE.NET.Web.Infrastructure/"] -RUN dotnet restore "./Web/LOVE.NET.Web/LOVE.NET.Web.csproj" -COPY . . -WORKDIR "/src/Web/LOVE.NET.Web" -RUN dotnet build "./LOVE.NET.Web.csproj" -c $BUILD_CONFIGURATION -o /app/build - -FROM build AS publish -ARG BUILD_CONFIGURATION=Release -RUN dotnet publish "./LOVE.NET.Web.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false - -FROM base AS final -WORKDIR /app -COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "LOVE.NET.Web.dll"] \ No newline at end of file diff --git a/Web/LOVE.NET.Web/LOVE.NET.Web.csproj b/Web/LOVE.NET.Web/LOVE.NET.Web.csproj deleted file mode 100644 index a8e4bda..0000000 --- a/Web/LOVE.NET.Web/LOVE.NET.Web.csproj +++ /dev/null @@ -1,59 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk.Web"> - - <PropertyGroup> - <TargetFramework>net8.0</TargetFramework> - <UserSecretsId>aspnet-LOVE.NET-BBB373B5-EF3F-4DBB-B8AA-7152CEC275BF</UserSecretsId> - <LangVersion>latest</LangVersion> - </PropertyGroup> - <PropertyGroup> - <ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles> - </PropertyGroup> - <PropertyGroup> - <CodeAnalysisRuleSet>..\..\Rules.ruleset</CodeAnalysisRuleSet> - <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> - <DockerfileContext>..\..</DockerfileContext> - <DockerComposeProjectPath>..\..\docker-compose.dcproj</DockerComposeProjectPath> - </PropertyGroup> - <ItemGroup> - <AdditionalFiles Include="..\..\stylecop.json" /> - </ItemGroup> - - <ItemGroup> - <InternalsVisibleTo Include="LOVE.NET.Web.Tests" /> - </ItemGroup> - - <ItemGroup> - <None Include="..\..\.editorconfig" Link=".editorconfig" /> - </ItemGroup> - - <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.2" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.2" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.2"> - <PrivateAssets>all</PrivateAssets> - <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> - </PackageReference> - <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.19.6" /> - <PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.1" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.2" /> - <PackageReference Include="BuildBundlerMinifier" Version="3.2.449" /> - <PackageReference Include="Microsoft.Web.LibraryManager.Build" Version="2.1.175" /> - <PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.2" /> - <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.2" /> - <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.435" PrivateAssets="all"> - <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> - </PackageReference> - <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" /> - </ItemGroup> - - <ItemGroup> - <ProjectReference Include="..\..\LOVE.NET.Common\LOVE.NET.Common.csproj" /> - <ProjectReference Include="..\..\Data\LOVE.NET.Data.Models\LOVE.NET.Data.Models.csproj" /> - <ProjectReference Include="..\..\Data\LOVE.NET.Data\LOVE.NET.Data.csproj" /> - <ProjectReference Include="..\..\Services\LOVE.NET.Services.Mapping\LOVE.NET.Services.Mapping.csproj" /> - <ProjectReference Include="..\..\Services\LOVE.NET.Services.Messaging\LOVE.NET.Services.Messaging.csproj" /> - <ProjectReference Include="..\..\Services\LOVE.NET.Services\LOVE.NET.Services.csproj" /> - <ProjectReference Include="..\LOVE.NET.Web.Infrastructure\LOVE.NET.Web.Infrastructure.csproj" /> - <ProjectReference Include="..\LOVE.NET.Web.ViewModels\LOVE.NET.Web.ViewModels.csproj" /> - </ItemGroup> -</Project> diff --git a/Web/LOVE.NET.Web/Program.cs b/Web/LOVE.NET.Web/Program.cs deleted file mode 100644 index 277570d..0000000 --- a/Web/LOVE.NET.Web/Program.cs +++ /dev/null @@ -1,222 +0,0 @@ -namespace LOVE.NET.Web -{ - using System; - using System.Reflection; - using System.Text; - - using CloudinaryDotNet; - - using LOVE.NET.Data; - using LOVE.NET.Data.Common; - using LOVE.NET.Data.Common.Repositories; - using LOVE.NET.Data.Models; - using LOVE.NET.Data.Repositories; - using LOVE.NET.Data.Repositories.Chat; - using LOVE.NET.Data.Repositories.Countries; - using LOVE.NET.Data.Repositories.Users; - using LOVE.NET.Data.Seeding; - using LOVE.NET.Services.Chats; - using LOVE.NET.Services.Countries; - using LOVE.NET.Services.Dashboard; - using LOVE.NET.Services.Dating; - using LOVE.NET.Services.Email; - using LOVE.NET.Services.Genders; - using LOVE.NET.Services.Identity; - using LOVE.NET.Services.Images; - using LOVE.NET.Services.Mapping; - using LOVE.NET.Services.Messaging; - using LOVE.NET.Web.ViewModels.Chat; - using LOVE.NET.Web.ViewModels.Identity; - - using Microsoft.AspNetCore.Authentication.JwtBearer; - using Microsoft.AspNetCore.Builder; - using Microsoft.AspNetCore.Identity; - using Microsoft.EntityFrameworkCore; - using Microsoft.Extensions.Configuration; - using Microsoft.Extensions.DependencyInjection; - using Microsoft.Extensions.Hosting; - using Microsoft.IdentityModel.Tokens; - using Microsoft.OpenApi.Models; - - using static LOVE.NET.Common.GlobalConstants; - using static LOVE.NET.Common.GlobalConstants.JWTSecurityScheme; - - public class Program - { - public static void Main(string[] args) - { - var builder = WebApplication.CreateBuilder(args); - ConfigureServices(builder.Services, builder.Configuration); - var app = builder.Build(); - Configure(app, builder.Configuration); - app.Run(); - } - - private static void ConfigureServices(IServiceCollection services, IConfiguration configuration) - { - services.AddDbContext<ApplicationDbContext>( - options => options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"))); - - services.AddDefaultIdentity<ApplicationUser>(IdentityOptionsProvider.GetIdentityOptions) - .AddRoles<ApplicationRole>() - .AddEntityFrameworkStores<ApplicationDbContext>() - .AddDefaultTokenProviders(); - - services.AddSwaggerGen(); - services.AddRazorPages(); - services.AddDatabaseDeveloperPageExceptionFilter(); - - services.AddSingleton(configuration); - - Account cloudinaryAccount = new Account( - configuration[CloudinaryCloudName], - configuration[CloudinaryKey], - configuration[CloudinarySecret]); - - Cloudinary cloudinary = new Cloudinary(cloudinaryAccount); - cloudinary.Api.Secure = true; - - services.AddSingleton(cloudinary); - - services.AddAuthentication(auth => - { - auth.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - auth.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - }).AddJwtBearer(options => - { - options.RequireHttpsMetadata = false; - options.SaveToken = true; - options.TokenValidationParameters = new TokenValidationParameters() - { - ValidateIssuer = true, - ValidateAudience = true, - ValidAudience = configuration[AuthSettingsAudience], - ValidIssuer = configuration[AuthSettingsIssuer], - RequireExpirationTime = true, - IssuerSigningKey = new SymmetricSecurityKey( - Encoding.UTF8.GetBytes( - configuration[AuthSettingsKey])), - ValidateIssuerSigningKey = true, - ValidateLifetime = true, - }; - }); - - services.AddSwaggerGen(options => - { - options.SwaggerDoc("v1", new OpenApiInfo - { - Version = "v1", - Title = "LOVE.NET.API", - Description = "An ASP.NET Core Web Dating App API", - }); - - var jwtSecurityScheme = new OpenApiSecurityScheme - { - Scheme = JWTScheme, - BearerFormat = JWT, - Name = JWTName, - In = ParameterLocation.Header, - Type = SecuritySchemeType.Http, - Description = JWTDescription, - Reference = new OpenApiReference - { - Id = JwtBearerDefaults.AuthenticationScheme, - Type = ReferenceType.SecurityScheme, - }, - }; - - options.AddSecurityDefinition(jwtSecurityScheme.Reference.Id, jwtSecurityScheme); - - options.AddSecurityRequirement(new OpenApiSecurityRequirement - { - { - jwtSecurityScheme, Array.Empty<string>() - }, - }); - }); - - // Data repositories - services.AddScoped(typeof(IDeletableEntityRepository<>), typeof(EfDeletableEntityRepository<>)); - services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>)); - services.AddScoped(typeof(IRepository<Gender>), typeof(EfRepository<Gender>)); - services.AddScoped<IDbQueryRunner, DbQueryRunner>(); - services.AddScoped<IUsersRepository, UsersRepository>(); - services.AddScoped<ICountriesRepository, CountriesRepository>(); - services.AddScoped<IChatRepository, ChatRepository>(); - services.AddScoped<IChatroomRepository, ChatroomRepository>(); - - // Application services - services.AddSignalR(); - services.AddTransient<IEmailSender>(x => - new SendGridEmailSender(configuration[SendGridApiKey])); - services.AddTransient<IIdentityService, IdentityService>(); - services.AddTransient<IEmailService, EmailService>(); - services.AddTransient<ICountriesService, CountriesService>(); - services.AddSingleton<IImagesService, ImagesService>(); - services.AddTransient<IGendersService, GendersService>(); - services.AddTransient<IDatingService, DatingService>(); - services.AddTransient<IDashboardService, DashboardService>(); - services.AddScoped<IChatService, ChatService>(); - services.AddSingleton<IUsersGroupService, UsersGroupService>(); - services.AddCors(options => - { - options.AddPolicy("DockerOrigin", - builder => builder.WithOrigins(configuration[UrlBase]) - .AllowAnyHeader() - .AllowAnyMethod() - .AllowCredentials()); - }); - } - - private static void Configure(WebApplication app, IConfiguration configuration) - { - AutoMapperConfig.RegisterMappings( - typeof(BaseCredentialsModel).GetTypeInfo().Assembly, - typeof(MessageDto).GetTypeInfo().Assembly); - - app.UseSwagger(); - app.UseSwaggerUI(options => - { - options.SwaggerEndpoint("/swagger/v1/swagger.json", "v1"); - options.RoutePrefix = string.Empty; - }); - - if (app.Environment.IsDevelopment()) - { - // Seed data on application startup - using (var serviceScope = app.Services.CreateScope()) - { - var dbContext = serviceScope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); - - // dbContext.Database.EnsureDeleted(); - dbContext.Database.Migrate(); - new ApplicationDbContextSeeder().SeedAsync(dbContext, serviceScope.ServiceProvider).GetAwaiter().GetResult(); - } - - app.UseDeveloperExceptionPage(); - app.UseMigrationsEndPoint(); - } - else - { - app.UseExceptionHandler("/Home/Error"); - app.UseHsts(); - app.UseHttpsRedirection(); - } - - app.UseCors("DockerOrigin"); - - // app.UseHttpsRedirection(); // Do not redirect to https when in dev docker - app.UseStaticFiles(); - - app.UseRouting(); - - app.UseAuthentication(); - app.UseAuthorization(); - - app.MapHub<ChatHub>("/chat"); - app.MapControllerRoute("areaRoute", "{area:exists}/{controller=Home}/{action=Index}/{id?}"); - app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}"); - app.MapRazorPages(); - } - } -} diff --git a/Web/LOVE.NET.Web/appsettings.Development.json b/Web/LOVE.NET.Web/appsettings.Development.json deleted file mode 100644 index 754b466..0000000 --- a/Web/LOVE.NET.Web/appsettings.Development.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - }, - "AuthSettings": { - "Key": "This key will be used in the encrypting keep it in appsettings.Production.json", - "Audience": "https://localhost:3000/", - "Issuer": "https://localhost:3000/" - }, - "Url": { - "Base": "http://localhost:3000" - } -} diff --git a/Web/LOVE.NET.Web/appsettings.json b/Web/LOVE.NET.Web/appsettings.json deleted file mode 100644 index f48a7e9..0000000 --- a/Web/LOVE.NET.Web/appsettings.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "ConnectionStrings": { - "DefaultConnection": "Server=mssql,1433;Initial Catalog=LOVE.NET;User ID =SA;Password=Admin@123;TrustServerCertificate=True;", - "DefaultConnection_LOCAL": "Server=.;Database=LOVE.NET;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True" - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/Web/LOVE.NET.Web/bundleconfig.json b/Web/LOVE.NET.Web/bundleconfig.json deleted file mode 100644 index 6d3f9a5..0000000 --- a/Web/LOVE.NET.Web/bundleconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -// Configure bundling and minification for the project. -// More info at https://go.microsoft.com/fwlink/?LinkId=808241 -[ - { - "outputFileName": "wwwroot/css/site.min.css", - // An array of relative input file paths. Globbing patterns supported - "inputFiles": [ - "wwwroot/css/site.css" - ] - }, - { - "outputFileName": "wwwroot/js/site.min.js", - "inputFiles": [ - "wwwroot/js/site.js" - ], - // Optionally specify minification options - "minify": { - "enabled": true, - "renameLocals": true - }, - // Optionally generate .map file - "sourceMap": false - } -] diff --git a/Web/LOVE.NET.Web/libman.json b/Web/LOVE.NET.Web/libman.json deleted file mode 100644 index 139d74c..0000000 --- a/Web/LOVE.NET.Web/libman.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "version": "1.0", - "defaultProvider": "jsdelivr", - "libraries": [ - { - "library": "jquery@3.6.0", - "destination": "wwwroot/lib/jquery/", - "files": [ - "dist/jquery.min.js", - "dist/jquery.js" - ] - }, - { - "library": "bootstrap@5.1.3", - "destination": "wwwroot/lib/bootstrap/", - "files": [ - "dist/css/bootstrap.css", - "dist/css/bootstrap.min.css", - "dist/js/bootstrap.js", - "dist/js/bootstrap.min.js" - ] - }, - { - "library": "jquery-validation@1.19.4", - "destination": "wwwroot/lib/jquery-validation/", - "files": [ - "dist/jquery.validate.js", - "dist/jquery.validate.min.js" - ] - }, - { - "library": "jquery-validation-unobtrusive@3.2.12", - "destination": "wwwroot/lib/jquery-validation-unobtrusive/", - "files": [ - "dist/jquery.validate.unobtrusive.js", - "dist/jquery.validate.unobtrusive.min.js" - ] - } - ] -} diff --git a/Web/LOVE.NET.Web/wwwroot/css/site.css b/Web/LOVE.NET.Web/wwwroot/css/site.css deleted file mode 100644 index cabe29d..0000000 --- a/Web/LOVE.NET.Web/wwwroot/css/site.css +++ /dev/null @@ -1,74 +0,0 @@ -/* Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification -for details on configuring this project to bundle and minify static web assets. */ - -a.navbar-brand { - white-space: normal; - text-align: center; - word-break: break-all; -} - -/* Provide sufficient contrast against white background */ -a { - color: #0366d6; -} - -.btn-primary { - color: #fff; - background-color: #1b6ec2; - border-color: #1861ac; -} - -.nav-pills .nav-link.active, .nav-pills .show > .nav-link { - color: #fff; - background-color: #1b6ec2; - border-color: #1861ac; -} - -/* Sticky footer styles --------------------------------------------------- */ -html { - font-size: 14px; -} - -@media (min-width: 768px) { - html { - font-size: 16px; - } -} - -.border-top { - border-top: 1px solid #e5e5e5; -} - -.border-bottom { - border-bottom: 1px solid #e5e5e5; -} - -.box-shadow { - box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05); -} - -button.accept-policy { - font-size: 1rem; - line-height: inherit; -} - -/* Sticky footer styles --------------------------------------------------- */ -html { - position: relative; - min-height: 100%; -} - -body { - /* Margin bottom by footer height */ - margin-bottom: 60px; -} - -.footer { - position: absolute; - bottom: 0; - width: 100%; - white-space: nowrap; - line-height: 60px; /* Vertically center the text there */ -} diff --git a/Web/LOVE.NET.Web/wwwroot/favicon.ico b/Web/LOVE.NET.Web/wwwroot/favicon.ico deleted file mode 100644 index 074c775..0000000 Binary files a/Web/LOVE.NET.Web/wwwroot/favicon.ico and /dev/null differ diff --git a/Web/LOVE.NET.Web/wwwroot/js/site.js b/Web/LOVE.NET.Web/wwwroot/js/site.js deleted file mode 100644 index ac49c18..0000000 --- a/Web/LOVE.NET.Web/wwwroot/js/site.js +++ /dev/null @@ -1,4 +0,0 @@ -// Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification -// for details on configuring this project to bundle and minify static web assets. - -// Write your JavaScript code. diff --git a/docker-compose.dcproj b/docker-compose.dcproj deleted file mode 100644 index 56896cd..0000000 --- a/docker-compose.dcproj +++ /dev/null @@ -1,19 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="15.0" Sdk="Microsoft.Docker.Sdk"> - <PropertyGroup Label="Globals"> - <ProjectVersion>2.1</ProjectVersion> - <DockerTargetOS>Linux</DockerTargetOS> - <DockerPublishLocally>False</DockerPublishLocally> - <ProjectGuid>e12c4beb-6f98-4b14-b805-30f94cb6bebc</ProjectGuid> - <DockerLaunchAction>LaunchBrowser</DockerLaunchAction> - <DockerServiceUrl>{Scheme}://localhost:{ServicePort}</DockerServiceUrl> - <DockerServiceName>love.net.web.tests</DockerServiceName> - </PropertyGroup> - <ItemGroup> - <None Include="docker-compose.override.yml"> - <DependentUpon>docker-compose.yml</DependentUpon> - </None> - <None Include="docker-compose.yml" /> - <None Include=".dockerignore" /> - </ItemGroup> -</Project> \ No newline at end of file diff --git a/docker-compose.override.yml b/docker-compose.override.yml deleted file mode 100644 index 90470eb..0000000 --- a/docker-compose.override.yml +++ /dev/null @@ -1,14 +0,0 @@ -version: '3.4' - -services: - love.net.web: - environment: - - ASPNETCORE_ENVIRONMENT=Development - - ASPNETCORE_HTTP_PORTS=8080 - - ASPNETCORE_HTTPS_PORTS=8081 - ports: - - "8080" - - "8081" - volumes: - - ${APPDATA}/Microsoft/UserSecrets:/home/app/.microsoft/usersecrets:ro - - ${APPDATA}/ASP.NET/Https:/home/app/.aspnet/https:ro diff --git a/docker-compose.yml b/docker-compose.yml index d6e71f6..1556a32 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,42 +1,348 @@ -version: '3.4' +name: lovenet + +# Infrastructure tier: one Postgres per stateful service (bulkhead isolation), +# single-node Kafka in KRaft mode, Redis for the SignalR backplane + presence. +# Application services are added on top of this file (see README). + +x-postgres-common: &postgres-common + image: postgres:16-alpine + environment: &postgres-env + POSTGRES_USER: lovenet + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-lovenet} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U lovenet"] + interval: 5s + timeout: 3s + retries: 12 + networks: + - net services: - mssql: - container_name: mssql - image: mcr.microsoft.com/mssql/server:2017-latest + identity-db: + <<: *postgres-common + container_name: lovenet-identity-db + environment: + <<: *postgres-env + POSTGRES_DB: lovenet_identity + ports: + - "5433:5432" + volumes: + - identity-db-data:/var/lib/postgresql/data + + profile-db: + <<: *postgres-common + container_name: lovenet-profile-db + environment: + <<: *postgres-env + POSTGRES_DB: lovenet_profile + ports: + - "5434:5432" + volumes: + - profile-db-data:/var/lib/postgresql/data + + matching-db: + <<: *postgres-common + container_name: lovenet-matching-db + environment: + <<: *postgres-env + POSTGRES_DB: lovenet_matching + ports: + - "5435:5432" + volumes: + - matching-db-data:/var/lib/postgresql/data + + chat-db: + <<: *postgres-common + container_name: lovenet-chat-db environment: - ACCEPT_EULA: 'Y' - MSSQL_SA_PASSWORD: 'Admin@123' - ports: - - 1433:1433 + <<: *postgres-env + POSTGRES_DB: lovenet_chat + ports: + - "5436:5432" + volumes: + - chat-db-data:/var/lib/postgresql/data + + notifications-db: + <<: *postgres-common + container_name: lovenet-notifications-db + environment: + <<: *postgres-env + POSTGRES_DB: lovenet_notifications + ports: + - "5437:5432" + volumes: + - notifications-db-data:/var/lib/postgresql/data + + kafka: + image: confluentinc/cp-kafka:7.6.1 + container_name: lovenet-kafka + ports: + - "29092:29092" + environment: + CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk + KAFKA_NODE_ID: 1 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093 + KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,PLAINTEXT_HOST://0.0.0.0:29092 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false" + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + volumes: + - kafka-data:/var/lib/kafka/data + healthcheck: + test: ["CMD-SHELL", "kafka-topics --bootstrap-server localhost:9092 --list >/dev/null 2>&1"] + interval: 10s + timeout: 10s + retries: 12 networks: - net - love.net.web: - image: ${DOCKER_REGISTRY-}lovenetweb + # One-shot topic creation. Auto-creation is disabled on the broker so every + # topic gets its intended partition count (ordering is per partition). + kafka-init: + image: confluentinc/cp-kafka:7.6.1 + container_name: lovenet-kafka-init + depends_on: + kafka: + condition: service_healthy + entrypoint: ["/bin/bash", "-c"] + command: + - | + set -e + create() { kafka-topics --bootstrap-server kafka:9092 --create --if-not-exists --topic "$$1" --partitions "$$2" --replication-factor 1; } + create identity.user-registered 3 + create identity.user-banned 3 + create profile.user-updated 3 + create matching.match-created 3 + create chat.message-sent 8 + create chat.room-provisioned 3 + create notifications.email-requested 3 + create dead-letter 1 + create healthchecks-topic 1 + echo "Topics ready:" + kafka-topics --bootstrap-server kafka:9092 --list + networks: + - net + + redis: + image: redis:7-alpine + container_name: lovenet-redis + ports: + - "6380:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 12 + networks: + - net + + jaeger: + image: jaegertracing/all-in-one:1.57 + container_name: lovenet-jaeger + profiles: ["observability"] + ports: + - "16686:16686" # UI + - "4317:4317" # OTLP gRPC + networks: + - net + + # ---------------------------------------------------------------- services + + identity-api: build: context: . - dockerfile: Web/LOVE.NET.Web/Dockerfile - ports: - - 8080:8080 + dockerfile: src/Services/Identity/LoveNet.Identity.Api/Dockerfile + container_name: lovenet-identity-api + environment: &service-env + ASPNETCORE_ENVIRONMENT: Development + Kafka__BootstrapServers: kafka:9092 + Jwt__Key: ${JWT_KEY:-lovenet-local-development-signing-key-do-not-use-in-production-0123456789abcdef} + Jwt__Issuer: ${JWT_ISSUER:-https://localhost:3000/} + Jwt__Audience: ${JWT_AUDIENCE:-https://localhost:3000/} + ConnectionStrings__Default: Host=identity-db;Port=5432;Database=lovenet_identity;Username=lovenet;Password=${POSTGRES_PASSWORD:-lovenet} + Cloudinary__CloudName: ${CLOUDINARY_CLOUD_NAME:-} + Cloudinary__Key: ${CLOUDINARY_KEY:-} + Cloudinary__Secret: ${CLOUDINARY_SECRET:-} + ports: + - "8081:8080" + depends_on: + identity-db: + condition: service_healthy + kafka-init: + condition: service_completed_successfully + healthcheck: &service-health + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 10s + timeout: 5s + retries: 18 + start_period: 20s + networks: + - net + + profile-api: + build: + context: . + dockerfile: src/Services/Profile/LoveNet.Profile.Api/Dockerfile + container_name: lovenet-profile-api + environment: + <<: *service-env + ConnectionStrings__Default: Host=profile-db;Port=5432;Database=lovenet_profile;Username=lovenet;Password=${POSTGRES_PASSWORD:-lovenet} + ports: + - "8082:8080" depends_on: - - mssql + profile-db: + condition: service_healthy + kafka-init: + condition: service_completed_successfully + healthcheck: *service-health networks: - net - love.net.web-fe: + matching-api: + build: + context: . + dockerfile: src/Services/Matching/LoveNet.Matching.Api/Dockerfile + container_name: lovenet-matching-api + environment: + <<: *service-env + ConnectionStrings__Default: Host=matching-db;Port=5432;Database=lovenet_matching;Username=lovenet;Password=${POSTGRES_PASSWORD:-lovenet} + ports: + - "8083:8080" + depends_on: + matching-db: + condition: service_healthy + kafka-init: + condition: service_completed_successfully + healthcheck: *service-health + networks: + - net + + chat-api: + build: + context: . + dockerfile: src/Services/Chat/LoveNet.Chat.Api/Dockerfile + container_name: lovenet-chat-api + environment: + <<: *service-env + ConnectionStrings__Default: Host=chat-db;Port=5432;Database=lovenet_chat;Username=lovenet;Password=${POSTGRES_PASSWORD:-lovenet} + ports: + - "8084:8080" + depends_on: + chat-db: + condition: service_healthy + kafka-init: + condition: service_completed_successfully + healthcheck: *service-health + networks: + - net + + realtime-api: + build: + context: . + dockerfile: src/Services/Realtime/LoveNet.Realtime.Api/Dockerfile + container_name: lovenet-realtime-api + environment: + <<: *service-env + Redis__Connection: redis:6379 + ports: + - "8085:8080" + depends_on: + redis: + condition: service_healthy + kafka-init: + condition: service_completed_successfully + healthcheck: *service-health + networks: + - net + + notifications-api: + build: + context: . + dockerfile: src/Services/Notifications/LoveNet.Notifications.Api/Dockerfile + container_name: lovenet-notifications-api + environment: + <<: *service-env + ConnectionStrings__Default: Host=notifications-db;Port=5432;Database=lovenet_notifications;Username=lovenet;Password=${POSTGRES_PASSWORD:-lovenet} + SendGrid__ApiKey: ${SENDGRID_API_KEY:-} + ports: + - "8086:8080" + depends_on: + notifications-db: + condition: service_healthy + kafka-init: + condition: service_completed_successfully + healthcheck: *service-health + networks: + - net + + gateway: + build: + context: . + dockerfile: src/Gateway/LoveNet.Gateway/Dockerfile + container_name: lovenet-gateway + environment: + ASPNETCORE_ENVIRONMENT: Development + Jwt__Key: ${JWT_KEY:-lovenet-local-development-signing-key-do-not-use-in-production-0123456789abcdef} + Jwt__Issuer: ${JWT_ISSUER:-https://localhost:3000/} + Jwt__Audience: ${JWT_AUDIENCE:-https://localhost:3000/} + Frontend__Origin: ${FRONTEND_ORIGIN:-http://localhost:3000} + Services__Identity: http://identity-api:8080 + Services__Profile: http://profile-api:8080 + Services__Matching: http://matching-api:8080 + Services__Chat: http://chat-api:8080 + Services__Realtime: http://realtime-api:8080 + ReverseProxy__Clusters__identity__Destinations__destination1__Address: http://identity-api:8080 + ReverseProxy__Clusters__profile__Destinations__destination1__Address: http://profile-api:8080 + ReverseProxy__Clusters__matching__Destinations__destination1__Address: http://matching-api:8080 + ReverseProxy__Clusters__chat__Destinations__destination1__Address: http://chat-api:8080 + ReverseProxy__Clusters__realtime__Destinations__destination1__Address: http://realtime-api:8080 + ports: + - "8080:8080" + depends_on: + identity-api: + condition: service_healthy + profile-api: + condition: service_healthy + matching-api: + condition: service_healthy + chat-api: + condition: service_healthy + realtime-api: + condition: service_healthy + notifications-api: + condition: service_healthy + healthcheck: *service-health + networks: + - net + + frontend: build: context: ./Web-FrontEnd dockerfile: Dockerfile - container_name: LOVE.NET.Web-Fe + container_name: lovenet-frontend ports: - - 3000:3000 + - "3000:3000" stdin_open: true tty: true depends_on: - - love.net.web + - gateway networks: - net +volumes: + identity-db-data: + profile-db-data: + matching-db-data: + chat-db-data: + notifications-db-data: + kafka-data: + networks: - net: \ No newline at end of file + net: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..aed7a3b --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,185 @@ +# LOVE.NET — Microservices Architecture + +LOVE.NET is a dating app (swipe → match → chat) refactored from a layered +ASP.NET Core monolith into an event-driven microservices system designed to +scale a realtime chat workload to millions of users. The original monolith is +preserved on the `monolith-snapshot` branch. + +## Why the monolith had to change + +The monolith (single `ApplicationDbContext`, one SQL Server, one web process) +had five structural blockers, all verified in code before this refactor: + +1. **Unauthenticated realtime.** The SignalR hub had no `[Authorize]`; the + sender's identity was whatever the client claimed in the payload. +2. **Process-local state.** Room presence lived in an in-memory singleton + dictionary (the code itself said `// Use Redis Cache for this`), and SignalR + ran without a backplane — a second instance would split rooms in half. +3. **Display-before-durability.** The hub broadcast a message to clients + *before* persisting it; a DB failure lost a message everyone already saw. +4. **Derived matches.** A "match" was recomputed from mutual likes on every + request, and chat authorization string-parsed the room-id convention. +5. **One database for everything.** `ApplicationUser` was a mega-aggregate + eagerly loading likes, images, geo data, roles, and tokens; every feature + was coupled to every table. + +## Target architecture + +```mermaid +flowchart LR + SPA[React SPA :3000] + GW[Gateway - YARP :8080\nCORS, rate limits, admin stats fan-out] + + SPA -->|REST + WebSocket| GW + + GW --> ID[Identity API\nPostgres: lovenet_identity] + GW --> PR[Profile API\nPostgres: lovenet_profile] + GW --> MA[Matching API\nPostgres: lovenet_matching] + GW --> CH[Chat API\nPostgres: lovenet_chat] + GW -->|/chat WebSocket| RT[Realtime API\nstateless, Redis] + + K[(Kafka)] + R[(Redis\nbackplane + presence)] + + ID -->|user-registered, user-banned, email-requested| K + PR -->|user-updated| K + MA -->|match-created| K + RT -->|message-sent| K + CH -->|room-provisioned| K + + K --> PR + K --> MA + K --> CH + K --> RT + K --> NO[Notifications API\nPostgres: lovenet_notifications\nSendGrid] + K --> ID + + RT --- R +``` + +| Service | Port | Owns | Publishes | Consumes | +|---|---|---|---|---| +| Gateway (YARP) | 8080 | routing, CORS, rate limits, `/api/admin/dashboard` aggregation | — | — | +| Identity | 8081 | credentials (ASP.NET Identity), refresh tokens, JWT issuance, moderation | `identity.user-registered`, `identity.user-banned`, `notifications.email-requested` | `profile.user-updated` (login read model) | +| Profile | 8082 | profiles, images, countries/cities/genders | `profile.user-updated` (enriched) | `identity.user-registered`, `identity.user-banned` | +| Matching | 8083 | likes, **persisted matches**, candidate read model | `matching.match-created` | `profile.user-updated` | +| Chat | 8084 | messages (partitioned), chatrooms, provisioned rooms, history API | `chat.room-provisioned` | `chat.message-sent`, `matching.match-created`, `profile.user-updated` | +| Realtime | 8085 | SignalR hub, presence (Redis), match push | `chat.message-sent` | `matching.match-created`, `chat.room-provisioned` | +| Notifications | 8086 | email delivery, templates, contact read model | — | `notifications.email-requested`, `matching.match-created`, user events | + +## Key flows + +**Chat message (durable-before-display).** +Client → hub `SendMessage` → hub validates room membership and stamps +id/sender from JWT claims → **awaits Kafka produce** (`chat.message-sent`, +acks=all, key=roomId) → broadcasts `ReceiveMessage` via the Redis backplane → +Chat service consumer persists idempotently by message id. A message a client +has seen is always already in the broker; per-room ordering comes from the +partition key. + +**Match.** +Matching inserts the like; on reciprocity it inserts the `matches` row **and** +the `matching.match-created` outbox event in one transaction. Downstream: +Chat provisions the room, Notifications emails both users, Realtime pushes +`MatchReceived` to both. The room id keeps the legacy convention (both user +GUIDs sorted, concatenated) so Chat/Realtime can authorize deterministically +even before the room event lands. + +**Registration.** +Identity creates the account and writes `identity.user-registered` + +`notifications.email-requested` to its outbox in the same transaction. Profile +builds the profile aggregate and republishes the **enriched** +`profile.user-updated` (geo names, coordinates, avatar, images, ban/admin +flags) that every other service uses for its local read model. Demo users are +seeded through this same pipeline. + +## Patterns applied (and the vocabulary they map to) + +- **Transactional outbox / inbox.** State change + event commit atomically; + each consumer registers the envelope `MessageId` in the same transaction as + its side effects. Delivery is **at-least-once**; idempotent handlers make + processing **effectively-once**. +- **Ordering guarantees.** Only per partition: `chat.message-sent` and + `matching.match-created` are keyed by roomId, user events by userId. No + global ordering is promised, and nothing depends on one. +- **CAP / consistency spectrum.** Within a service: strong consistency + (Postgres). Between services: **eventual consistency** through Kafka. The + visible windows (match toast before room row; profile edits reaching the + deck) are bounded by consumer lag and mitigated by the deterministic room-id + fallback authorization. +- **Stateless vs stateful tiers.** Every API tier is stateless; state lives in + Postgres/Redis/Kafka. Realtime keeps no in-process state at all — presence + is in Redis, group fan-out goes through the backplane — so it scales + horizontally (with sticky negotiate or `skipNegotiation` + WebSockets). +- **Resiliency patterns.** Timeouts, retry-with-backoff and circuit breakers + on the gateway's HTTP fan-out (`Microsoft.Extensions.Http.Resilience`); + graceful degradation (fallback) in the stats aggregation; **rate limiting** + at the gateway (fixed window per IP, tighter on credentials, token bucket on + likes); **bulkhead** isolation via database-per-service and per-service + containers; **dead-letter queue** after bounded consumer retries so a poison + message never stalls a partition. +- **Observability.** Serilog structured logs (JSON outside Development), + OpenTelemetry traces + metrics with OTLP export (optional Jaeger container + behind the `observability` compose profile), correlation ids propagated + HTTP → outbox envelope → consumers, `/health` per service covering its real + dependencies. + +## The chat data model + +Messages are write-heavy and read-recent ("last N per room"). The `messages` +table is **range-partitioned by month** on `created_on` (hand-written SQL in +the initial migration; a DEFAULT partition catches strays and startup code +pre-creates months around now), with the read index `(room_id, created_on +DESC)`. All access goes through the narrow `IMessageStore` seam +(append + page-by-room), which is exactly the shape a Cassandra/ScyllaDB +implementation keyed on `(room_id, time_bucket)` needs — that swap is the +documented scale-out path Discord took, and it requires no caller changes. + +## Scaling story (HA) + +- **Realtime**: N replicas behind the gateway; Redis backplane fans broadcasts + across instances; presence is shared state in Redis. WebSockets need session + affinity for the negotiate handshake (or `skipNegotiation`). +- **Kafka**: `chat.message-sent` has 8 partitions — up to 8 parallel Chat + consumers; scale by partitions. +- **APIs**: stateless; scale horizontally behind the gateway/LB. +- **Postgres**: read replicas per service; Chat's growth path is the + `IMessageStore` swap above. Reference data (countries/genders) can be + cached in Redis. +- **Outbox publisher**: single instance per service by design; scaling it out + needs row locking (`FOR UPDATE SKIP LOCKED`) — documented, not built. + +## Security notes / production hardening + +- All services validate the same **HS512** JWT (shared key via env). This kept + token issuance byte-compatible with the monolith, but one leaked key forges + tokens everywhere. The production upgrade is asymmetric signing: Identity + signs **RS256** and exposes JWKS; validators fetch the public key — a + config-level change in `BuildingBlocks.Web`. +- The hub authenticates via `access_token` query parameter (SignalR standard); + the gateway terminates TLS in production. +- Secrets flow through `.env` (gitignored; `.env.example` documents the keys). + Cloudinary/SendGrid degrade gracefully when unconfigured for local dev. + +## What was deliberately NOT built + +- **Sagas/compensation** — the only multi-service write flow (registration) + is safe with outbox + retries; no rollback semantics are needed yet. +- **DLQ replay tooling** — dead-lettered messages carry origin headers but + replay is manual (`kafka-console-consumer`/`producer`). +- **Kubernetes manifests** — compose is the deployment story here; the + services are 12-factor and container-ready. +- **Schema registry / Avro** — JSON envelopes with a `version` field; a + registry earns its keep only with many producers per topic. +- **Read-receipts / typing indicators** — parity with the monolith was the + goal; the hub is the obvious place to add them. + +## Running locally + +```bash +cp .env.example .env # fill Cloudinary/SendGrid if you want images/emails +docker compose up -d --build +./scripts/smoke-test.ps1 # end-to-end proof +# SPA: http://localhost:3000, gateway: http://localhost:8080 +# optional tracing UI: docker compose --profile observability up -d jaeger +``` diff --git a/docs/images/containers.png b/docs/images/containers.png new file mode 100644 index 0000000..06f3627 Binary files /dev/null and b/docs/images/containers.png differ diff --git a/docs/images/database-placement.png b/docs/images/database-placement.png new file mode 100644 index 0000000..01959af Binary files /dev/null and b/docs/images/database-placement.png differ diff --git a/docs/images/tests.png b/docs/images/tests.png new file mode 100644 index 0000000..f28c408 Binary files /dev/null and b/docs/images/tests.png differ diff --git a/launchSettings.json b/launchSettings.json deleted file mode 100644 index dc3415c..0000000 --- a/launchSettings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "profiles": { - "Docker Compose": { - "commandName": "DockerCompose", - "commandVersion": "1.0", - "serviceActions": { - "love.net.web.tests": "StartDebugging", - "love.net.web": "StartDebugging" - } - } - } -} \ No newline at end of file diff --git a/scripts/hub-smoke.js b/scripts/hub-smoke.js new file mode 100644 index 0000000..cb27435 --- /dev/null +++ b/scripts/hub-smoke.js @@ -0,0 +1,63 @@ +// SignalR end-to-end probe, executed inside the frontend container (docker +// exec) so it can reuse the app's @microsoft/signalr and ws packages and +// reach the gateway over the compose network. +// +// Env: GATEWAY_URL (default http://gateway:8080), TOKEN, ROOM_ID, MESSAGE_TEXT. +// Prints HUB_OK <json> and exits 0 when the sent message echoes back. + +const signalR = require("@microsoft/signalr"); +const WebSocket = require("ws"); + +const gateway = process.env.GATEWAY_URL || "http://gateway:8080"; +const token = process.env.TOKEN; +const roomId = process.env.ROOM_ID; +const text = process.env.MESSAGE_TEXT || "smoke-test message"; + +(async () => { + const connection = new signalR.HubConnectionBuilder() + .withUrl(`${gateway}/chat`, { + accessTokenFactory: () => token, + // Node has no browser fetch/WebSocket: skip the negotiate HTTP call and + // go straight to websockets with the ws package. + skipNegotiation: true, + transport: signalR.HttpTransportType.WebSockets, + WebSocket, + }) + .build(); + + let received = null; + connection.on("ReceiveMessage", (message) => { + received = message; + }); + + await connection.start(); + await connection.invoke("JoinRoom", { + roomId, + userId: null, + userName: null, + profilePictureUrl: null, + }); + await connection.invoke("SendMessage", { + roomId, + text, + imageUrl: null, + profilePicture: null, + }); + + const deadline = Date.now() + 15000; + while (!received && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + if (!received || received.text !== text) { + console.error("FAIL: ReceiveMessage did not echo the sent message", received); + process.exit(1); + } + + console.log("HUB_OK " + JSON.stringify(received)); + await connection.stop(); + process.exit(0); +})().catch((error) => { + console.error("FAIL:", error.message); + process.exit(1); +}); diff --git a/scripts/smoke-test.ps1 b/scripts/smoke-test.ps1 new file mode 100644 index 0000000..45b09a8 --- /dev/null +++ b/scripts/smoke-test.ps1 @@ -0,0 +1,117 @@ +# End-to-end smoke test for the LOVE.NET microservices stack. +# Prereq: docker compose up -d --build (all containers healthy). +# Works on Windows PowerShell 5.1 (uses curl.exe for multipart forms). + +$ErrorActionPreference = 'Stop' +$gateway = 'http://localhost:8080' +$script:failures = 0 + +function Step($ok, $message) { + if ($ok) { Write-Host " OK $message" -ForegroundColor Green } + else { Write-Host " FAIL $message" -ForegroundColor Red; $script:failures++ } +} + +function Poll($description, [scriptblock]$probe, $timeoutSeconds = 90) { + $deadline = (Get-Date).AddSeconds($timeoutSeconds) + while ((Get-Date) -lt $deadline) { + try { $result = & $probe; if ($result) { return $result } } catch { } + Start-Sleep -Seconds 3 + } + return $null +} + +Write-Host "1. Health checks" -ForegroundColor Cyan +foreach ($port in 8080..8086) { + try { $h = Invoke-RestMethod "http://localhost:$port/health" -TimeoutSec 10 } catch { $h = $_.Exception.Message } + Step ($h -eq 'Healthy') "service on :$port is healthy ($h)" +} + +Write-Host "2. Register two users (multipart form via gateway)" -ForegroundColor Cyan +$suffix = -join ((97..122) | Get-Random -Count 6 | ForEach-Object { [char]$_ }) +$userA = "smokea$suffix"; $userB = "smokeb$suffix" +foreach ($name in @($userA, $userB)) { + $status = curl.exe -s -o NUL -w "%{http_code}" -X POST "$gateway/api/identity/register" ` + -F "Email=$name@smoke.test" -F "Password=smoke123" -F "ConfirmPassword=smoke123" ` + -F "UserName=$name" -F "Bio=smoke test user" -F "Birthdate=1995-05-05" ` + -F "CountryId=30" -F "GenderId=1" -F "CityId=5808" + Step ($status -eq '201') "registered $name (HTTP $status)" +} + +Write-Host "3. Confirm emails directly in identity-db (test shortcut for the email loop)" -ForegroundColor Cyan +# SQL goes via stdin so PowerShell does not eat the quoted identifiers. +'UPDATE "AspNetUsers" SET "EmailConfirmed"=true WHERE "Email" LIKE ''%@smoke.test'';' | + docker exec -i lovenet-identity-db psql -U lovenet -d lovenet_identity -q +Step $? "smoke users email-confirmed" + +Write-Host "4. Login both users" -ForegroundColor Cyan +function Login($name) { + Invoke-RestMethod -Uri "$gateway/api/identity/login" -Method Post -ContentType 'application/json' ` + -Body (@{ email = "$name@smoke.test"; password = 'smoke123' } | ConvertTo-Json) +} +$a = Login $userA; $b = Login $userB +Step ($a.token.Length -gt 100) "login $userA -> JWT" +Step ($b.token.Length -gt 100) "login $userB -> JWT" +$headersA = @{ Authorization = "Bearer $($a.token)" } +$headersB = @{ Authorization = "Bearer $($b.token)" } + +Write-Host "5. Event pipeline: profiles propagate into the Matching deck" -ForegroundColor Cyan +$deck = Poll "deck contains $userB" { + $d = Invoke-RestMethod -Uri "$gateway/api/dating" -Headers $headersA + if (($d | Where-Object { $_.id -eq $b.id })) { $d } +} +Step ($null -ne $deck) "$userB appeared in $userA's swipe deck (identity -> profile -> matching events)" + +Write-Host "6. Mutual like creates a match" -ForegroundColor Cyan +$like1 = Invoke-RestMethod -Uri "$gateway/api/dating/like/$($b.id)" -Method Post -Headers $headersA +Step (-not $like1.isMatch) "first like: no match yet" +$like2 = Invoke-RestMethod -Uri "$gateway/api/dating/like/$($a.id)" -Method Post -Headers $headersB +Step ($like2.isMatch -and $like2.user.id -eq $a.id) "reciprocal like: match created, matched user returned" + +$matches = Invoke-RestMethod -Uri "$gateway/api/dating/matches" -Method Post -Headers $headersA ` + -ContentType 'application/json' -Body (@{ userId = $a.id; page = 1 } | ConvertTo-Json) +$roomId = ($matches.matches | Where-Object { $_.id -eq $b.id }).roomId +Step ($matches.totalMatches -ge 1 -and $roomId) "match list returns roomId $roomId" + +Write-Host "7. Chat room provisioned from the match event" -ForegroundColor Cyan +$room = Poll "room row in chat-db" { + $raw = "SELECT count(*) FROM rooms WHERE `"RoomId`"='$roomId';" | + docker exec -i lovenet-chat-db psql -U lovenet -d lovenet_chat -t -A 2>$null + if ($raw -match '^[1-9]') { $true } +} +Step ($null -ne $room) "rooms row exists for $roomId" + +Write-Host "8. Realtime hub end-to-end (join, send, echo) via websocket through the gateway" -ForegroundColor Cyan +$messageText = "smoke $suffix" +docker cp "$PSScriptRoot\hub-smoke.js" lovenet-frontend:/app/hub-smoke.js | Out-Null +$hubOut = docker exec -e GATEWAY_URL=http://gateway:8080 -e "TOKEN=$($a.token)" -e "ROOM_ID=$roomId" -e "MESSAGE_TEXT=$messageText" lovenet-frontend node hub-smoke.js 2>&1 +Step ("$hubOut" -match 'HUB_OK') "hub echoed ReceiveMessage ($hubOut)" + +Write-Host "9. Message persisted by Chat via Kafka consumer" -ForegroundColor Cyan +$history = Poll "message in history" { + $h = Invoke-RestMethod -Uri "$gateway/api/chat" -Method Post -Headers $headersA ` + -ContentType 'application/json' -Body (@{ roomId = $roomId; page = 1 } | ConvertTo-Json) + if ($h.totalMessages -ge 1 -and ($h.messages | Where-Object { $_.text -eq $messageText })) { $h } +} +Step ($null -ne $history) "chat history contains the hub message (durable via chat.message-sent)" + +Write-Host "10. Public chatrooms" -ForegroundColor Cyan +$chatrooms = Invoke-RestMethod -Uri "$gateway/api/chat/chatrooms" -Headers $headersA +Step ($chatrooms.Count -ge 6) "seeded chatrooms returned ($($chatrooms.Count))" + +Write-Host "11. Admin: promote $userA, aggregate stats via gateway fan-out" -ForegroundColor Cyan +"INSERT INTO `"AspNetUserRoles`" (`"UserId`",`"RoleId`") SELECT '$($a.id)', `"Id`" FROM `"AspNetRoles`" WHERE `"Name`"='Administrator' ON CONFLICT DO NOTHING;" | + docker exec -i lovenet-identity-db psql -U lovenet -d lovenet_identity -q +$aAdmin = Login $userA +$stats = Invoke-RestMethod -Uri "$gateway/api/admin/dashboard" -Headers @{ Authorization = "Bearer $($aAdmin.token)" } +Step ($stats.usersCount -ge 8 -and $stats.matchesCount -ge 1 -and $stats.messagesCount -ge 1) ` + "stats aggregated: users=$($stats.usersCount) matches=$($stats.matchesCount) messages=$($stats.messagesCount)" +$unauthStatus = curl.exe -s -o NUL -w "%{http_code}" "$gateway/api/admin/dashboard" -H "Authorization: Bearer $($b.token)" +Step ($unauthStatus -eq '403') "non-admin gets 403 from admin stats" + +Write-Host "" +if ($script:failures -eq 0) { + Write-Host "SMOKE TEST PASSED" -ForegroundColor Green + exit 0 +} +Write-Host "SMOKE TEST FAILED ($script:failures failures)" -ForegroundColor Red +exit 1 diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/EventEnvelope.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/EventEnvelope.cs new file mode 100644 index 0000000..3c8fae1 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/EventEnvelope.cs @@ -0,0 +1,24 @@ +namespace LoveNet.BuildingBlocks.Contracts; + +/// <summary> +/// Wire format for every event on Kafka. <see cref="MessageId"/> is the +/// idempotency key consumers dedupe on; <see cref="CorrelationId"/> carries +/// the originating request id across service boundaries. +/// </summary> +public record EventEnvelope<T>( + Guid MessageId, + string Type, + int Version, + DateTime OccurredOn, + string? CorrelationId, + T Data) +{ + public static EventEnvelope<T> Create(T data, string? correlationId = null) => + new( + Guid.NewGuid(), + typeof(T).Name, + 1, + DateTime.UtcNow, + correlationId, + data); +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/Events/ChatEvents.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/Events/ChatEvents.cs new file mode 100644 index 0000000..f14eb2c --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/Events/ChatEvents.cs @@ -0,0 +1,26 @@ +namespace LoveNet.BuildingBlocks.Contracts.Events; + +/// <summary> +/// Published by Realtime after a hub client sends a message; consumed by Chat +/// for persistence. Id is assigned server-side in the hub and doubles as the +/// idempotency key. Key: roomId (per-room ordering). +/// </summary> +public record MessageSentEvent( + string Id, + string RoomId, + string SenderId, + string SenderUserName, + string? SenderAvatarUrl, + string? Text, + string? ImageUrl, + DateTime CreatedOn); + +/// <summary> +/// Published by Chat when a room becomes joinable (match room or seeded public +/// chatroom); consumed by Realtime for join authorization. Key: roomId. +/// </summary> +public record RoomProvisionedEvent( + string RoomId, + bool IsPublic, + IReadOnlyList<string> MemberIds, + string? Name); diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/Events/IdentityEvents.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/Events/IdentityEvents.cs new file mode 100644 index 0000000..3eff30b --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/Events/IdentityEvents.cs @@ -0,0 +1,19 @@ +namespace LoveNet.BuildingBlocks.Contracts.Events; + +/// <summary>Published by Identity when registration succeeds. Key: userId.</summary> +public record UserRegisteredEvent( + string UserId, + string Email, + string UserName, + string? Bio, + DateTime Birthdate, + int GenderId, + int CityId, + int CountryId, + IReadOnlyList<string> ImageUrls, + bool IsAdmin = false); + +/// <summary>Published by Identity on moderation. BannedUntil null = unbanned. Key: userId.</summary> +public record UserBannedEvent( + string UserId, + DateTimeOffset? BannedUntil); diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/Events/MatchingEvents.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/Events/MatchingEvents.cs new file mode 100644 index 0000000..86ba46b --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/Events/MatchingEvents.cs @@ -0,0 +1,19 @@ +namespace LoveNet.BuildingBlocks.Contracts.Events; + +public record MatchedUser( + string Id, + string UserName, + string Email, + string? AvatarUrl); + +/// <summary> +/// Published by Matching when a reciprocal like creates a match. +/// RoomId is the deterministic 1:1 room id (the two user GUIDs sorted +/// ascending and concatenated). Key: roomId. +/// </summary> +public record MatchCreatedEvent( + string MatchId, + string RoomId, + MatchedUser UserA, + MatchedUser UserB, + DateTime CreatedOn); diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/Events/NotificationEvents.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/Events/NotificationEvents.cs new file mode 100644 index 0000000..254c827 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/Events/NotificationEvents.cs @@ -0,0 +1,16 @@ +namespace LoveNet.BuildingBlocks.Contracts.Events; + +public enum EmailType +{ + ConfirmEmail, + ResetPassword, +} + +/// <summary> +/// Published by Identity when a transactional email must go out; consumed by +/// Notifications, which owns templates and the SendGrid integration. Key: userId. +/// </summary> +public record EmailRequestedEvent( + EmailType EmailType, + string ToEmail, + string Link); diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/Events/ProfileEvents.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/Events/ProfileEvents.cs new file mode 100644 index 0000000..6541fbb --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/Events/ProfileEvents.cs @@ -0,0 +1,25 @@ +namespace LoveNet.BuildingBlocks.Contracts.Events; + +/// <summary> +/// Published by Profile whenever a profile is created or changes. Enriched with +/// geo names/coordinates and image URLs so consumers need no reference-data joins. +/// Key: userId. +/// </summary> +public record UserUpdatedEvent( + string UserId, + string Email, + string UserName, + string? Bio, + DateTime Birthdate, + int GenderId, + string? GenderName, + int CityId, + string? CityName, + double? Latitude, + double? Longitude, + int CountryId, + string? CountryName, + string? AvatarUrl, + IReadOnlyList<string> ImageUrls, + bool IsBanned, + bool IsAdmin = false); diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/KafkaTopics.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/KafkaTopics.cs new file mode 100644 index 0000000..8ae3608 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/KafkaTopics.cs @@ -0,0 +1,15 @@ +namespace LoveNet.BuildingBlocks.Contracts; + +public static class KafkaTopics +{ + public const string UserRegistered = "identity.user-registered"; + public const string UserBanned = "identity.user-banned"; + public const string UserUpdated = "profile.user-updated"; + public const string MatchCreated = "matching.match-created"; + public const string MessageSent = "chat.message-sent"; + public const string RoomProvisioned = "chat.room-provisioned"; + public const string EmailRequested = "notifications.email-requested"; + + /// <summary>Poison messages land here after bounded consumer retries.</summary> + public const string DeadLetter = "dead-letter"; +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/LoveNet.BuildingBlocks.Contracts.csproj b/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/LoveNet.BuildingBlocks.Contracts.csproj new file mode 100644 index 0000000..fa71b7a --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Contracts/LoveNet.BuildingBlocks.Contracts.csproj @@ -0,0 +1,9 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + +</Project> diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/Consuming/KafkaConsumerService.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/Consuming/KafkaConsumerService.cs new file mode 100644 index 0000000..fc51189 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/Consuming/KafkaConsumerService.cs @@ -0,0 +1,161 @@ +using System.Text; +using System.Text.Json; +using Confluent.Kafka; +using LoveNet.BuildingBlocks.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace LoveNet.BuildingBlocks.EventBus.Consuming; + +/// <summary> +/// Base class for one topic/one event-type Kafka consumers. Manual commits, +/// bounded retries with exponential backoff, then dead-letter and move on so +/// a poison message never stalls the partition. +/// </summary> +public abstract class KafkaConsumerService<TEvent> : BackgroundService +{ + private static readonly TimeSpan[] RetryDelays = + { + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(4), + }; + + private readonly IServiceScopeFactory scopeFactory; + private readonly IProducer<string, string> producer; + private readonly KafkaOptions options; + + protected KafkaConsumerService( + IServiceScopeFactory scopeFactory, + IProducer<string, string> producer, + IOptions<KafkaOptions> options, + ILogger logger, + string topic, + string groupId) + { + this.scopeFactory = scopeFactory; + this.producer = producer; + this.options = options.Value; + Logger = logger; + Topic = topic; + GroupId = groupId; + } + + protected ILogger Logger { get; } + + protected string Topic { get; } + + protected string GroupId { get; } + + /// <summary> + /// Handles one envelope inside a fresh DI scope. Implementations are + /// responsible for idempotency (see TryRegisterInboxAsync). + /// </summary> + protected abstract Task HandleAsync(IServiceProvider services, EventEnvelope<TEvent> envelope, CancellationToken cancellationToken); + + protected override Task ExecuteAsync(CancellationToken stoppingToken) => + Task.Run(() => ConsumeLoopAsync(stoppingToken), stoppingToken); + + private async Task ConsumeLoopAsync(CancellationToken stoppingToken) + { + var config = new ConsumerConfig + { + BootstrapServers = options.BootstrapServers, + GroupId = GroupId, + EnableAutoCommit = false, + AutoOffsetReset = AutoOffsetReset.Earliest, + AllowAutoCreateTopics = false, + }; + + using var consumer = new ConsumerBuilder<string, string>(config) + .SetErrorHandler((_, e) => Logger.LogWarning("Kafka consumer error on {Topic}: {Reason}", Topic, e.Reason)) + .Build(); + + consumer.Subscribe(Topic); + Logger.LogInformation("Consuming {Topic} as {GroupId}", Topic, GroupId); + + while (!stoppingToken.IsCancellationRequested) + { + ConsumeResult<string, string>? result = null; + + try + { + result = consumer.Consume(stoppingToken); + await ProcessAsync(result, stoppingToken); + consumer.Commit(result); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (ConsumeException ex) + { + Logger.LogError(ex, "Consume failed on {Topic}", Topic); + } + catch (Exception ex) + { + await DeadLetterAsync(result, ex, stoppingToken); + if (result is not null) + { + consumer.Commit(result); + } + } + } + + consumer.Close(); + } + + private async Task ProcessAsync(ConsumeResult<string, string> result, CancellationToken cancellationToken) + { + var envelope = JsonSerializer.Deserialize<EventEnvelope<TEvent>>(result.Message.Value, JsonDefaults.Options) + ?? throw new InvalidOperationException($"Message on {Topic} is not a valid envelope"); + + for (var attempt = 0; ; attempt++) + { + try + { + using var scope = scopeFactory.CreateScope(); + await HandleAsync(scope.ServiceProvider, envelope, cancellationToken); + return; + } + catch (Exception ex) when (attempt < RetryDelays.Length && ex is not OperationCanceledException) + { + Logger.LogWarning(ex, "Handling {MessageId} from {Topic} failed (attempt {Attempt}), retrying", envelope.MessageId, Topic, attempt + 1); + await Task.Delay(RetryDelays[attempt], cancellationToken); + } + } + } + + private async Task DeadLetterAsync(ConsumeResult<string, string>? result, Exception exception, CancellationToken cancellationToken) + { + if (result is null) + { + return; + } + + Logger.LogError(exception, "Dead-lettering message from {Topic} (key {Key})", Topic, result.Message.Key); + + var deadLetter = new Message<string, string> + { + Key = result.Message.Key, + Value = result.Message.Value, + Headers = new Headers + { + { "origin-topic", Encoding.UTF8.GetBytes(Topic) }, + { "consumer-group", Encoding.UTF8.GetBytes(GroupId) }, + { "error", Encoding.UTF8.GetBytes(exception.Message) }, + }, + }; + + try + { + await producer.ProduceAsync(KafkaTopics.DeadLetter, deadLetter, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.LogError(ex, "Failed to dead-letter message from {Topic}; skipping it", Topic); + } + } +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/EventBusDbContextExtensions.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/EventBusDbContextExtensions.cs new file mode 100644 index 0000000..37523a1 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/EventBusDbContextExtensions.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.EventBus.Inbox; +using LoveNet.BuildingBlocks.EventBus.Outbox; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.BuildingBlocks.EventBus; + +public static class EventBusDbContextExtensions +{ + /// <summary>Maps the outbox_messages table. Call from OnModelCreating.</summary> + public static ModelBuilder AddOutbox(this ModelBuilder modelBuilder) + { + modelBuilder.Entity<OutboxMessage>(entity => + { + entity.ToTable("outbox_messages"); + entity.HasKey(m => m.Id); + entity.HasIndex(m => m.ProcessedOn); + entity.Property(m => m.Topic).HasMaxLength(128); + entity.Property(m => m.Key).HasMaxLength(128); + }); + + return modelBuilder; + } + + /// <summary>Maps the inbox_messages table. Call from OnModelCreating.</summary> + public static ModelBuilder AddInbox(this ModelBuilder modelBuilder) + { + modelBuilder.Entity<InboxMessage>(entity => + { + entity.ToTable("inbox_messages"); + entity.HasKey(m => new { m.MessageId, m.Consumer }); + entity.Property(m => m.Consumer).HasMaxLength(128); + }); + + return modelBuilder; + } + + /// <summary> + /// Queues an event in the outbox as part of the current unit of work. + /// Becomes visible to the publisher only when SaveChanges commits. + /// </summary> + public static void AddOutboxMessage<T>(this DbContext db, string topic, string key, T data, string? correlationId = null) + { + var envelope = EventEnvelope<T>.Create(data, correlationId); + + db.Set<OutboxMessage>().Add(new OutboxMessage + { + Id = envelope.MessageId, + Topic = topic, + Key = key, + Payload = JsonSerializer.Serialize(envelope, JsonDefaults.Options), + OccurredOn = envelope.OccurredOn, + }); + } + + /// <summary> + /// Returns false when the envelope was already processed by this consumer. + /// Otherwise registers it; the row commits atomically with the handler's + /// SaveChanges, and a concurrent duplicate fails on the composite PK. + /// </summary> + public static async Task<bool> TryRegisterInboxAsync(this DbContext db, Guid messageId, string consumer, CancellationToken cancellationToken = default) + { + var alreadyProcessed = await db.Set<InboxMessage>() + .AnyAsync(m => m.MessageId == messageId && m.Consumer == consumer, cancellationToken); + + if (alreadyProcessed) + { + return false; + } + + db.Set<InboxMessage>().Add(new InboxMessage + { + MessageId = messageId, + Consumer = consumer, + ProcessedOn = DateTime.UtcNow, + }); + + return true; + } +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/IEventPublisher.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/IEventPublisher.cs new file mode 100644 index 0000000..311eca0 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/IEventPublisher.cs @@ -0,0 +1,11 @@ +namespace LoveNet.BuildingBlocks.EventBus; + +/// <summary> +/// Direct (non-outbox) publish. Use only from services without a database of +/// their own (e.g. Realtime); everywhere else prefer the transactional outbox. +/// </summary> +public interface IEventPublisher +{ + /// <summary>Awaits broker acknowledgement (acks=all) before returning.</summary> + Task PublishAsync<T>(string topic, string key, T data, string? correlationId = null, CancellationToken cancellationToken = default); +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/Inbox/InboxMessage.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/Inbox/InboxMessage.cs new file mode 100644 index 0000000..a6199b3 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/Inbox/InboxMessage.cs @@ -0,0 +1,15 @@ +namespace LoveNet.BuildingBlocks.EventBus.Inbox; + +/// <summary> +/// Consumer-side dedupe record: one row per processed envelope MessageId. +/// Inserted in the same transaction as the event's side effects, so a +/// redelivered message either sees the row (skip) or conflicts on the PK. +/// </summary> +public class InboxMessage +{ + public Guid MessageId { get; set; } + + public string Consumer { get; set; } = null!; + + public DateTime ProcessedOn { get; set; } +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/JsonDefaults.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/JsonDefaults.cs new file mode 100644 index 0000000..8602bc6 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/JsonDefaults.cs @@ -0,0 +1,12 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace LoveNet.BuildingBlocks.EventBus; + +public static class JsonDefaults +{ + public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() }, + }; +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/KafkaEventPublisher.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/KafkaEventPublisher.cs new file mode 100644 index 0000000..7548c48 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/KafkaEventPublisher.cs @@ -0,0 +1,27 @@ +using System.Text.Json; +using Confluent.Kafka; +using LoveNet.BuildingBlocks.Contracts; + +namespace LoveNet.BuildingBlocks.EventBus; + +public class KafkaEventPublisher : IEventPublisher +{ + private readonly IProducer<string, string> producer; + + public KafkaEventPublisher(IProducer<string, string> producer) + { + this.producer = producer; + } + + public async Task PublishAsync<T>(string topic, string key, T data, string? correlationId = null, CancellationToken cancellationToken = default) + { + var envelope = EventEnvelope<T>.Create(data, correlationId); + var message = new Message<string, string> + { + Key = key, + Value = JsonSerializer.Serialize(envelope, JsonDefaults.Options), + }; + + await producer.ProduceAsync(topic, message, cancellationToken); + } +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/KafkaOptions.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/KafkaOptions.cs new file mode 100644 index 0000000..19f4395 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/KafkaOptions.cs @@ -0,0 +1,8 @@ +namespace LoveNet.BuildingBlocks.EventBus; + +public class KafkaOptions +{ + public const string SectionName = "Kafka"; + + public string BootstrapServers { get; set; } = "localhost:29092"; +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/LoveNet.BuildingBlocks.EventBus.csproj b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/LoveNet.BuildingBlocks.EventBus.csproj new file mode 100644 index 0000000..2a961a3 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/LoveNet.BuildingBlocks.EventBus.csproj @@ -0,0 +1,21 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <ItemGroup> + <ProjectReference Include="..\LoveNet.BuildingBlocks.Contracts\LoveNet.BuildingBlocks.Contracts.csproj" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Confluent.Kafka" Version="2.5.3" /> + <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.11" /> + <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" /> + <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" /> + </ItemGroup> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + +</Project> diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/Outbox/OutboxMessage.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/Outbox/OutboxMessage.cs new file mode 100644 index 0000000..9549593 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/Outbox/OutboxMessage.cs @@ -0,0 +1,26 @@ +namespace LoveNet.BuildingBlocks.EventBus.Outbox; + +/// <summary> +/// Transactional outbox row. Written in the same transaction as the business +/// change; <see cref="OutboxPublisher{TContext}"/> relays it to Kafka. +/// </summary> +public class OutboxMessage +{ + /// <summary>Equals the envelope's MessageId.</summary> + public Guid Id { get; set; } + + public string Topic { get; set; } = null!; + + public string Key { get; set; } = null!; + + /// <summary>The full serialized <c>EventEnvelope<T></c>.</summary> + public string Payload { get; set; } = null!; + + public DateTime OccurredOn { get; set; } + + public DateTime? ProcessedOn { get; set; } + + public int Attempts { get; set; } + + public string? LastError { get; set; } +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/Outbox/OutboxPublisher.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/Outbox/OutboxPublisher.cs new file mode 100644 index 0000000..474d3de --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/Outbox/OutboxPublisher.cs @@ -0,0 +1,103 @@ +using Confluent.Kafka; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace LoveNet.BuildingBlocks.EventBus.Outbox; + +/// <summary> +/// Polls the host service's outbox table and relays pending rows to Kafka in +/// OccurredOn order. Stops the batch on the first failure so per-key ordering +/// is never violated by skipping ahead. Designed for a single publisher +/// instance per service; scaling out would require row locking. +/// </summary> +public class OutboxPublisher<TContext> : BackgroundService + where TContext : DbContext +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(500); + private const int BatchSize = 50; + + private readonly IServiceScopeFactory scopeFactory; + private readonly IProducer<string, string> producer; + private readonly ILogger<OutboxPublisher<TContext>> logger; + + public OutboxPublisher( + IServiceScopeFactory scopeFactory, + IProducer<string, string> producer, + ILogger<OutboxPublisher<TContext>> logger) + { + this.scopeFactory = scopeFactory; + this.producer = producer; + this.logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(PollInterval); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await PublishPendingAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Outbox polling iteration failed"); + } + + try + { + await timer.WaitForNextTickAsync(stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + } + + private async Task PublishPendingAsync(CancellationToken cancellationToken) + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService<TContext>(); + + var pending = await db.Set<OutboxMessage>() + .Where(m => m.ProcessedOn == null) + .OrderBy(m => m.OccurredOn) + .Take(BatchSize) + .ToListAsync(cancellationToken); + + if (pending.Count == 0) + { + return; + } + + foreach (var message in pending) + { + try + { + await producer.ProduceAsync( + message.Topic, + new Message<string, string> { Key = message.Key, Value = message.Payload }, + cancellationToken); + + message.ProcessedOn = DateTime.UtcNow; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + message.Attempts++; + message.LastError = ex.Message; + logger.LogError(ex, "Failed to publish outbox message {MessageId} to {Topic} (attempt {Attempts})", message.Id, message.Topic, message.Attempts); + break; + } + } + + await db.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/ServiceCollectionExtensions.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..617eb7d --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.EventBus/ServiceCollectionExtensions.cs @@ -0,0 +1,41 @@ +using Confluent.Kafka; +using LoveNet.BuildingBlocks.EventBus.Outbox; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace LoveNet.BuildingBlocks.EventBus; + +public static class ServiceCollectionExtensions +{ + /// <summary> + /// Registers the shared idempotent Kafka producer and <see cref="IEventPublisher"/>. + /// </summary> + public static IServiceCollection AddKafkaEventBus(this IServiceCollection services, IConfiguration configuration) + { + services.Configure<KafkaOptions>(configuration.GetSection(KafkaOptions.SectionName)); + + services.AddSingleton<IProducer<string, string>>(sp => + { + var options = sp.GetRequiredService<IOptions<KafkaOptions>>().Value; + var config = new ProducerConfig + { + BootstrapServers = options.BootstrapServers, + EnableIdempotence = true, + Acks = Acks.All, + }; + + return new ProducerBuilder<string, string>(config).Build(); + }); + + services.AddSingleton<IEventPublisher, KafkaEventPublisher>(); + + return services; + } + + /// <summary>Starts the outbox relay for the service's DbContext.</summary> + public static IServiceCollection AddOutboxPublisher<TContext>(this IServiceCollection services) + where TContext : DbContext + => services.AddHostedService<OutboxPublisher<TContext>>(); +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Media/IImagesService.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Media/IImagesService.cs new file mode 100644 index 0000000..6c235b0 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Media/IImagesService.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Http; + +namespace LoveNet.BuildingBlocks.Media; + +public interface IImagesService +{ + Task<string> UploadImageAsync(IFormFile image); + + Task<IEnumerable<string>> UploadImagesAsync(IEnumerable<IFormFile> images); +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Media/ImagesService.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Media/ImagesService.cs new file mode 100644 index 0000000..211ae51 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Media/ImagesService.cs @@ -0,0 +1,46 @@ +using CloudinaryDotNet; +using CloudinaryDotNet.Actions; +using Microsoft.AspNetCore.Http; + +namespace LoveNet.BuildingBlocks.Media; + +/// <summary> +/// Cloudinary-backed image storage, ported from the monolith's Images/ImagesService. +/// The client is created on first actual upload so hosts (and flows that carry +/// no images) run without Cloudinary credentials. +/// </summary> +public class ImagesService : IImagesService +{ + private readonly Lazy<Cloudinary> cloudinary; + + public ImagesService(Lazy<Cloudinary> cloudinary) + { + this.cloudinary = cloudinary; + } + + public async Task<string> UploadImageAsync(IFormFile image) + { + using var memoryStream = new MemoryStream(); + await image.CopyToAsync(memoryStream); + memoryStream.Position = 0; + + var uploadParams = new ImageUploadParams + { + File = new FileDescription(Guid.NewGuid().ToString(), memoryStream), + }; + + var result = await cloudinary.Value.UploadAsync(uploadParams); + + return result.SecureUrl.AbsoluteUri; + } + + public async Task<IEnumerable<string>> UploadImagesAsync(IEnumerable<IFormFile> images) + { + var uploads = images + .Where(image => image is not null) + .Select(UploadImageAsync) + .ToArray(); + + return await Task.WhenAll(uploads); + } +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Media/LoveNet.BuildingBlocks.Media.csproj b/src/BuildingBlocks/LoveNet.BuildingBlocks.Media/LoveNet.BuildingBlocks.Media.csproj new file mode 100644 index 0000000..a5117b7 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Media/LoveNet.BuildingBlocks.Media.csproj @@ -0,0 +1,17 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + + <ItemGroup> + <FrameworkReference Include="Microsoft.AspNetCore.App" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="CloudinaryDotNet" Version="1.26.0" /> + </ItemGroup> + +</Project> diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Media/MediaExtensions.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Media/MediaExtensions.cs new file mode 100644 index 0000000..67a9f9d --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Media/MediaExtensions.cs @@ -0,0 +1,34 @@ +using CloudinaryDotNet; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace LoveNet.BuildingBlocks.Media; + +public static class MediaExtensions +{ + /// <summary> + /// Registers <see cref="IImagesService"/> (config section "Cloudinary"). + /// The Cloudinary client is deferred behind a Lazy so hosts start — and + /// image-less flows run — without credentials; only an actual upload + /// attempt fails loudly when the section is empty. + /// </summary> + public static IServiceCollection AddCloudinaryMedia(this IServiceCollection services, IConfiguration configuration) + { + services.AddSingleton(_ => new Lazy<Cloudinary>(() => + { + var account = new Account( + configuration["Cloudinary:CloudName"], + configuration["Cloudinary:Key"], + configuration["Cloudinary:Secret"]); + + var cloudinary = new Cloudinary(account); + cloudinary.Api.Secure = true; + + return cloudinary; + })); + + services.AddSingleton<IImagesService, ImagesService>(); + + return services; + } +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Auth/ClaimsPrincipalExtensions.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Auth/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000..dea9c22 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Auth/ClaimsPrincipalExtensions.cs @@ -0,0 +1,15 @@ +using System.Security.Claims; + +namespace LoveNet.BuildingBlocks.Web.Auth; + +public static class ClaimsPrincipalExtensions +{ + /// <summary>The authenticated user id (NameIdentifier claim from the Identity-issued JWT).</summary> + public static string GetUserId(this ClaimsPrincipal user) => + user.FindFirstValue(ClaimTypes.NameIdentifier) + ?? throw new InvalidOperationException("Authenticated principal has no NameIdentifier claim"); + + public static string GetUserName(this ClaimsPrincipal user) => + user.FindFirstValue(ClaimTypes.Name) + ?? throw new InvalidOperationException("Authenticated principal has no Name claim"); +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Auth/JwtAuthenticationExtensions.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Auth/JwtAuthenticationExtensions.cs new file mode 100644 index 0000000..a7378b4 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Auth/JwtAuthenticationExtensions.cs @@ -0,0 +1,62 @@ +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; + +namespace LoveNet.BuildingBlocks.Web.Auth; + +public static class JwtAuthenticationExtensions +{ + /// <summary> + /// Shared-key HS512 bearer validation, identical across every service so a + /// token issued by Identity is accepted everywhere. Also lifts the token + /// from the "access_token" query parameter for SignalR websocket requests. + /// </summary> + public static IServiceCollection AddLoveNetJwt(this IServiceCollection services, IConfiguration configuration) + { + var jwt = configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() + ?? throw new InvalidOperationException($"Missing '{JwtOptions.SectionName}' configuration section"); + + services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName)); + + services.AddAuthentication(auth => + { + auth.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + auth.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }).AddJwtBearer(options => + { + options.RequireHttpsMetadata = false; + options.SaveToken = true; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidAudience = jwt.Audience, + ValidIssuer = jwt.Issuer, + RequireExpirationTime = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Key)), + ValidateIssuerSigningKey = true, + ValidateLifetime = true, + }; + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + if (!string.IsNullOrEmpty(accessToken) && + context.HttpContext.Request.Path.StartsWithSegments("/chat")) + { + context.Token = accessToken; + } + + return Task.CompletedTask; + }, + }; + }); + + services.AddAuthorization(); + + return services; + } +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Auth/JwtOptions.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Auth/JwtOptions.cs new file mode 100644 index 0000000..fb82787 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Auth/JwtOptions.cs @@ -0,0 +1,12 @@ +namespace LoveNet.BuildingBlocks.Web.Auth; + +public class JwtOptions +{ + public const string SectionName = "Jwt"; + + public string Key { get; set; } = null!; + + public string Issuer { get; set; } = null!; + + public string Audience { get; set; } = null!; +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/DateHelper.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/DateHelper.cs new file mode 100644 index 0000000..3f8263c --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/DateHelper.cs @@ -0,0 +1,17 @@ +namespace LoveNet.BuildingBlocks.Web; + +public static class DateHelper +{ + public static int CalculateAge(DateTime birthdate) + { + var today = DateTime.UtcNow.Date; + var age = today.Year - birthdate.Year; + + if (birthdate.Date > today.AddYears(-age)) + { + age--; + } + + return age; + } +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/HealthCheckExtensions.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/HealthCheckExtensions.cs new file mode 100644 index 0000000..77e86a5 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/HealthCheckExtensions.cs @@ -0,0 +1,50 @@ +using Confluent.Kafka; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace LoveNet.BuildingBlocks.Web; + +public static class HealthCheckExtensions +{ + /// <summary> + /// Registers /health checks for the dependencies a service actually has. + /// Connection string name: "Default"; Kafka: "Kafka:BootstrapServers"; + /// Redis: "Redis:Connection". + /// </summary> + public static IServiceCollection AddLoveNetHealthChecks( + this IServiceCollection services, + IConfiguration configuration, + bool postgres = false, + bool kafka = false, + bool redis = false) + { + var healthChecks = services.AddHealthChecks(); + + if (postgres) + { + healthChecks.AddNpgSql(configuration.GetConnectionString("Default")!); + } + + if (kafka) + { + healthChecks.AddKafka(new ProducerConfig + { + BootstrapServers = configuration["Kafka:BootstrapServers"], + }); + } + + if (redis) + { + healthChecks.AddRedis(configuration["Redis:Connection"]!); + } + + return services; + } + + public static WebApplication MapLoveNetHealth(this WebApplication app) + { + app.MapHealthChecks("/health"); + return app; + } +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/HostingExtensions.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/HostingExtensions.cs new file mode 100644 index 0000000..bb6fad6 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/HostingExtensions.cs @@ -0,0 +1,97 @@ +using LoveNet.BuildingBlocks.Web.Middleware; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Npgsql; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using Serilog; +using Serilog.Formatting.Compact; + +namespace LoveNet.BuildingBlocks.Web; + +public static class HostingExtensions +{ + /// <summary> + /// Cross-cutting defaults every LoveNet service shares: structured Serilog + /// logging, OpenTelemetry traces/metrics (OTLP export when + /// OTEL_EXPORTER_OTLP_ENDPOINT is set), and ProblemDetails error responses. + /// </summary> + public static WebApplicationBuilder AddLoveNetDefaults(this WebApplicationBuilder builder, string serviceName) + { + // Entities ported from the monolith mix DateTime kinds (seed data, + // birthdates); legacy behavior maps them to "timestamp without time + // zone" like the monolith did. Harmless for services without Npgsql. + AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); + + builder.Host.UseSerilog((context, loggerConfiguration) => + { + loggerConfiguration + .ReadFrom.Configuration(context.Configuration) + .Enrich.FromLogContext() + .Enrich.WithProperty("Service", serviceName); + + if (context.HostingEnvironment.IsDevelopment()) + { + loggerConfiguration.WriteTo.Console(); + } + else + { + loggerConfiguration.WriteTo.Console(new RenderedCompactJsonFormatter()); + } + }); + + var openTelemetry = builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService(serviceName)) + .WithTracing(tracing => tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddNpgsql()) + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation()); + + if (!string.IsNullOrEmpty(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"])) + { + openTelemetry + .WithTracing(tracing => tracing.AddOtlpExporter()) + .WithMetrics(metrics => metrics.AddOtlpExporter()); + } + + builder.Services.AddProblemDetails(); + + return builder; + } + + /// <summary>Standard early pipeline: exception handling → request logging → correlation id.</summary> + public static WebApplication UseLoveNetDefaults(this WebApplication app) + { + app.UseExceptionHandler(); + app.UseSerilogRequestLogging(); + app.UseMiddleware<CorrelationIdMiddleware>(); + + return app; + } + + /// <summary> + /// Single CORS policy for the SPA origin (config key "Frontend:Origin"). + /// Credentials are required for the refresh-token cookie and SignalR. + /// </summary> + public static IServiceCollection AddFrontendCors(this IServiceCollection services, IConfiguration configuration) + { + var origin = configuration["Frontend:Origin"] ?? "http://localhost:3000"; + + services.AddCors(options => + { + options.AddPolicy("Frontend", policy => policy + .WithOrigins(origin) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials()); + }); + + return services; + } +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/LoveNet.BuildingBlocks.Web.csproj b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/LoveNet.BuildingBlocks.Web.csproj new file mode 100644 index 0000000..fd2773a --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/LoveNet.BuildingBlocks.Web.csproj @@ -0,0 +1,26 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + + <ItemGroup> + <FrameworkReference Include="Microsoft.AspNetCore.App" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="AspNetCore.HealthChecks.Kafka" Version="8.0.1" /> + <PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" /> + <PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" /> + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" /> + <PackageReference Include="Npgsql.OpenTelemetry" Version="8.0.5" /> + <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" /> + <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.9.0" /> + <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.9.0" /> + <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.9.0" /> + <PackageReference Include="Serilog.AspNetCore" Version="8.0.3" /> + </ItemGroup> + +</Project> diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/LoveNetConstants.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/LoveNetConstants.cs new file mode 100644 index 0000000..b40a914 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/LoveNetConstants.cs @@ -0,0 +1,16 @@ +namespace LoveNet.BuildingBlocks.Web; + +/// <summary>Constants shared by more than one service. Service-specific +/// messages/routes live inside the owning service.</summary> +public static class LoveNetConstants +{ + public const string AdministratorRoleName = "Administrator"; + + public const int DefaultTake = 10; + + public const int MinimalAge = 18; + + public const int MaxFileSizeInBytes = 20 * 1024 * 1024; + + public const string DefaultProfilePictureUrl = "https://res.cloudinary.com/dojl8gfnd/image/upload/v1666714042/24-248253_user-profile-default-image-png-clipart-png-download_tuamuf.png"; +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Middleware/CorrelationIdMiddleware.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Middleware/CorrelationIdMiddleware.cs new file mode 100644 index 0000000..2ad4d3b --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Middleware/CorrelationIdMiddleware.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Http; +using Serilog.Context; + +namespace LoveNet.BuildingBlocks.Web.Middleware; + +/// <summary> +/// Reads (or mints) the X-Correlation-Id header, echoes it on the response and +/// pushes it into the Serilog log context. The value also rides along in Kafka +/// event envelopes so a request can be traced across services. +/// </summary> +public class CorrelationIdMiddleware +{ + public const string HeaderName = "X-Correlation-Id"; + + private readonly RequestDelegate next; + + public CorrelationIdMiddleware(RequestDelegate next) + { + this.next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + var correlationId = context.Request.Headers.TryGetValue(HeaderName, out var incoming) && !string.IsNullOrWhiteSpace(incoming) + ? incoming.ToString() + : Guid.NewGuid().ToString("N"); + + context.Items[HeaderName] = correlationId; + context.Response.Headers[HeaderName] = correlationId; + + using (LogContext.PushProperty("CorrelationId", correlationId)) + { + await next(context); + } + } +} + +public static class CorrelationIdHttpContextExtensions +{ + public static string? GetCorrelationId(this HttpContext context) => + context.Items[CorrelationIdMiddleware.HeaderName] as string; +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Result.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Result.cs new file mode 100644 index 0000000..63e9e59 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Result.cs @@ -0,0 +1,17 @@ +namespace LoveNet.BuildingBlocks.Web; + +/// <summary>Lightweight success/error result carried up to controllers.</summary> +public class Result +{ + public bool Succeeded { get; private set; } + + public bool Failure => !Succeeded; + + public string[] Errors { get; private set; } = Array.Empty<string>(); + + public static implicit operator Result(bool succeeded) + => new() { Succeeded = succeeded }; + + public static implicit operator Result(string error) + => new() { Succeeded = false, Errors = new[] { error } }; +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Validation/AllowedFileExtensionsAttribute.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Validation/AllowedFileExtensionsAttribute.cs new file mode 100644 index 0000000..f741b7e --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Validation/AllowedFileExtensionsAttribute.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Http; + +namespace LoveNet.BuildingBlocks.Web.Validation; + +public class AllowedFileExtensionsAttribute : ValidationAttribute +{ + private const string Message = "Unsupported file type"; + + private readonly string[] extensions; + + public AllowedFileExtensionsAttribute(string[] extensions) + { + this.extensions = extensions; + } + + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + var files = value switch + { + IFormFile single => new[] { single }, + IEnumerable<IFormFile?> many => many, + _ => Enumerable.Empty<IFormFile?>(), + }; + + return files.Any(f => f is not null && !extensions.Contains(Path.GetExtension(f.FileName).ToLowerInvariant())) + ? new ValidationResult(Message) + : ValidationResult.Success; + } +} diff --git a/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Validation/MaxFileSizeAttribute.cs b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Validation/MaxFileSizeAttribute.cs new file mode 100644 index 0000000..cc76f25 --- /dev/null +++ b/src/BuildingBlocks/LoveNet.BuildingBlocks.Web/Validation/MaxFileSizeAttribute.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Http; + +namespace LoveNet.BuildingBlocks.Web.Validation; + +public class MaxFileSizeAttribute : ValidationAttribute +{ + private const string Message = "Max file size reached"; + + private readonly int maxFileSize; + + public MaxFileSizeAttribute(int maxFileSize) + { + this.maxFileSize = maxFileSize; + } + + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + var files = value switch + { + IFormFile single => new[] { single }, + IEnumerable<IFormFile?> many => many, + _ => Enumerable.Empty<IFormFile?>(), + }; + + return files.Any(f => f is not null && f.Length > maxFileSize) + ? new ValidationResult(Message) + : ValidationResult.Success; + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..2a87413 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,11 @@ +<Project> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + <LangVersion>latest</LangVersion> + <EnableNETAnalyzers>true</EnableNETAnalyzers> + <AnalysisLevel>latest</AnalysisLevel> + <InvariantGlobalization>false</InvariantGlobalization> + </PropertyGroup> +</Project> diff --git a/src/Gateway/LoveNet.Gateway/Controllers/AdminDashboardController.cs b/src/Gateway/LoveNet.Gateway/Controllers/AdminDashboardController.cs new file mode 100644 index 0000000..54bd0d3 --- /dev/null +++ b/src/Gateway/LoveNet.Gateway/Controllers/AdminDashboardController.cs @@ -0,0 +1,91 @@ +using LoveNet.BuildingBlocks.Web; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LoveNet.Gateway.Controllers; + +/// <summary> +/// Admin statistics aggregation. Fans out to each service's gateway-only +/// /internal/stats endpoint and reassembles the monolith's StatisticsViewModel +/// shape. An unreachable service degrades to zeros instead of failing the call. +/// </summary> +[Authorize(Roles = LoveNetConstants.AdministratorRoleName)] +[Route("api/admin/dashboard")] +[ApiController] +public class AdminDashboardController : ControllerBase +{ + private readonly IHttpClientFactory httpClientFactory; + private readonly ILogger<AdminDashboardController> logger; + + public AdminDashboardController( + IHttpClientFactory httpClientFactory, + ILogger<AdminDashboardController> logger) + { + this.httpClientFactory = httpClientFactory; + this.logger = logger; + } + + [HttpGet] + public async Task<ActionResult<StatisticsResponse>> GetStatistics() + { + var identityTask = GetStatsAsync<IdentityStats>("identity"); + var profileTask = GetStatsAsync<ProfileStats>("profile"); + var matchingTask = GetStatsAsync<MatchingStats>("matching"); + var chatTask = GetStatsAsync<ChatStats>("chat"); + + await Task.WhenAll(identityTask, profileTask, matchingTask, chatTask); + + var identity = await identityTask ?? new IdentityStats(0, 0); + var profile = await profileTask ?? new ProfileStats(0); + var matching = await matchingTask ?? new MatchingStats(0, 0); + var chat = await chatTask ?? new ChatStats(0); + + var response = new StatisticsResponse( + UsersCount: identity.UsersCount, + BannedUsersCount: identity.BannedUsersCount, + MatchesCount: matching.MatchesCount, + LikedUsersCount: matching.LikedUsersCount, + NotSwipedUsersCount: identity.UsersCount - matching.LikedUsersCount, + ImagesCount: profile.ImagesCount, + MessagesCount: chat.MessagesCount); + + return Ok(response); + } + + private async Task<TStats?> GetStatsAsync<TStats>(string clientName) + where TStats : class + { + try + { + var client = httpClientFactory.CreateClient(clientName); + + return await client.GetFromJsonAsync<TStats>("/internal/stats", HttpContext.RequestAborted); + } + catch (Exception exception) + { + logger.LogWarning( + exception, + "Failed to fetch /internal/stats from the {Service} service; statistics will report zeros for it", + clientName); + + return null; + } + } + + public sealed record StatisticsResponse( + int UsersCount, + int BannedUsersCount, + int MatchesCount, + int LikedUsersCount, + int NotSwipedUsersCount, + int ImagesCount, + int MessagesCount); + + private sealed record IdentityStats(int UsersCount, int BannedUsersCount); + + private sealed record ProfileStats(int ImagesCount); + + private sealed record MatchingStats(int MatchesCount, int LikedUsersCount); + + private sealed record ChatStats(int MessagesCount); +} diff --git a/src/Gateway/LoveNet.Gateway/Dockerfile b/src/Gateway/LoveNet.Gateway/Dockerfile new file mode 100644 index 0000000..daa3f78 --- /dev/null +++ b/src/Gateway/LoveNet.Gateway/Dockerfile @@ -0,0 +1,17 @@ +# Build context is the repository root. +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY NuGet.config . +COPY src/Directory.Build.props src/ +COPY src/BuildingBlocks/ src/BuildingBlocks/ +COPY src/Gateway/ src/Gateway/ +RUN dotnet publish src/Gateway/LoveNet.Gateway/LoveNet.Gateway.csproj -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +# curl is used by the compose healthcheck. +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +COPY --from=build /app/publish . +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 +ENTRYPOINT ["dotnet", "LoveNet.Gateway.dll"] diff --git a/src/Gateway/LoveNet.Gateway/LoveNet.Gateway.csproj b/src/Gateway/LoveNet.Gateway/LoveNet.Gateway.csproj new file mode 100644 index 0000000..5e6192e --- /dev/null +++ b/src/Gateway/LoveNet.Gateway/LoveNet.Gateway.csproj @@ -0,0 +1,18 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <ItemGroup> + <ProjectReference Include="..\..\BuildingBlocks\LoveNet.BuildingBlocks.Web\LoveNet.BuildingBlocks.Web.csproj" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="8.10.0" /> + <PackageReference Include="Yarp.ReverseProxy" Version="2.2.0" /> + </ItemGroup> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + </PropertyGroup> + +</Project> diff --git a/src/Gateway/LoveNet.Gateway/Program.cs b/src/Gateway/LoveNet.Gateway/Program.cs new file mode 100644 index 0000000..ba9054b --- /dev/null +++ b/src/Gateway/LoveNet.Gateway/Program.cs @@ -0,0 +1,93 @@ +using System.Threading.RateLimiting; +using LoveNet.BuildingBlocks.Web; +using LoveNet.BuildingBlocks.Web.Auth; +using Microsoft.AspNetCore.RateLimiting; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddLoveNetDefaults("gateway"); + +builder.Services.AddLoveNetJwt(builder.Configuration); +builder.Services.AddFrontendCors(builder.Configuration); +builder.Services.AddControllers(); +builder.Services.AddHealthChecks(); + +// Named clients for the admin statistics aggregation. Standard resilience = +// request timeout + retry with backoff + circuit breaker per service. +foreach (var service in new[] { "Identity", "Profile", "Matching", "Chat" }) +{ + var baseAddress = builder.Configuration[$"Services:{service}"] + ?? throw new InvalidOperationException($"Missing 'Services:{service}' configuration value"); + + builder.Services + .AddHttpClient(service.ToLowerInvariant(), client => client.BaseAddress = new Uri(baseAddress)) + .AddStandardResilienceHandler(); +} + +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + // Coarse per-IP ceiling over everything the gateway serves or proxies. + options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context => + RateLimitPartition.GetFixedWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 200, + Window = TimeSpan.FromSeconds(10), + QueueLimit = 0, + })); + + // Tight limit for credential endpoints (login/register brute-force). + options.AddPolicy("auth", context => + RateLimitPartition.GetFixedWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 10, + Window = TimeSpan.FromSeconds(60), + QueueLimit = 0, + })); + + // Swiping burst control: 5-token bucket refilled at 5 tokens/second. + options.AddPolicy("likes", context => + RateLimitPartition.GetTokenBucketLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new TokenBucketRateLimiterOptions + { + TokenLimit = 5, + TokensPerPeriod = 5, + ReplenishmentPeriod = TimeSpan.FromSeconds(1), + QueueLimit = 0, + AutoReplenishment = true, + })); + + options.OnRejected = (context, _) => + { + context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + + return ValueTask.CompletedTask; + }; +}); + +builder.Services.AddReverseProxy() + .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")); + +var app = builder.Build(); + +app.UseLoveNetDefaults(); + +// Downstream services set no CORS headers; the gateway answers for the SPA +// origin on both local endpoints and proxied responses (credentials included +// for the refresh-token cookie and SignalR). +app.UseCors("Frontend"); +app.UseRateLimiter(); +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); +app.MapReverseProxy(); +app.MapHealthChecks("/health"); + +await app.RunAsync(); diff --git a/src/Gateway/LoveNet.Gateway/Properties/launchSettings.json b/src/Gateway/LoveNet.Gateway/Properties/launchSettings.json new file mode 100644 index 0000000..9ebed41 --- /dev/null +++ b/src/Gateway/LoveNet.Gateway/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:8080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Gateway/LoveNet.Gateway/appsettings.Development.json b/src/Gateway/LoveNet.Gateway/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/Gateway/LoveNet.Gateway/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Gateway/LoveNet.Gateway/appsettings.json b/src/Gateway/LoveNet.Gateway/appsettings.json new file mode 100644 index 0000000..7ebee44 --- /dev/null +++ b/src/Gateway/LoveNet.Gateway/appsettings.json @@ -0,0 +1,154 @@ +{ + "Services": { + "Identity": "http://localhost:8081", + "Profile": "http://localhost:8082", + "Matching": "http://localhost:8083", + "Chat": "http://localhost:8084", + "Realtime": "http://localhost:8085" + }, + "ReverseProxy": { + "Routes": { + "auth-login-route": { + "ClusterId": "identity", + "RateLimiterPolicy": "auth", + "Match": { + "Path": "/api/identity/login" + } + }, + "auth-register-route": { + "ClusterId": "identity", + "RateLimiterPolicy": "auth", + "Match": { + "Path": "/api/identity/register" + } + }, + "identity-route": { + "ClusterId": "identity", + "Match": { + "Path": "/api/identity/{**catch-all}" + } + }, + "email-route": { + "ClusterId": "identity", + "Match": { + "Path": "/api/email/{**catch-all}" + } + }, + "admin-moderate-route": { + "ClusterId": "identity", + "Match": { + "Path": "/api/admin/moderate" + } + }, + "profile-route": { + "ClusterId": "profile", + "Match": { + "Path": "/api/profile/{**catch-all}" + } + }, + "countries-route": { + "ClusterId": "profile", + "Match": { + "Path": "/api/countries/{**catch-all}" + } + }, + "genders-route": { + "ClusterId": "profile", + "Match": { + "Path": "/api/genders/{**catch-all}" + } + }, + "admin-users-route": { + "ClusterId": "profile", + "Match": { + "Path": "/api/admin/users" + } + }, + "dating-route": { + "ClusterId": "matching", + "RateLimiterPolicy": "likes", + "Match": { + "Path": "/api/dating/{**catch-all}" + } + }, + "chat-api-route": { + "ClusterId": "chat", + "Match": { + "Path": "/api/chat/{**catch-all}" + } + }, + "hub-route": { + "ClusterId": "realtime", + "Match": { + "Path": "/chat/{**catch-all}" + } + }, + "hub-root-route": { + "ClusterId": "realtime", + "Match": { + "Path": "/chat" + } + } + }, + "Clusters": { + "identity": { + "Destinations": { + "destination1": { + "Address": "http://localhost:8081" + } + } + }, + "profile": { + "Destinations": { + "destination1": { + "Address": "http://localhost:8082" + } + } + }, + "matching": { + "Destinations": { + "destination1": { + "Address": "http://localhost:8083" + } + } + }, + "chat": { + "Destinations": { + "destination1": { + "Address": "http://localhost:8084" + } + } + }, + "realtime": { + "Destinations": { + "destination1": { + "Address": "http://localhost:8085" + } + } + } + } + }, + "Jwt": { + "Key": "lovenet-local-development-signing-key-do-not-use-in-production-0123456789abcdef", + "Issuer": "https://localhost:3000/", + "Audience": "https://localhost:3000/" + }, + "Frontend": { + "Origin": "http://localhost:3000" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning" + } + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/Consumers/MatchCreatedConsumer.cs b/src/Services/Chat/LoveNet.Chat.Api/Consumers/MatchCreatedConsumer.cs new file mode 100644 index 0000000..5b3c343 --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Consumers/MatchCreatedConsumer.cs @@ -0,0 +1,60 @@ +using Confluent.Kafka; +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.EventBus.Consuming; +using LoveNet.Chat.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace LoveNet.Chat.Api.Consumers; + +/// <summary>Provisions the 1:1 room for a new match and announces it to Realtime.</summary> +public class MatchCreatedConsumer : KafkaConsumerService<MatchCreatedEvent> +{ + private const string ConsumerName = "chat-service"; + + public MatchCreatedConsumer( + IServiceScopeFactory scopeFactory, + IProducer<string, string> producer, + IOptions<KafkaOptions> options, + ILogger<MatchCreatedConsumer> logger) + : base(scopeFactory, producer, options, logger, KafkaTopics.MatchCreated, ConsumerName) + { + } + + protected override async Task HandleAsync(IServiceProvider services, EventEnvelope<MatchCreatedEvent> envelope, CancellationToken cancellationToken) + { + var db = services.GetRequiredService<ChatDbContext>(); + + if (!await db.TryRegisterInboxAsync(envelope.MessageId, ConsumerName, cancellationToken)) + { + return; + } + + var data = envelope.Data; + + if (!await db.Rooms.AnyAsync(r => r.RoomId == data.RoomId, cancellationToken)) + { + db.Rooms.Add(new Room + { + RoomId = data.RoomId, + UserAId = data.UserA.Id, + UserBId = data.UserB.Id, + CreatedOn = data.CreatedOn, + }); + + db.AddOutboxMessage( + KafkaTopics.RoomProvisioned, + data.RoomId, + new RoomProvisionedEvent( + data.RoomId, + IsPublic: false, + new[] { data.UserA.Id, data.UserB.Id }, + Name: null), + envelope.CorrelationId); + } + + await db.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/Consumers/MessageSentConsumer.cs b/src/Services/Chat/LoveNet.Chat.Api/Consumers/MessageSentConsumer.cs new file mode 100644 index 0000000..0a2933e --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Consumers/MessageSentConsumer.cs @@ -0,0 +1,46 @@ +using Confluent.Kafka; +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.EventBus.Consuming; +using LoveNet.Chat.Api.Data; +using LoveNet.Chat.Api.Services; +using Microsoft.Extensions.Options; + +namespace LoveNet.Chat.Api.Consumers; + +/// <summary> +/// Persists messages published by the Realtime hub. Idempotent by message id +/// (the hub assigns it before producing), so Kafka redelivery is harmless. +/// </summary> +public class MessageSentConsumer : KafkaConsumerService<MessageSentEvent> +{ + private const string ConsumerName = "chat-service"; + + public MessageSentConsumer( + IServiceScopeFactory scopeFactory, + IProducer<string, string> producer, + IOptions<KafkaOptions> options, + ILogger<MessageSentConsumer> logger) + : base(scopeFactory, producer, options, logger, KafkaTopics.MessageSent, ConsumerName) + { + } + + protected override async Task HandleAsync(IServiceProvider services, EventEnvelope<MessageSentEvent> envelope, CancellationToken cancellationToken) + { + var store = services.GetRequiredService<IMessageStore>(); + var data = envelope.Data; + + await store.AppendAsync( + new Message + { + Id = data.Id, + RoomId = data.RoomId, + SenderId = data.SenderId, + Text = data.Text, + ImageUrl = data.ImageUrl, + CreatedOn = data.CreatedOn, + }, + cancellationToken); + } +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/Consumers/UserUpdatedConsumer.cs b/src/Services/Chat/LoveNet.Chat.Api/Consumers/UserUpdatedConsumer.cs new file mode 100644 index 0000000..6f20521 --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Consumers/UserUpdatedConsumer.cs @@ -0,0 +1,48 @@ +using Confluent.Kafka; +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.EventBus.Consuming; +using LoveNet.Chat.Api.Data; +using Microsoft.Extensions.Options; + +namespace LoveNet.Chat.Api.Consumers; + +/// <summary>Keeps sender names/avatars current for chat-history rendering.</summary> +public class UserUpdatedConsumer : KafkaConsumerService<UserUpdatedEvent> +{ + private const string ConsumerName = "chat-service"; + + public UserUpdatedConsumer( + IServiceScopeFactory scopeFactory, + IProducer<string, string> producer, + IOptions<KafkaOptions> options, + ILogger<UserUpdatedConsumer> logger) + : base(scopeFactory, producer, options, logger, KafkaTopics.UserUpdated, ConsumerName) + { + } + + protected override async Task HandleAsync(IServiceProvider services, EventEnvelope<UserUpdatedEvent> envelope, CancellationToken cancellationToken) + { + var db = services.GetRequiredService<ChatDbContext>(); + + if (!await db.TryRegisterInboxAsync(envelope.MessageId, ConsumerName, cancellationToken)) + { + return; + } + + var data = envelope.Data; + var profile = await db.UserProfiles.FindAsync(new object[] { data.UserId }, cancellationToken); + + if (profile is null) + { + profile = new UserProfileLite { UserId = data.UserId }; + db.UserProfiles.Add(profile); + } + + profile.UserName = data.UserName; + profile.AvatarUrl = data.AvatarUrl; + + await db.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/Controllers/ChatController.cs b/src/Services/Chat/LoveNet.Chat.Api/Controllers/ChatController.cs new file mode 100644 index 0000000..ef9d0e8 --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Controllers/ChatController.cs @@ -0,0 +1,61 @@ +using LoveNet.BuildingBlocks.Media; +using LoveNet.BuildingBlocks.Web; +using LoveNet.BuildingBlocks.Web.Auth; +using LoveNet.BuildingBlocks.Web.Validation; +using LoveNet.Chat.Api.Models; +using LoveNet.Chat.Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LoveNet.Chat.Api.Controllers; + +[Route("api/chat")] +[ApiController] +public class ChatController : ControllerBase +{ + private readonly IChatService chatService; + private readonly IImagesService imagesService; + + public ChatController(IChatService chatService, IImagesService imagesService) + { + this.chatService = chatService; + this.imagesService = imagesService; + } + + [Authorize] + [HttpPost] + public async Task<IActionResult> GetChatMessages([FromBody] ChatRequestModel request) + { + if (!await chatService.CanAccessRoomAsync(request.RoomId, User.GetUserId())) + { + return Forbid(); + } + + return Ok(await chatService.GetChatAsync(request)); + } + + [Authorize] + [HttpGet("chatrooms")] + public async Task<IActionResult> GetChatRooms() => Ok(await chatService.GetChatroomsAsync()); + + /// <summary> + /// Chat-image upload. Replaces the monolith's base64-over-WebSocket flow: + /// the client uploads first, then sends the message with the returned URL. + /// </summary> + [Authorize] + [HttpPost("images")] + public async Task<IActionResult> UploadImage( + [MaxFileSize(LoveNetConstants.MaxFileSizeInBytes)] + [AllowedFileExtensions(new[] { ".jpg", ".png" })] + IFormFile file) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + var url = await imagesService.UploadImageAsync(file); + + return Ok(new { Url = url }); + } +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/Controllers/InternalController.cs b/src/Services/Chat/LoveNet.Chat.Api/Controllers/InternalController.cs new file mode 100644 index 0000000..5192303 --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Controllers/InternalController.cs @@ -0,0 +1,27 @@ +using LoveNet.Chat.Api.Data; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Chat.Api.Controllers; + +/// <summary>Gateway-only endpoints — not routed publicly.</summary> +[Route("internal")] +[ApiController] +public class InternalController : ControllerBase +{ + private readonly ChatDbContext db; + + public InternalController(ChatDbContext db) + { + this.db = db; + } + + [HttpGet("stats")] + public async Task<IActionResult> GetStats() + { + return Ok(new + { + MessagesCount = await db.Messages.CountAsync(), + }); + } +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/Data/ChatDbContext.cs b/src/Services/Chat/LoveNet.Chat.Api/Data/ChatDbContext.cs new file mode 100644 index 0000000..fdee15d --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Data/ChatDbContext.cs @@ -0,0 +1,53 @@ +using LoveNet.BuildingBlocks.EventBus; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Chat.Api.Data; + +public class ChatDbContext : DbContext +{ + public ChatDbContext(DbContextOptions<ChatDbContext> options) + : base(options) + { + } + + public DbSet<Message> Messages => Set<Message>(); + + public DbSet<Chatroom> Chatrooms => Set<Chatroom>(); + + public DbSet<Room> Rooms => Set<Room>(); + + public DbSet<UserProfileLite> UserProfiles => Set<UserProfileLite>(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity<Message>(entity => + { + entity.ToTable("messages"); + entity.HasKey(m => new { m.Id, m.CreatedOn }); + entity.HasIndex(m => new { m.RoomId, m.CreatedOn }).IsDescending(false, true); + entity.Property(m => m.Id).HasMaxLength(64); + entity.Property(m => m.RoomId).HasMaxLength(128); + }); + + modelBuilder.Entity<Chatroom>(entity => + { + entity.ToTable("chatrooms"); + entity.HasKey(c => c.Id); + }); + + modelBuilder.Entity<Room>(entity => + { + entity.ToTable("rooms"); + entity.HasKey(r => r.RoomId); + }); + + modelBuilder.Entity<UserProfileLite>(entity => + { + entity.ToTable("user_profiles_lite"); + entity.HasKey(p => p.UserId); + }); + + modelBuilder.AddOutbox(); + modelBuilder.AddInbox(); + } +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/Data/Chatroom.cs b/src/Services/Chat/LoveNet.Chat.Api/Data/Chatroom.cs new file mode 100644 index 0000000..ce501ae --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Data/Chatroom.cs @@ -0,0 +1,11 @@ +namespace LoveNet.Chat.Api.Data; + +/// <summary>Public/group room, seeded from Chatrooms.json (monolith parity).</summary> +public class Chatroom +{ + public string Id { get; set; } = null!; + + public string Title { get; set; } = null!; + + public string Url { get; set; } = null!; +} diff --git a/Data/LOVE.NET.Data/Files/Chatrooms.json b/src/Services/Chat/LoveNet.Chat.Api/Data/Files/Chatrooms.json similarity index 100% rename from Data/LOVE.NET.Data/Files/Chatrooms.json rename to src/Services/Chat/LoveNet.Chat.Api/Data/Files/Chatrooms.json diff --git a/src/Services/Chat/LoveNet.Chat.Api/Data/Message.cs b/src/Services/Chat/LoveNet.Chat.Api/Data/Message.cs new file mode 100644 index 0000000..0f66df8 --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Data/Message.cs @@ -0,0 +1,22 @@ +namespace LoveNet.Chat.Api.Data; + +/// <summary> +/// Chat message. The physical table is range-partitioned by CreatedOn +/// (monthly) — hence the composite key (Id, CreatedOn); Postgres requires the +/// partition key inside the primary key. +/// </summary> +public class Message +{ + /// <summary>Assigned by the Realtime hub; doubles as the idempotency key.</summary> + public string Id { get; set; } = null!; + + public string RoomId { get; set; } = null!; + + public string SenderId { get; set; } = null!; + + public string? Text { get; set; } + + public string? ImageUrl { get; set; } + + public DateTime CreatedOn { get; set; } +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/Data/Migrations/20260713080516_InitialCreate.Designer.cs b/src/Services/Chat/LoveNet.Chat.Api/Data/Migrations/20260713080516_InitialCreate.Designer.cs new file mode 100644 index 0000000..e82c91a --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Data/Migrations/20260713080516_InitialCreate.Designer.cs @@ -0,0 +1,174 @@ +// <auto-generated /> +using System; +using LoveNet.Chat.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LoveNet.Chat.Api.Data.Migrations +{ + [DbContext(typeof(ChatDbContext))] + [Migration("20260713080516_InitialCreate")] + partial class InitialCreate + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LoveNet.BuildingBlocks.EventBus.Inbox.InboxMessage", b => + { + b.Property<Guid>("MessageId") + .HasColumnType("uuid"); + + b.Property<string>("Consumer") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<DateTime>("ProcessedOn") + .HasColumnType("timestamp without time zone"); + + b.HasKey("MessageId", "Consumer"); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.BuildingBlocks.EventBus.Outbox.OutboxMessage", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<int>("Attempts") + .HasColumnType("integer"); + + b.Property<string>("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<string>("LastError") + .HasColumnType("text"); + + b.Property<DateTime>("OccurredOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime?>("ProcessedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Topic") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedOn"); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.Chat.Api.Data.Chatroom", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<string>("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("chatrooms", (string)null); + }); + + modelBuilder.Entity("LoveNet.Chat.Api.Data.Message", b => + { + b.Property<string>("Id") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property<DateTime>("CreatedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("ImageUrl") + .HasColumnType("text"); + + b.Property<string>("RoomId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<string>("SenderId") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Text") + .HasColumnType("text"); + + b.HasKey("Id", "CreatedOn"); + + b.HasIndex("RoomId", "CreatedOn") + .IsDescending(false, true); + + b.ToTable("messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.Chat.Api.Data.Room", b => + { + b.Property<string>("RoomId") + .HasColumnType("text"); + + b.Property<DateTime>("CreatedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("UserAId") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("UserBId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("RoomId"); + + b.ToTable("rooms", (string)null); + }); + + modelBuilder.Entity("LoveNet.Chat.Api.Data.UserProfileLite", b => + { + b.Property<string>("UserId") + .HasColumnType("text"); + + b.Property<string>("AvatarUrl") + .HasColumnType("text"); + + b.Property<string>("UserName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId"); + + b.ToTable("user_profiles_lite", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/Data/Migrations/20260713080516_InitialCreate.cs b/src/Services/Chat/LoveNet.Chat.Api/Data/Migrations/20260713080516_InitialCreate.cs new file mode 100644 index 0000000..a14d9f9 --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Data/Migrations/20260713080516_InitialCreate.cs @@ -0,0 +1,139 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LoveNet.Chat.Api.Data.Migrations +{ + /// <inheritdoc /> + public partial class InitialCreate : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "chatrooms", + columns: table => new + { + Id = table.Column<string>(type: "text", nullable: false), + Title = table.Column<string>(type: "text", nullable: false), + Url = table.Column<string>(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_chatrooms", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "inbox_messages", + columns: table => new + { + MessageId = table.Column<Guid>(type: "uuid", nullable: false), + Consumer = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false), + ProcessedOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_inbox_messages", x => new { x.MessageId, x.Consumer }); + }); + + // Hand-written: messages is range-partitioned by created_on (write-heavy, + // read-recent workload). EF's CreateTable cannot emit PARTITION BY; the + // model snapshot still describes the logical shape, which is all EF needs + // for querying. Monthly partitions are created at startup + // (PartitionMaintenance); the DEFAULT partition catches everything else. + migrationBuilder.Sql( + """ + CREATE TABLE messages ( + "Id" character varying(64) NOT NULL, + "CreatedOn" timestamp without time zone NOT NULL, + "RoomId" character varying(128) NOT NULL, + "SenderId" text NOT NULL, + "Text" text NULL, + "ImageUrl" text NULL, + CONSTRAINT "PK_messages" PRIMARY KEY ("Id", "CreatedOn") + ) PARTITION BY RANGE ("CreatedOn"); + + CREATE TABLE messages_default PARTITION OF messages DEFAULT; + """); + + migrationBuilder.CreateTable( + name: "outbox_messages", + columns: table => new + { + Id = table.Column<Guid>(type: "uuid", nullable: false), + Topic = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false), + Key = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false), + Payload = table.Column<string>(type: "text", nullable: false), + OccurredOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: false), + ProcessedOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: true), + Attempts = table.Column<int>(type: "integer", nullable: false), + LastError = table.Column<string>(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_outbox_messages", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "rooms", + columns: table => new + { + RoomId = table.Column<string>(type: "text", nullable: false), + UserAId = table.Column<string>(type: "text", nullable: false), + UserBId = table.Column<string>(type: "text", nullable: false), + CreatedOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_rooms", x => x.RoomId); + }); + + migrationBuilder.CreateTable( + name: "user_profiles_lite", + columns: table => new + { + UserId = table.Column<string>(type: "text", nullable: false), + UserName = table.Column<string>(type: "text", nullable: false), + AvatarUrl = table.Column<string>(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_user_profiles_lite", x => x.UserId); + }); + + migrationBuilder.CreateIndex( + name: "IX_messages_RoomId_CreatedOn", + table: "messages", + columns: new[] { "RoomId", "CreatedOn" }, + descending: new[] { false, true }); + + migrationBuilder.CreateIndex( + name: "IX_outbox_messages_ProcessedOn", + table: "outbox_messages", + column: "ProcessedOn"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "chatrooms"); + + migrationBuilder.DropTable( + name: "inbox_messages"); + + migrationBuilder.DropTable( + name: "messages"); + + migrationBuilder.DropTable( + name: "outbox_messages"); + + migrationBuilder.DropTable( + name: "rooms"); + + migrationBuilder.DropTable( + name: "user_profiles_lite"); + } + } +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/Data/Migrations/ChatDbContextModelSnapshot.cs b/src/Services/Chat/LoveNet.Chat.Api/Data/Migrations/ChatDbContextModelSnapshot.cs new file mode 100644 index 0000000..cdc3365 --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Data/Migrations/ChatDbContextModelSnapshot.cs @@ -0,0 +1,171 @@ +// <auto-generated /> +using System; +using LoveNet.Chat.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LoveNet.Chat.Api.Data.Migrations +{ + [DbContext(typeof(ChatDbContext))] + partial class ChatDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LoveNet.BuildingBlocks.EventBus.Inbox.InboxMessage", b => + { + b.Property<Guid>("MessageId") + .HasColumnType("uuid"); + + b.Property<string>("Consumer") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<DateTime>("ProcessedOn") + .HasColumnType("timestamp without time zone"); + + b.HasKey("MessageId", "Consumer"); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.BuildingBlocks.EventBus.Outbox.OutboxMessage", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<int>("Attempts") + .HasColumnType("integer"); + + b.Property<string>("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<string>("LastError") + .HasColumnType("text"); + + b.Property<DateTime>("OccurredOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime?>("ProcessedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Topic") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedOn"); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.Chat.Api.Data.Chatroom", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<string>("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("chatrooms", (string)null); + }); + + modelBuilder.Entity("LoveNet.Chat.Api.Data.Message", b => + { + b.Property<string>("Id") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property<DateTime>("CreatedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("ImageUrl") + .HasColumnType("text"); + + b.Property<string>("RoomId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<string>("SenderId") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Text") + .HasColumnType("text"); + + b.HasKey("Id", "CreatedOn"); + + b.HasIndex("RoomId", "CreatedOn") + .IsDescending(false, true); + + b.ToTable("messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.Chat.Api.Data.Room", b => + { + b.Property<string>("RoomId") + .HasColumnType("text"); + + b.Property<DateTime>("CreatedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("UserAId") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("UserBId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("RoomId"); + + b.ToTable("rooms", (string)null); + }); + + modelBuilder.Entity("LoveNet.Chat.Api.Data.UserProfileLite", b => + { + b.Property<string>("UserId") + .HasColumnType("text"); + + b.Property<string>("AvatarUrl") + .HasColumnType("text"); + + b.Property<string>("UserName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId"); + + b.ToTable("user_profiles_lite", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/Data/PartitionMaintenance.cs b/src/Services/Chat/LoveNet.Chat.Api/Data/PartitionMaintenance.cs new file mode 100644 index 0000000..7a6a95f --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Data/PartitionMaintenance.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Chat.Api.Data; + +/// <summary> +/// The messages table is range-partitioned by created_on. A DEFAULT partition +/// (created in the initial migration) catches anything unexpected; this +/// routine pre-creates explicit monthly partitions around "now" at startup. +/// </summary> +public static class PartitionMaintenance +{ + public static async Task EnsureMonthlyMessagePartitionsAsync(ChatDbContext db, int monthsBehind = 1, int monthsAhead = 3) + { + if (!db.Database.IsRelational() || db.Database.ProviderName?.Contains("Npgsql") != true) + { + return; + } + + var start = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1).AddMonths(-monthsBehind); + + for (var i = 0; i <= monthsBehind + monthsAhead; i++) + { + var from = start.AddMonths(i); + var to = from.AddMonths(1); + var name = $"messages_y{from:yyyy}m{from:MM}"; + + await db.Database.ExecuteSqlRawAsync( + $""" + CREATE TABLE IF NOT EXISTS {name} + PARTITION OF messages + FOR VALUES FROM ('{from:yyyy-MM-dd}') TO ('{to:yyyy-MM-dd}'); + """); + } + } +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/Data/Room.cs b/src/Services/Chat/LoveNet.Chat.Api/Data/Room.cs new file mode 100644 index 0000000..756a63c --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Data/Room.cs @@ -0,0 +1,16 @@ +namespace LoveNet.Chat.Api.Data; + +/// <summary> +/// 1:1 room provisioned from matching.match-created. Hardens authorization +/// beyond the deterministic roomId format the ids are derived from. +/// </summary> +public class Room +{ + public string RoomId { get; set; } = null!; + + public string UserAId { get; set; } = null!; + + public string UserBId { get; set; } = null!; + + public DateTime CreatedOn { get; set; } +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/Data/Seeding/ChatroomsSeeder.cs b/src/Services/Chat/LoveNet.Chat.Api/Data/Seeding/ChatroomsSeeder.cs new file mode 100644 index 0000000..00df66f --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Data/Seeding/ChatroomsSeeder.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Chat.Api.Data.Seeding; + +/// <summary> +/// Seeds the public chatrooms (monolith's Chatrooms.json) and announces each +/// via chat.room-provisioned so Realtime learns the public-room set. +/// </summary> +public static class ChatroomsSeeder +{ + public static async Task SeedAsync(ChatDbContext db) + { + if (await db.Chatrooms.AnyAsync()) + { + return; + } + + var filePath = Path.Combine(AppContext.BaseDirectory, "Data", "Files", "Chatrooms.json"); + var chatrooms = JsonSerializer.Deserialize<Chatroom[]>( + await File.ReadAllTextAsync(filePath), + new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? Array.Empty<Chatroom>(); + + foreach (var chatroom in chatrooms) + { + db.Chatrooms.Add(chatroom); + + db.AddOutboxMessage( + KafkaTopics.RoomProvisioned, + chatroom.Id, + new RoomProvisionedEvent(chatroom.Id, IsPublic: true, Array.Empty<string>(), chatroom.Title)); + } + + await db.SaveChangesAsync(); + } +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/Data/UserProfileLite.cs b/src/Services/Chat/LoveNet.Chat.Api/Data/UserProfileLite.cs new file mode 100644 index 0000000..5e051e8 --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Data/UserProfileLite.cs @@ -0,0 +1,11 @@ +namespace LoveNet.Chat.Api.Data; + +/// <summary>Read model from profile.user-updated; joined into chat history so avatars stay current.</summary> +public class UserProfileLite +{ + public string UserId { get; set; } = null!; + + public string UserName { get; set; } = null!; + + public string? AvatarUrl { get; set; } +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/Dockerfile b/src/Services/Chat/LoveNet.Chat.Api/Dockerfile new file mode 100644 index 0000000..df98434 --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Dockerfile @@ -0,0 +1,17 @@ +# Build context is the repository root. +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY NuGet.config . +COPY src/Directory.Build.props src/ +COPY src/BuildingBlocks/ src/BuildingBlocks/ +COPY src/Services/Chat/ src/Services/Chat/ +RUN dotnet publish src/Services/Chat/LoveNet.Chat.Api/LoveNet.Chat.Api.csproj -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +# curl is used by the compose healthcheck. +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +COPY --from=build /app/publish . +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 +ENTRYPOINT ["dotnet", "LoveNet.Chat.Api.dll"] diff --git a/src/Services/Chat/LoveNet.Chat.Api/LoveNet.Chat.Api.csproj b/src/Services/Chat/LoveNet.Chat.Api/LoveNet.Chat.Api.csproj new file mode 100644 index 0000000..bcd5199 --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/LoveNet.Chat.Api.csproj @@ -0,0 +1,26 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <ItemGroup> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.Contracts\LoveNet.BuildingBlocks.Contracts.csproj" /> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.EventBus\LoveNet.BuildingBlocks.EventBus.csproj" /> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.Web\LoveNet.BuildingBlocks.Web.csproj" /> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.Media\LoveNet.BuildingBlocks.Media.csproj" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" /> + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" /> + <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" /> + </ItemGroup> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + </PropertyGroup> + + <ItemGroup> + <Content Update="Data\Files\Chatrooms.json" CopyToOutputDirectory="PreserveNewest" /> + </ItemGroup> + +</Project> diff --git a/src/Services/Chat/LoveNet.Chat.Api/Models/ChatModels.cs b/src/Services/Chat/LoveNet.Chat.Api/Models/ChatModels.cs new file mode 100644 index 0000000..f592d2f --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Models/ChatModels.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; + +namespace LoveNet.Chat.Api.Models; + +public class ChatRequestModel +{ + [Required] + public string RoomId { get; set; } = null!; + + [Required] + public int Page { get; set; } +} + +/// <summary>History item — same field names the SPA renders (monolith MessageDto).</summary> +public class MessageModel +{ + public string RoomId { get; set; } = null!; + + public string UserId { get; set; } = null!; + + public string? Text { get; set; } + + /// <summary>Current profile picture of the sender (joined from the read model).</summary> + public string? ProfilePicture { get; set; } + + /// <summary>Image sent as the message body.</summary> + public string? ImageUrl { get; set; } + + public DateTime CreatedOn { get; set; } +} + +public class ChatResponseModel +{ + public IEnumerable<MessageModel> Messages { get; set; } = Enumerable.Empty<MessageModel>(); + + public int TotalMessages { get; set; } +} + +public class ChatroomModel +{ + public string Id { get; set; } = null!; + + public string Title { get; set; } = null!; + + public string Url { get; set; } = null!; +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/Program.cs b/src/Services/Chat/LoveNet.Chat.Api/Program.cs new file mode 100644 index 0000000..8f70b7a --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Program.cs @@ -0,0 +1,52 @@ +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.Media; +using LoveNet.BuildingBlocks.Web; +using LoveNet.BuildingBlocks.Web.Auth; +using LoveNet.Chat.Api.Consumers; +using LoveNet.Chat.Api.Data; +using LoveNet.Chat.Api.Data.Seeding; +using LoveNet.Chat.Api.Services; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddLoveNetDefaults("chat-api"); + +builder.Services.AddDbContext<ChatDbContext>(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("Default"))); + +builder.Services.AddLoveNetJwt(builder.Configuration); +builder.Services.AddKafkaEventBus(builder.Configuration); +builder.Services.AddOutboxPublisher<ChatDbContext>(); +builder.Services.AddHostedService<MessageSentConsumer>(); +builder.Services.AddHostedService<MatchCreatedConsumer>(); +builder.Services.AddHostedService<UserUpdatedConsumer>(); +builder.Services.AddCloudinaryMedia(builder.Configuration); + +builder.Services.AddScoped<IMessageStore, PostgresMessageStore>(); +builder.Services.AddScoped<IChatService, ChatService>(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddLoveNetHealthChecks(builder.Configuration, postgres: true, kafka: true); + +var app = builder.Build(); + +app.UseLoveNetDefaults(); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.MapLoveNetHealth(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService<ChatDbContext>(); + await db.Database.MigrateAsync(); + await PartitionMaintenance.EnsureMonthlyMessagePartitionsAsync(db); + await ChatroomsSeeder.SeedAsync(db); +} + +await app.RunAsync(); diff --git a/src/Services/Chat/LoveNet.Chat.Api/Properties/launchSettings.json b/src/Services/Chat/LoveNet.Chat.Api/Properties/launchSettings.json new file mode 100644 index 0000000..9c2ea5c --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:22885", + "sslPort": 44394 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5294", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7038;http://localhost:5294", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/Services/ChatService.cs b/src/Services/Chat/LoveNet.Chat.Api/Services/ChatService.cs new file mode 100644 index 0000000..338a0cf --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Services/ChatService.cs @@ -0,0 +1,55 @@ +using LoveNet.Chat.Api.Data; +using LoveNet.Chat.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Chat.Api.Services; + +public interface IChatService +{ + Task<ChatResponseModel> GetChatAsync(ChatRequestModel request); + + Task<List<ChatroomModel>> GetChatroomsAsync(); + + /// <summary> + /// A user may read a room when it is a public chatroom, a provisioned + /// match room they belong to, or (fallback while the match event is still + /// in flight) a room id containing their own user id. + /// </summary> + Task<bool> CanAccessRoomAsync(string roomId, string userId); +} + +public class ChatService : IChatService +{ + private readonly ChatDbContext db; + private readonly IMessageStore messageStore; + + public ChatService(ChatDbContext db, IMessageStore messageStore) + { + this.db = db; + this.messageStore = messageStore; + } + + public Task<ChatResponseModel> GetChatAsync(ChatRequestModel request) => + messageStore.GetPageAsync(request.RoomId, request.Page); + + public Task<List<ChatroomModel>> GetChatroomsAsync() => + db.Chatrooms.AsNoTracking() + .Select(c => new ChatroomModel { Id = c.Id, Title = c.Title, Url = c.Url }) + .ToListAsync(); + + public async Task<bool> CanAccessRoomAsync(string roomId, string userId) + { + if (roomId.Contains(userId)) + { + return true; + } + + if (await db.Chatrooms.AsNoTracking().AnyAsync(c => c.Id == roomId)) + { + return true; + } + + return await db.Rooms.AsNoTracking() + .AnyAsync(r => r.RoomId == roomId && (r.UserAId == userId || r.UserBId == userId)); + } +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/Services/IMessageStore.cs b/src/Services/Chat/LoveNet.Chat.Api/Services/IMessageStore.cs new file mode 100644 index 0000000..bada902 --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Services/IMessageStore.cs @@ -0,0 +1,18 @@ +using LoveNet.Chat.Api.Data; +using LoveNet.Chat.Api.Models; + +namespace LoveNet.Chat.Api.Services; + +/// <summary> +/// Storage seam for chat messages. The default implementation is Postgres +/// (monthly range partitions); the interface is deliberately narrow — +/// append + page-by-room — so a Cassandra/ScyllaDB implementation keyed on +/// (room_id, time_bucket) can be swapped in without touching callers. +/// </summary> +public interface IMessageStore +{ + /// <summary>Idempotent by message id: appending the same id twice is a no-op.</summary> + Task AppendAsync(Message message, CancellationToken cancellationToken = default); + + Task<ChatResponseModel> GetPageAsync(string roomId, int page, CancellationToken cancellationToken = default); +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/Services/PostgresMessageStore.cs b/src/Services/Chat/LoveNet.Chat.Api/Services/PostgresMessageStore.cs new file mode 100644 index 0000000..dc6decd --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/Services/PostgresMessageStore.cs @@ -0,0 +1,62 @@ +using LoveNet.BuildingBlocks.Web; +using LoveNet.Chat.Api.Data; +using LoveNet.Chat.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Chat.Api.Services; + +public class PostgresMessageStore : IMessageStore +{ + private readonly ChatDbContext db; + + public PostgresMessageStore(ChatDbContext db) + { + this.db = db; + } + + public async Task AppendAsync(Message message, CancellationToken cancellationToken = default) + { + var exists = await db.Messages.AsNoTracking() + .AnyAsync(m => m.Id == message.Id, cancellationToken); + + if (exists) + { + return; + } + + db.Messages.Add(message); + await db.SaveChangesAsync(cancellationToken); + } + + public async Task<ChatResponseModel> GetPageAsync(string roomId, int page, CancellationToken cancellationToken = default) + { + var messagesQuery = db.Messages.AsNoTracking() + .Where(m => m.RoomId == roomId) + .OrderByDescending(m => m.CreatedOn); + + var totalMessages = await messagesQuery.CountAsync(cancellationToken); + + var messages = await messagesQuery + .Skip((page - 1) * LoveNetConstants.DefaultTake) + .Take(LoveNetConstants.DefaultTake) + .Select(m => new MessageModel + { + RoomId = m.RoomId, + UserId = m.SenderId, + Text = m.Text, + ImageUrl = m.ImageUrl, + CreatedOn = m.CreatedOn, + ProfilePicture = db.UserProfiles + .Where(p => p.UserId == m.SenderId) + .Select(p => p.AvatarUrl) + .FirstOrDefault(), + }) + .ToListAsync(cancellationToken); + + return new ChatResponseModel + { + Messages = messages, + TotalMessages = totalMessages, + }; + } +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/appsettings.Development.json b/src/Services/Chat/LoveNet.Chat.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Services/Chat/LoveNet.Chat.Api/appsettings.json b/src/Services/Chat/LoveNet.Chat.Api/appsettings.json new file mode 100644 index 0000000..0699874 --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Api/appsettings.json @@ -0,0 +1,34 @@ +{ + "ConnectionStrings": { + "Default": "Host=localhost;Port=5436;Database=lovenet_chat;Username=lovenet;Password=lovenet" + }, + "Kafka": { + "BootstrapServers": "localhost:29092" + }, + "Jwt": { + "Key": "lovenet-local-development-signing-key-do-not-use-in-production-0123456789abcdef", + "Issuer": "https://localhost:3000/", + "Audience": "https://localhost:3000/" + }, + "Cloudinary": { + "CloudName": "", + "Key": "", + "Secret": "" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Services/Chat/LoveNet.Chat.Tests/ChatServiceTests.cs b/src/Services/Chat/LoveNet.Chat.Tests/ChatServiceTests.cs new file mode 100644 index 0000000..119cad5 --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Tests/ChatServiceTests.cs @@ -0,0 +1,123 @@ +using LoveNet.Chat.Api.Data; +using LoveNet.Chat.Api.Models; +using LoveNet.Chat.Api.Services; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Chat.Tests; + +[TestFixture] +public class ChatServiceTests +{ + private const string RoomId = "room-1"; + + private ChatDbContext db = null!; + private PostgresMessageStore messageStore = null!; + private ChatService chatService = null!; + + [SetUp] + public async Task Setup() + { + var options = new DbContextOptionsBuilder<ChatDbContext>() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + db = new ChatDbContext(options); + messageStore = new PostgresMessageStore(db); + chatService = new ChatService(db, messageStore); + + db.UserProfiles.Add(new UserProfileLite + { + UserId = "sender-1", + UserName = "alpha", + AvatarUrl = "https://img/alpha.png", + }); + + for (var i = 0; i < 15; i++) + { + db.Messages.Add(new Message + { + Id = $"msg-{i}", + RoomId = RoomId, + SenderId = "sender-1", + Text = $"message {i}", + CreatedOn = DateTime.UtcNow.AddMinutes(i), + }); + } + + await db.SaveChangesAsync(); + } + + [TearDown] + public void TearDown() => db.Dispose(); + + [Test] + public async Task SuccessGetFirstPage() + { + var chat = await chatService.GetChatAsync(new ChatRequestModel { RoomId = RoomId, Page = 1 }); + + Assert.Multiple(() => + { + Assert.That(chat.TotalMessages, Is.EqualTo(15)); + Assert.That(chat.Messages.Count(), Is.EqualTo(10)); + // Newest first, with the sender's current avatar joined in. + Assert.That(chat.Messages.First().Text, Is.EqualTo("message 14")); + Assert.That(chat.Messages.First().ProfilePicture, Is.EqualTo("https://img/alpha.png")); + }); + } + + [Test] + public async Task SuccessGetSecondPage() + { + var chat = await chatService.GetChatAsync(new ChatRequestModel { RoomId = RoomId, Page = 2 }); + + Assert.Multiple(() => + { + Assert.That(chat.Messages.Count(), Is.EqualTo(5)); + Assert.That(chat.Messages.Last().Text, Is.EqualTo("message 0")); + }); + } + + [Test] + public async Task SuccessSaveMessageIsIdempotentById() + { + var message = new Message + { + Id = "dup-1", + RoomId = RoomId, + SenderId = "sender-1", + Text = "hello", + CreatedOn = DateTime.UtcNow, + }; + + await messageStore.AppendAsync(message); + await messageStore.AppendAsync(new Message + { + Id = "dup-1", + RoomId = RoomId, + SenderId = "sender-1", + Text = "hello again (redelivered)", + CreatedOn = message.CreatedOn, + }); + + Assert.That(await db.Messages.CountAsync(m => m.Id == "dup-1"), Is.EqualTo(1)); + } + + [Test] + public async Task CanAccessRoomCoversAllThreeGrants() + { + var userId = Guid.NewGuid().ToString(); + var otherId = Guid.NewGuid().ToString(); + var strangerRoom = string.Join(string.Empty, new[] { Guid.NewGuid().ToString(), Guid.NewGuid().ToString() }.OrderBy(x => x)); + + db.Chatrooms.Add(new Chatroom { Id = "public-room", Title = "Public", Url = "https://img/room.png" }); + db.Rooms.Add(new Room { RoomId = "provisioned-room", UserAId = userId, UserBId = otherId, CreatedOn = DateTime.UtcNow }); + await db.SaveChangesAsync(); + + var matchRoom = string.Join(string.Empty, new[] { userId, otherId }.OrderBy(x => x)); + + Assert.That(await chatService.CanAccessRoomAsync("public-room", userId), Is.True, "public chatroom"); + Assert.That(await chatService.CanAccessRoomAsync("provisioned-room", userId), Is.True, "provisioned membership"); + Assert.That(await chatService.CanAccessRoomAsync(matchRoom, userId), Is.True, "deterministic roomId fallback"); + Assert.That(await chatService.CanAccessRoomAsync(strangerRoom, userId), Is.False, "someone else's room"); + } +} diff --git a/src/Services/Chat/LoveNet.Chat.Tests/LoveNet.Chat.Tests.csproj b/src/Services/Chat/LoveNet.Chat.Tests/LoveNet.Chat.Tests.csproj new file mode 100644 index 0000000..d2965e1 --- /dev/null +++ b/src/Services/Chat/LoveNet.Chat.Tests/LoveNet.Chat.Tests.csproj @@ -0,0 +1,30 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="coverlet.collector" Version="6.0.0" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> + <PackageReference Include="Moq" Version="4.20.70" /> + <PackageReference Include="NUnit" Version="3.14.0" /> + <PackageReference Include="NUnit.Analyzers" Version="3.9.0" /> + <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> + </ItemGroup> + + <ItemGroup> + <Using Include="NUnit.Framework" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\LoveNet.Chat.Api\LoveNet.Chat.Api.csproj" /> + </ItemGroup> + +</Project> diff --git a/src/Services/Identity/LoveNet.Identity.Api/Consumers/UserUpdatedConsumer.cs b/src/Services/Identity/LoveNet.Identity.Api/Consumers/UserUpdatedConsumer.cs new file mode 100644 index 0000000..58b9315 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Consumers/UserUpdatedConsumer.cs @@ -0,0 +1,55 @@ +using Confluent.Kafka; +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.EventBus.Consuming; +using LoveNet.Identity.Api.Data; +using Microsoft.Extensions.Options; + +namespace LoveNet.Identity.Api.Consumers; + +/// <summary> +/// Maintains the lite profile read model that enriches the login response. +/// </summary> +public class UserUpdatedConsumer : KafkaConsumerService<UserUpdatedEvent> +{ + private const string ConsumerName = "identity-service"; + + public UserUpdatedConsumer( + IServiceScopeFactory scopeFactory, + IProducer<string, string> producer, + IOptions<KafkaOptions> options, + ILogger<UserUpdatedConsumer> logger) + : base(scopeFactory, producer, options, logger, KafkaTopics.UserUpdated, ConsumerName) + { + } + + protected override async Task HandleAsync(IServiceProvider services, EventEnvelope<UserUpdatedEvent> envelope, CancellationToken cancellationToken) + { + var db = services.GetRequiredService<AppIdentityDbContext>(); + + if (!await db.TryRegisterInboxAsync(envelope.MessageId, ConsumerName, cancellationToken)) + { + return; + } + + var data = envelope.Data; + var profile = await db.UserProfiles.FindAsync(new object[] { data.UserId }, cancellationToken); + + if (profile is null) + { + profile = new UserProfileLite { UserId = data.UserId }; + db.UserProfiles.Add(profile); + } + + profile.Bio = data.Bio; + profile.Birthdate = data.Birthdate; + profile.CountryName = data.CountryName; + profile.CityName = data.CityName; + profile.Latitude = data.Latitude; + profile.Longitude = data.Longitude; + profile.AvatarUrl = data.AvatarUrl; + + await db.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Controllers/AdminController.cs b/src/Services/Identity/LoveNet.Identity.Api/Controllers/AdminController.cs new file mode 100644 index 0000000..b7b62c8 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Controllers/AdminController.cs @@ -0,0 +1,45 @@ +using LoveNet.BuildingBlocks.Web; +using LoveNet.BuildingBlocks.Web.Auth; +using LoveNet.BuildingBlocks.Web.Middleware; +using LoveNet.Identity.Api.Models; +using LoveNet.Identity.Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LoveNet.Identity.Api.Controllers; + +[Authorize(Roles = LoveNetConstants.AdministratorRoleName)] +[Route("api/admin")] +[ApiController] +public class AdminController : ControllerBase +{ + private readonly IIdentityService identityService; + + public AdminController(IIdentityService identityService) + { + this.identityService = identityService; + } + + [HttpPost("moderate")] + public async Task<IActionResult> ModerateUserAsync([FromBody] ModerateUserModel request) + { + if (User.GetUserId() == request.UserId) + { + return BadRequest(IdentityMessages.CantBanYourself); + } + + if (request.BannedUntil.HasValue && request.BannedUntil.Value.Date < DateTime.UtcNow.Date) + { + return BadRequest(IdentityMessages.CantBanUserInThePast); + } + + var result = await identityService.ModerateAsync(request, HttpContext.GetCorrelationId()); + + if (result.Failure) + { + return BadRequest((Result)string.Join('\n', result.Errors)); + } + + return NoContent(); + } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Controllers/EmailController.cs b/src/Services/Identity/LoveNet.Identity.Api/Controllers/EmailController.cs new file mode 100644 index 0000000..e929098 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Controllers/EmailController.cs @@ -0,0 +1,76 @@ +using LoveNet.BuildingBlocks.Web.Middleware; +using LoveNet.Identity.Api.Models; +using LoveNet.Identity.Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LoveNet.Identity.Api.Controllers; + +[Route("api/email")] +[ApiController] +public class EmailController : ControllerBase +{ + private readonly IEmailFlowService emailFlowService; + + public EmailController(IEmailFlowService emailFlowService) + { + this.emailFlowService = emailFlowService; + } + + [HttpGet("sendResetPasswordLink")] + [AllowAnonymous] + public async Task<IActionResult> SendResetPasswordLinkAsync(string email) + { + var result = await emailFlowService.SendResetPasswordLinkAsync( + email, Request.Headers.Origin, HttpContext.GetCorrelationId()); + + if (result.Failure) + { + return BadRequest(result.Errors); + } + + return Ok(IdentityMessages.CheckYourEmail); + } + + [HttpPost("resetPassword")] + public async Task<IActionResult> ResetPasswordAsync(ResetPasswordModel model) + { + var result = await emailFlowService.ResetPasswordAsync(model); + + if (result.Failure) + { + return BadRequest(result.Errors); + } + + return Ok(IdentityMessages.PasswordResetSuccessful); + } + + [HttpGet("verify")] + [AllowAnonymous] + public async Task<IActionResult> VerifyEmail([FromQuery] string email, [FromQuery] string token) + { + var result = await emailFlowService.VerifyEmailAsync(email, token); + + if (result.Failure) + { + return BadRequest(result.Errors); + } + + return Ok(IdentityMessages.EmailConfirmed); + } + + [HttpGet("resendEmailConfirmationLink")] + [AllowAnonymous] + public async Task<IActionResult> ResendEmailConfirmationLink(string email) + { + var result = await emailFlowService.ResendEmailConfirmationLinkAsync( + email, Request.Headers.Origin, HttpContext.GetCorrelationId()); + + if (result.Failure) + { + return BadRequest(result.Errors); + } + + return Ok(IdentityMessages.CheckYourEmail); + } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Controllers/IdentityController.cs b/src/Services/Identity/LoveNet.Identity.Api/Controllers/IdentityController.cs new file mode 100644 index 0000000..98ab3e6 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Controllers/IdentityController.cs @@ -0,0 +1,209 @@ +using LoveNet.BuildingBlocks.Web; +using LoveNet.BuildingBlocks.Web.Middleware; +using LoveNet.Identity.Api.Data; +using LoveNet.Identity.Api.Models; +using LoveNet.Identity.Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Identity.Api.Controllers; + +[Route("api/identity")] +[ApiController] +public class IdentityController : ControllerBase +{ + private const string RefreshTokenCookie = "refreshToken"; + + private readonly UserManager<ApplicationUser> userManager; + private readonly IIdentityService identityService; + private readonly IEmailFlowService emailFlowService; + + public IdentityController( + UserManager<ApplicationUser> userManager, + IIdentityService identityService, + IEmailFlowService emailFlowService) + { + this.userManager = userManager; + this.identityService = identityService; + this.emailFlowService = emailFlowService; + } + + [HttpPost("register")] + [AllowAnonymous] + public async Task<IActionResult> RegisterAsync([FromForm] RegisterModel model) + { + await ValidateRegisterModelAsync(model); + + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + var result = await identityService.RegisterAsync(model, HttpContext.GetCorrelationId()); + + if (!result.Succeeded) + { + var errorMessages = string.Join("\n", result.Errors.Select(x => x.Description)); + return BadRequest((Result)errorMessages); + } + + var user = await userManager.FindByEmailAsync(model.Email); + + await emailFlowService.SendEmailConfirmationAsync( + Request.Headers.Origin, + user!, + HttpContext.GetCorrelationId()); + + await SetRefreshToken(user!); + + return StatusCode(StatusCodes.Status201Created); + } + + [HttpPost("login")] + [AllowAnonymous] + public async Task<IActionResult> LoginAsync([FromBody] LoginModel model) + { + if (!ModelState.IsValid) + { + return BadRequest((Result)IdentityMessages.FillAllTheInformation); + } + + LoginResponseModel response; + + try + { + response = await identityService.LoginAsync(model); + } + catch (ArgumentException e) + { + return BadRequest((Result)e.Message); + } + + var user = await userManager.FindByEmailAsync(model.Email); + + await SetRefreshToken(user!); + + return Ok(response); + } + + [HttpPost("logout")] + public async Task<IActionResult> Logout() + { + var refreshToken = Request.Cookies[RefreshTokenCookie]; + Response.Cookies.Delete(RefreshTokenCookie); + + var user = await userManager.Users + .Include(u => u.RefreshTokens) + .FirstOrDefaultAsync(x => x.RefreshTokens.Any(t => t.Token == refreshToken)); + + var token = user?.RefreshTokens.FirstOrDefault(t => t.Token == refreshToken); + + if (token != null) + { + token.Revoked = DateTime.UtcNow; + await userManager.UpdateAsync(user!); + } + + return Ok(); + } + + [HttpPost("refreshToken")] + public async Task<IActionResult> RefreshToken() + { + var refreshToken = Request.Cookies[RefreshTokenCookie]; + + var user = await userManager.Users + .Include(u => u.RefreshTokens) + .FirstOrDefaultAsync(x => x.RefreshTokens.Any(t => t.Token == refreshToken)); + + if (user is null) + { + return Unauthorized(); + } + + var oldToken = user.RefreshTokens.FirstOrDefault(t => t.Token == refreshToken); + + if (oldToken != null && !oldToken.IsActive) + { + return Unauthorized(); + } + + var userRoles = await userManager.GetRolesAsync(user); + var newToken = await identityService.GenerateJwtToken(user); + + await SetRefreshToken(user, oldToken); + + var response = new LoginResponseModel + { + Id = user.Id, + Token = newToken, + Email = user.Email!, + UserName = user.UserName!, + IsAdmin = userRoles.Contains(LoveNetConstants.AdministratorRoleName), + }; + + return Ok(response); + } + + private async Task SetRefreshToken(ApplicationUser user, RefreshToken? oldRefreshToken = null) + { + var refreshToken = identityService.GenerateRefreshToken(); + + if (oldRefreshToken != null) + { + oldRefreshToken.Revoked = DateTime.UtcNow; + oldRefreshToken.ReplacedByToken = refreshToken.Token; + } + + user.RefreshTokens.Add(refreshToken); + + await userManager.UpdateAsync(user); + + var cookieOptions = new CookieOptions + { + HttpOnly = true, + Expires = DateTime.UtcNow.AddHours(1.5), + SameSite = SameSiteMode.None, + Secure = true, + }; + + Response.Cookies.Append(RefreshTokenCookie, refreshToken.Token, cookieOptions); + } + + private async Task ValidateRegisterModelAsync(RegisterModel model) + { + if (await userManager.Users.AnyAsync(x => x.Email == model.Email)) + { + ModelState.AddModelError("Error", IdentityMessages.EmailAlreadyInUse); + } + + if (model.Password != model.ConfirmPassword) + { + ModelState.AddModelError("Error", IdentityMessages.PasswordsDontMatch); + } + + if (DateHelper.CalculateAge(model.Birthdate) < LoveNetConstants.MinimalAge) + { + ModelState.AddModelError("Error", IdentityMessages.UnderagedUser); + } + + // Range checks only — the Profile service owns the reference data and + // is the authority on geo consistency. + if (model.GenderId < 1 || model.GenderId > IdentityMessages.GendersMaxCountInDb) + { + ModelState.AddModelError("Error", IdentityMessages.InvalidGender); + } + + if (model.CityId < 1 || model.CityId > IdentityMessages.CitiesMaxCountInDb) + { + ModelState.AddModelError("Error", IdentityMessages.InvalidCity); + } + + if (model.CountryId < 1 || model.CountryId > IdentityMessages.CountriesMaxCountInDb) + { + ModelState.AddModelError("Error", IdentityMessages.InvalidCountry); + } + } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Controllers/InternalController.cs b/src/Services/Identity/LoveNet.Identity.Api/Controllers/InternalController.cs new file mode 100644 index 0000000..4b3ff20 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Controllers/InternalController.cs @@ -0,0 +1,30 @@ +using LoveNet.Identity.Api.Data; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Identity.Api.Controllers; + +/// <summary> +/// Gateway-only endpoints — not routed publicly (YARP exposes no /internal path). +/// </summary> +[Route("internal")] +[ApiController] +public class InternalController : ControllerBase +{ + private readonly AppIdentityDbContext db; + + public InternalController(AppIdentityDbContext db) + { + this.db = db; + } + + [HttpGet("stats")] + public async Task<IActionResult> GetStats() + { + return Ok(new + { + UsersCount = await db.Users.CountAsync(), + BannedUsersCount = await db.Users.CountAsync(u => u.LockoutEnd != null), + }); + } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Data/AppIdentityDbContext.cs b/src/Services/Identity/LoveNet.Identity.Api/Data/AppIdentityDbContext.cs new file mode 100644 index 0000000..8cdb2ae --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Data/AppIdentityDbContext.cs @@ -0,0 +1,37 @@ +using LoveNet.BuildingBlocks.EventBus; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Identity.Api.Data; + +public class AppIdentityDbContext : IdentityDbContext<ApplicationUser, ApplicationRole, string> +{ + public AppIdentityDbContext(DbContextOptions<AppIdentityDbContext> options) + : base(options) + { + } + + public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>(); + + public DbSet<UserProfileLite> UserProfiles => Set<UserProfileLite>(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.Entity<ApplicationUser>() + .HasMany(u => u.RefreshTokens) + .WithOne() + .HasForeignKey(t => t.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Entity<UserProfileLite>(entity => + { + entity.ToTable("user_profiles_lite"); + entity.HasKey(p => p.UserId); + }); + + builder.AddOutbox(); + builder.AddInbox(); + } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Data/ApplicationRole.cs b/src/Services/Identity/LoveNet.Identity.Api/Data/ApplicationRole.cs new file mode 100644 index 0000000..68d3aee --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Data/ApplicationRole.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Identity; + +namespace LoveNet.Identity.Api.Data; + +public class ApplicationRole : IdentityRole +{ + public ApplicationRole() + { + } + + public ApplicationRole(string name) + : base(name) + { + } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Data/ApplicationUser.cs b/src/Services/Identity/LoveNet.Identity.Api/Data/ApplicationUser.cs new file mode 100644 index 0000000..b5dfb31 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Data/ApplicationUser.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Identity; + +namespace LoveNet.Identity.Api.Data; + +/// <summary> +/// Credentials-only user: profile attributes (bio, geo, images) live in the +/// Profile service and reach it through the identity.user-registered event. +/// </summary> +public class ApplicationUser : IdentityUser +{ + public ApplicationUser() + { + Id = Guid.NewGuid().ToString(); + } + + public DateTime CreatedOn { get; set; } + + public DateTime? ModifiedOn { get; set; } + + public virtual ICollection<RefreshToken> RefreshTokens { get; set; } = new HashSet<RefreshToken>(); +} diff --git a/Data/LOVE.NET.Data/Files/Users.json b/src/Services/Identity/LoveNet.Identity.Api/Data/Files/Users.json similarity index 100% rename from Data/LOVE.NET.Data/Files/Users.json rename to src/Services/Identity/LoveNet.Identity.Api/Data/Files/Users.json diff --git a/src/Services/Identity/LoveNet.Identity.Api/Data/IdentityOptionsProvider.cs b/src/Services/Identity/LoveNet.Identity.Api/Data/IdentityOptionsProvider.cs new file mode 100644 index 0000000..2c67d31 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Data/IdentityOptionsProvider.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Identity; + +namespace LoveNet.Identity.Api.Data; + +/// <summary>Password/username rules ported unchanged from the monolith.</summary> +public static class IdentityOptionsProvider +{ + public static void GetIdentityOptions(IdentityOptions options) + { + options.Password.RequireDigit = false; + options.Password.RequiredLength = 5; + options.Password.RequireUppercase = false; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequiredUniqueChars = 0; + options.User.AllowedUserNameCharacters += " "; + options.SignIn.RequireConfirmedEmail = false; + } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Data/Migrations/20260713073643_InitialCreate.Designer.cs b/src/Services/Identity/LoveNet.Identity.Api/Data/Migrations/20260713073643_InitialCreate.Designer.cs new file mode 100644 index 0000000..3242b24 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Data/Migrations/20260713073643_InitialCreate.Designer.cs @@ -0,0 +1,419 @@ +// <auto-generated /> +using System; +using LoveNet.Identity.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LoveNet.Identity.Api.Data.Migrations +{ + [DbContext(typeof(AppIdentityDbContext))] + [Migration("20260713073643_InitialCreate")] + partial class InitialCreate + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LoveNet.BuildingBlocks.EventBus.Inbox.InboxMessage", b => + { + b.Property<Guid>("MessageId") + .HasColumnType("uuid"); + + b.Property<string>("Consumer") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<DateTime>("ProcessedOn") + .HasColumnType("timestamp without time zone"); + + b.HasKey("MessageId", "Consumer"); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.BuildingBlocks.EventBus.Outbox.OutboxMessage", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<int>("Attempts") + .HasColumnType("integer"); + + b.Property<string>("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<string>("LastError") + .HasColumnType("text"); + + b.Property<DateTime>("OccurredOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime?>("ProcessedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Topic") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedOn"); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.Identity.Api.Data.ApplicationRole", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<string>("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property<string>("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<string>("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("LoveNet.Identity.Api.Data.ApplicationUser", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<int>("AccessFailedCount") + .HasColumnType("integer"); + + b.Property<string>("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property<DateTime>("CreatedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<bool>("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property<bool>("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property<DateTimeOffset?>("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTime?>("ModifiedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<string>("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<string>("PasswordHash") + .HasColumnType("text"); + + b.Property<string>("PhoneNumber") + .HasColumnType("text"); + + b.Property<bool>("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property<string>("SecurityStamp") + .HasColumnType("text"); + + b.Property<bool>("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property<string>("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("LoveNet.Identity.Api.Data.RefreshToken", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<DateTime>("CreatedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<DateTime>("Expires") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("ReplacedByToken") + .HasColumnType("text"); + + b.Property<DateTime?>("Revoked") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("LoveNet.Identity.Api.Data.UserProfileLite", b => + { + b.Property<string>("UserId") + .HasColumnType("text"); + + b.Property<string>("AvatarUrl") + .HasColumnType("text"); + + b.Property<string>("Bio") + .HasColumnType("text"); + + b.Property<DateTime>("Birthdate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("CityName") + .HasColumnType("text"); + + b.Property<string>("CountryName") + .HasColumnType("text"); + + b.Property<double?>("Latitude") + .HasColumnType("double precision"); + + b.Property<double?>("Longitude") + .HasColumnType("double precision"); + + b.HasKey("UserId"); + + b.ToTable("user_profiles_lite", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<string>("ClaimType") + .HasColumnType("text"); + + b.Property<string>("ClaimValue") + .HasColumnType("text"); + + b.Property<string>("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<string>("ClaimType") + .HasColumnType("text"); + + b.Property<string>("ClaimValue") + .HasColumnType("text"); + + b.Property<string>("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => + { + b.Property<string>("LoginProvider") + .HasColumnType("text"); + + b.Property<string>("ProviderKey") + .HasColumnType("text"); + + b.Property<string>("ProviderDisplayName") + .HasColumnType("text"); + + b.Property<string>("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b => + { + b.Property<string>("UserId") + .HasColumnType("text"); + + b.Property<string>("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b => + { + b.Property<string>("UserId") + .HasColumnType("text"); + + b.Property<string>("LoginProvider") + .HasColumnType("text"); + + b.Property<string>("Name") + .HasColumnType("text"); + + b.Property<string>("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("LoveNet.Identity.Api.Data.RefreshToken", b => + { + b.HasOne("LoveNet.Identity.Api.Data.ApplicationUser", null) + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => + { + b.HasOne("LoveNet.Identity.Api.Data.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b => + { + b.HasOne("LoveNet.Identity.Api.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => + { + b.HasOne("LoveNet.Identity.Api.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b => + { + b.HasOne("LoveNet.Identity.Api.Data.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LoveNet.Identity.Api.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b => + { + b.HasOne("LoveNet.Identity.Api.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LoveNet.Identity.Api.Data.ApplicationUser", b => + { + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Data/Migrations/20260713073643_InitialCreate.cs b/src/Services/Identity/LoveNet.Identity.Api/Data/Migrations/20260713073643_InitialCreate.cs new file mode 100644 index 0000000..7f11c51 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Data/Migrations/20260713073643_InitialCreate.cs @@ -0,0 +1,320 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LoveNet.Identity.Api.Data.Migrations +{ + /// <inheritdoc /> + public partial class InitialCreate : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column<string>(type: "text", nullable: false), + Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column<string>(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column<string>(type: "text", nullable: false), + CreatedOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: false), + ModifiedOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: true), + UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false), + PasswordHash = table.Column<string>(type: "text", nullable: true), + SecurityStamp = table.Column<string>(type: "text", nullable: true), + ConcurrencyStamp = table.Column<string>(type: "text", nullable: true), + PhoneNumber = table.Column<string>(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false), + LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false), + AccessFailedCount = table.Column<int>(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "inbox_messages", + columns: table => new + { + MessageId = table.Column<Guid>(type: "uuid", nullable: false), + Consumer = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false), + ProcessedOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_inbox_messages", x => new { x.MessageId, x.Consumer }); + }); + + migrationBuilder.CreateTable( + name: "outbox_messages", + columns: table => new + { + Id = table.Column<Guid>(type: "uuid", nullable: false), + Topic = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false), + Key = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false), + Payload = table.Column<string>(type: "text", nullable: false), + OccurredOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: false), + ProcessedOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: true), + Attempts = table.Column<int>(type: "integer", nullable: false), + LastError = table.Column<string>(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_outbox_messages", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "user_profiles_lite", + columns: table => new + { + UserId = table.Column<string>(type: "text", nullable: false), + Bio = table.Column<string>(type: "text", nullable: true), + Birthdate = table.Column<DateTime>(type: "timestamp without time zone", nullable: false), + CountryName = table.Column<string>(type: "text", nullable: true), + CityName = table.Column<string>(type: "text", nullable: true), + Latitude = table.Column<double>(type: "double precision", nullable: true), + Longitude = table.Column<double>(type: "double precision", nullable: true), + AvatarUrl = table.Column<string>(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_user_profiles_lite", x => x.UserId); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column<int>(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column<string>(type: "text", nullable: false), + ClaimType = table.Column<string>(type: "text", nullable: true), + ClaimValue = table.Column<string>(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column<int>(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column<string>(type: "text", nullable: false), + ClaimType = table.Column<string>(type: "text", nullable: true), + ClaimValue = table.Column<string>(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column<string>(type: "text", nullable: false), + ProviderKey = table.Column<string>(type: "text", nullable: false), + ProviderDisplayName = table.Column<string>(type: "text", nullable: true), + UserId = table.Column<string>(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column<string>(type: "text", nullable: false), + RoleId = table.Column<string>(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column<string>(type: "text", nullable: false), + LoginProvider = table.Column<string>(type: "text", nullable: false), + Name = table.Column<string>(type: "text", nullable: false), + Value = table.Column<string>(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + Id = table.Column<int>(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column<string>(type: "text", nullable: false), + Token = table.Column<string>(type: "text", nullable: false), + Expires = table.Column<DateTime>(type: "timestamp without time zone", nullable: false), + Revoked = table.Column<DateTime>(type: "timestamp without time zone", nullable: true), + CreatedOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: false), + ReplacedByToken = table.Column<string>(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + table.ForeignKey( + name: "FK_RefreshTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_outbox_messages_ProcessedOn", + table: "outbox_messages", + column: "ProcessedOn"); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId", + table: "RefreshTokens", + column: "UserId"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "inbox_messages"); + + migrationBuilder.DropTable( + name: "outbox_messages"); + + migrationBuilder.DropTable( + name: "RefreshTokens"); + + migrationBuilder.DropTable( + name: "user_profiles_lite"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Data/Migrations/IdentityDbContextModelSnapshot.cs b/src/Services/Identity/LoveNet.Identity.Api/Data/Migrations/IdentityDbContextModelSnapshot.cs new file mode 100644 index 0000000..09a105e --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Data/Migrations/IdentityDbContextModelSnapshot.cs @@ -0,0 +1,416 @@ +// <auto-generated /> +using System; +using LoveNet.Identity.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LoveNet.Identity.Api.Data.Migrations +{ + [DbContext(typeof(AppIdentityDbContext))] + partial class IdentityDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LoveNet.BuildingBlocks.EventBus.Inbox.InboxMessage", b => + { + b.Property<Guid>("MessageId") + .HasColumnType("uuid"); + + b.Property<string>("Consumer") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<DateTime>("ProcessedOn") + .HasColumnType("timestamp without time zone"); + + b.HasKey("MessageId", "Consumer"); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.BuildingBlocks.EventBus.Outbox.OutboxMessage", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<int>("Attempts") + .HasColumnType("integer"); + + b.Property<string>("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<string>("LastError") + .HasColumnType("text"); + + b.Property<DateTime>("OccurredOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime?>("ProcessedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Topic") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedOn"); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.Identity.Api.Data.ApplicationRole", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<string>("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property<string>("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<string>("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("LoveNet.Identity.Api.Data.ApplicationUser", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<int>("AccessFailedCount") + .HasColumnType("integer"); + + b.Property<string>("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property<DateTime>("CreatedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<bool>("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property<bool>("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property<DateTimeOffset?>("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property<DateTime?>("ModifiedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<string>("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<string>("PasswordHash") + .HasColumnType("text"); + + b.Property<string>("PhoneNumber") + .HasColumnType("text"); + + b.Property<bool>("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property<string>("SecurityStamp") + .HasColumnType("text"); + + b.Property<bool>("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property<string>("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("LoveNet.Identity.Api.Data.RefreshToken", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<DateTime>("CreatedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<DateTime>("Expires") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("ReplacedByToken") + .HasColumnType("text"); + + b.Property<DateTime?>("Revoked") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("LoveNet.Identity.Api.Data.UserProfileLite", b => + { + b.Property<string>("UserId") + .HasColumnType("text"); + + b.Property<string>("AvatarUrl") + .HasColumnType("text"); + + b.Property<string>("Bio") + .HasColumnType("text"); + + b.Property<DateTime>("Birthdate") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("CityName") + .HasColumnType("text"); + + b.Property<string>("CountryName") + .HasColumnType("text"); + + b.Property<double?>("Latitude") + .HasColumnType("double precision"); + + b.Property<double?>("Longitude") + .HasColumnType("double precision"); + + b.HasKey("UserId"); + + b.ToTable("user_profiles_lite", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<string>("ClaimType") + .HasColumnType("text"); + + b.Property<string>("ClaimValue") + .HasColumnType("text"); + + b.Property<string>("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<string>("ClaimType") + .HasColumnType("text"); + + b.Property<string>("ClaimValue") + .HasColumnType("text"); + + b.Property<string>("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => + { + b.Property<string>("LoginProvider") + .HasColumnType("text"); + + b.Property<string>("ProviderKey") + .HasColumnType("text"); + + b.Property<string>("ProviderDisplayName") + .HasColumnType("text"); + + b.Property<string>("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b => + { + b.Property<string>("UserId") + .HasColumnType("text"); + + b.Property<string>("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b => + { + b.Property<string>("UserId") + .HasColumnType("text"); + + b.Property<string>("LoginProvider") + .HasColumnType("text"); + + b.Property<string>("Name") + .HasColumnType("text"); + + b.Property<string>("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("LoveNet.Identity.Api.Data.RefreshToken", b => + { + b.HasOne("LoveNet.Identity.Api.Data.ApplicationUser", null) + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => + { + b.HasOne("LoveNet.Identity.Api.Data.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b => + { + b.HasOne("LoveNet.Identity.Api.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => + { + b.HasOne("LoveNet.Identity.Api.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b => + { + b.HasOne("LoveNet.Identity.Api.Data.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LoveNet.Identity.Api.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b => + { + b.HasOne("LoveNet.Identity.Api.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LoveNet.Identity.Api.Data.ApplicationUser", b => + { + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Data/RefreshToken.cs b/src/Services/Identity/LoveNet.Identity.Api/Data/RefreshToken.cs new file mode 100644 index 0000000..acb4caf --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Data/RefreshToken.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace LoveNet.Identity.Api.Data; + +public class RefreshToken +{ + [JsonIgnore] + public int Id { get; set; } + + public string UserId { get; set; } = null!; + + public string Token { get; set; } = null!; + + public DateTime Expires { get; set; } + + public bool IsExpired => DateTime.UtcNow >= Expires; + + public DateTime? Revoked { get; set; } + + public bool IsActive => Revoked == null && !IsExpired; + + public DateTime CreatedOn { get; set; } + + public string? ReplacedByToken { get; set; } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Data/Seeding/IdentitySeeder.cs b/src/Services/Identity/LoveNet.Identity.Api/Data/Seeding/IdentitySeeder.cs new file mode 100644 index 0000000..9dcb14a --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Data/Seeding/IdentitySeeder.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.Web; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Identity.Api.Data.Seeding; + +/// <summary> +/// Seeds the Administrator role and the demo users, then emits +/// identity.user-registered outbox events so downstream services (Profile, +/// Matching, ...) build their own state through the regular event pipeline. +/// </summary> +public static class IdentitySeeder +{ + public static async Task SeedAsync(IServiceProvider services) + { + var db = services.GetRequiredService<AppIdentityDbContext>(); + var roleManager = services.GetRequiredService<RoleManager<ApplicationRole>>(); + + if (await roleManager.FindByNameAsync(LoveNetConstants.AdministratorRoleName) is null) + { + var roleResult = await roleManager.CreateAsync(new ApplicationRole(LoveNetConstants.AdministratorRoleName)); + + if (!roleResult.Succeeded) + { + throw new InvalidOperationException(string.Join(Environment.NewLine, roleResult.Errors.Select(e => e.Description))); + } + } + + if (await db.Users.AnyAsync()) + { + return; + } + + var filePath = Path.Combine(AppContext.BaseDirectory, "Data", "Files", "Users.json"); + var seedUsers = JsonSerializer.Deserialize<SeedUser[]>(await File.ReadAllTextAsync(filePath)) + ?? Array.Empty<SeedUser>(); + + var userManager = services.GetRequiredService<UserManager<ApplicationUser>>(); + var isFirst = true; + + foreach (var seed in seedUsers) + { + var user = new ApplicationUser + { + UserName = seed.UserName, + NormalizedUserName = seed.UserName.ToUpperInvariant(), + Email = seed.Email, + NormalizedEmail = seed.NormalizedEmail, + EmailConfirmed = seed.EmailConfirmed, + PasswordHash = seed.PasswordHash, + LockoutEnabled = seed.LockoutEnabled, + SecurityStamp = Guid.NewGuid().ToString("D"), + CreatedOn = seed.CreatedOn, + }; + + db.Users.Add(user); + + var imageUrls = seed.Images + .OrderByDescending(i => i.IsProfilePicture) + .Select(i => i.Url) + .ToList(); + + db.AddOutboxMessage( + KafkaTopics.UserRegistered, + user.Id, + new UserRegisteredEvent( + user.Id, + seed.Email, + seed.UserName, + seed.Bio, + seed.Birthdate, + seed.GenderId, + seed.CityId, + seed.CountryId, + imageUrls, + IsAdmin: isFirst)); + + await db.SaveChangesAsync(); + + if (isFirst) + { + await userManager.AddToRoleAsync(user, LoveNetConstants.AdministratorRoleName); + isFirst = false; + } + } + } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Data/Seeding/SeedUser.cs b/src/Services/Identity/LoveNet.Identity.Api/Data/Seeding/SeedUser.cs new file mode 100644 index 0000000..d62c3a9 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Data/Seeding/SeedUser.cs @@ -0,0 +1,38 @@ +namespace LoveNet.Identity.Api.Data.Seeding; + +/// <summary>Shape of Data/Files/Users.json (carried over from the monolith).</summary> +public class SeedUser +{ + public DateTime CreatedOn { get; set; } + + public int GenderId { get; set; } + + public string Bio { get; set; } = null!; + + public DateTime Birthdate { get; set; } + + public List<SeedUserImage> Images { get; set; } = new(); + + public int CountryId { get; set; } + + public int CityId { get; set; } + + public string UserName { get; set; } = null!; + + public string Email { get; set; } = null!; + + public string NormalizedEmail { get; set; } = null!; + + public bool EmailConfirmed { get; set; } + + public string PasswordHash { get; set; } = null!; + + public bool LockoutEnabled { get; set; } +} + +public class SeedUserImage +{ + public string Url { get; set; } = null!; + + public bool IsProfilePicture { get; set; } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Data/UserProfileLite.cs b/src/Services/Identity/LoveNet.Identity.Api/Data/UserProfileLite.cs new file mode 100644 index 0000000..8e9860f --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Data/UserProfileLite.cs @@ -0,0 +1,24 @@ +namespace LoveNet.Identity.Api.Data; + +/// <summary> +/// Read model kept in sync from profile.user-updated events so the login +/// response can carry the profile fields the SPA stores (avatar, location). +/// </summary> +public class UserProfileLite +{ + public string UserId { get; set; } = null!; + + public string? Bio { get; set; } + + public DateTime Birthdate { get; set; } + + public string? CountryName { get; set; } + + public string? CityName { get; set; } + + public double? Latitude { get; set; } + + public double? Longitude { get; set; } + + public string? AvatarUrl { get; set; } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Dockerfile b/src/Services/Identity/LoveNet.Identity.Api/Dockerfile new file mode 100644 index 0000000..52f2733 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Dockerfile @@ -0,0 +1,17 @@ +# Build context is the repository root. +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY NuGet.config . +COPY src/Directory.Build.props src/ +COPY src/BuildingBlocks/ src/BuildingBlocks/ +COPY src/Services/Identity/ src/Services/Identity/ +RUN dotnet publish src/Services/Identity/LoveNet.Identity.Api/LoveNet.Identity.Api.csproj -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +# curl is used by the compose healthcheck. +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +COPY --from=build /app/publish . +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 +ENTRYPOINT ["dotnet", "LoveNet.Identity.Api.dll"] diff --git a/src/Services/Identity/LoveNet.Identity.Api/IdentityMessages.cs b/src/Services/Identity/LoveNet.Identity.Api/IdentityMessages.cs new file mode 100644 index 0000000..ce87e0c --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/IdentityMessages.cs @@ -0,0 +1,32 @@ +namespace LoveNet.Identity.Api; + +/// <summary>User-facing messages ported from the monolith so response bodies stay identical.</summary> +public static class IdentityMessages +{ + public const string EmailAlreadyInUse = "Email already in use"; + public const string PasswordsDontMatch = "Password and confirm password must match"; + public const string UnderagedUser = "You need to be 18 years old to register"; + public const string FillAllTheInformation = "Fill all the information"; + public const string UserNotFound = "User not found"; + public const string WrongPassword = "Wrong password"; + public const string BannedUntil = "You are banned until {0}"; + public const string InvalidGender = "Invalid gender"; + public const string InvalidCity = "Invalid city"; + public const string InvalidCountry = "Invalid country"; + public const string UserCouldNotBeBanned = "User could not be banned"; + public const string CantBanUserInThePast = "Can't ban user in the past"; + public const string CantBanYourself = "You can't ban yourself"; + + public const string IncorrectEmail = "Your email is incorrect"; + public const string EmailConfirmed = "Email confirmed - you can now login"; + public const string CheckYourEmail = "Check your email"; + public const string PasswordResetSuccessful = "Password reset is successful - you can now login"; + public const string EmailDoesntMatch = "This is not the email you registered with"; + public const string EmailNotConfirmed = "Please, check your email and verify your account"; + public const string EmailAlreadyVerified = "Your email is already verified"; + + // Reference-data bounds (owned by Profile; used here only for cheap range checks). + public const int CitiesMaxCountInDb = 42905; + public const int CountriesMaxCountInDb = 239; + public const int GendersMaxCountInDb = 3; +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/LoveNet.Identity.Api.csproj b/src/Services/Identity/LoveNet.Identity.Api/LoveNet.Identity.Api.csproj new file mode 100644 index 0000000..f7ba441 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/LoveNet.Identity.Api.csproj @@ -0,0 +1,27 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <ItemGroup> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.Contracts\LoveNet.BuildingBlocks.Contracts.csproj" /> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.EventBus\LoveNet.BuildingBlocks.EventBus.csproj" /> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.Web\LoveNet.BuildingBlocks.Web.csproj" /> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.Media\LoveNet.BuildingBlocks.Media.csproj" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" /> + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" /> + <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" /> + </ItemGroup> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + </PropertyGroup> + + <ItemGroup> + <Content Update="Data\Files\Users.json" CopyToOutputDirectory="PreserveNewest" /> + </ItemGroup> + +</Project> diff --git a/src/Services/Identity/LoveNet.Identity.Api/Models/LoginModel.cs b/src/Services/Identity/LoveNet.Identity.Api/Models/LoginModel.cs new file mode 100644 index 0000000..6960969 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Models/LoginModel.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace LoveNet.Identity.Api.Models; + +public class LoginModel +{ + [Required] + [StringLength(100)] + [EmailAddress] + public string Email { get; set; } = null!; + + [Required] + [MinLength(5)] + public string Password { get; set; } = null!; +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Models/LoginResponseModel.cs b/src/Services/Identity/LoveNet.Identity.Api/Models/LoginResponseModel.cs new file mode 100644 index 0000000..836429b --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Models/LoginResponseModel.cs @@ -0,0 +1,33 @@ +namespace LoveNet.Identity.Api.Models; + +/// <summary> +/// Same shape as the monolith's LoginResponseModel — the SPA persists this in +/// localStorage. Profile fields come from the local read model (see +/// UserProfileLite), which is eventually consistent with the Profile service. +/// </summary> +public class LoginResponseModel +{ + public string Id { get; set; } = null!; + + public string Token { get; set; } = null!; + + public string Email { get; set; } = null!; + + public string UserName { get; set; } = null!; + + public bool IsAdmin { get; set; } + + public string? Bio { get; set; } + + public DateTime Birthdate { get; set; } + + public string? CountryName { get; set; } + + public string? CityName { get; set; } + + public double Latitude { get; set; } + + public double Longitude { get; set; } + + public string? ProfilePicture { get; set; } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Models/ModerateUserModel.cs b/src/Services/Identity/LoveNet.Identity.Api/Models/ModerateUserModel.cs new file mode 100644 index 0000000..afb6680 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Models/ModerateUserModel.cs @@ -0,0 +1,9 @@ +namespace LoveNet.Identity.Api.Models; + +public class ModerateUserModel +{ + public string UserId { get; set; } = null!; + + /// <summary>Lockout end; null lifts the ban.</summary> + public DateTimeOffset? BannedUntil { get; set; } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Models/RegisterModel.cs b/src/Services/Identity/LoveNet.Identity.Api/Models/RegisterModel.cs new file mode 100644 index 0000000..c3be9ab --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Models/RegisterModel.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; +using LoveNet.BuildingBlocks.Web; +using LoveNet.BuildingBlocks.Web.Validation; + +namespace LoveNet.Identity.Api.Models; + +/// <summary>Multipart registration form — same field names the SPA already posts.</summary> +public class RegisterModel +{ + [Required] + [StringLength(100)] + [EmailAddress] + public string Email { get; set; } = null!; + + [Required] + [MinLength(5)] + public string Password { get; set; } = null!; + + [Required] + [StringLength(50, MinimumLength = 5)] + public string ConfirmPassword { get; set; } = null!; + + [Required] + [StringLength(100)] + public string UserName { get; set; } = null!; + + [Required] + [MaxLength(255)] + public string Bio { get; set; } = null!; + + public DateTime Birthdate { get; set; } + + public int CountryId { get; set; } + + public int GenderId { get; set; } + + public int CityId { get; set; } + + [MaxFileSize(LoveNetConstants.MaxFileSizeInBytes)] + [AllowedFileExtensions(new[] { ".jpg", ".png" })] + public IFormFile? Image { get; set; } + + [MaxFileSize(LoveNetConstants.MaxFileSizeInBytes)] + [AllowedFileExtensions(new[] { ".jpg", ".png" })] + public IFormFile[]? NewImages { get; set; } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Models/ResetPasswordModel.cs b/src/Services/Identity/LoveNet.Identity.Api/Models/ResetPasswordModel.cs new file mode 100644 index 0000000..531cce4 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Models/ResetPasswordModel.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace LoveNet.Identity.Api.Models; + +public class ResetPasswordModel +{ + [Required] + public string Token { get; set; } = null!; + + [Required] + public string Email { get; set; } = null!; + + [Required] + [MinLength(5)] + public string Password { get; set; } = null!; + + [Required] + [MinLength(5)] + public string ConfirmPassword { get; set; } = null!; +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Program.cs b/src/Services/Identity/LoveNet.Identity.Api/Program.cs new file mode 100644 index 0000000..ef61a71 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Program.cs @@ -0,0 +1,56 @@ +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.Media; +using LoveNet.BuildingBlocks.Web; +using LoveNet.BuildingBlocks.Web.Auth; +using LoveNet.Identity.Api.Consumers; +using LoveNet.Identity.Api.Data; +using LoveNet.Identity.Api.Data.Seeding; +using LoveNet.Identity.Api.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddLoveNetDefaults("identity-api"); + +builder.Services.AddDbContext<AppIdentityDbContext>(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("Default"))); + +builder.Services + .AddIdentityCore<ApplicationUser>(IdentityOptionsProvider.GetIdentityOptions) + .AddRoles<ApplicationRole>() + .AddEntityFrameworkStores<AppIdentityDbContext>() + .AddDefaultTokenProviders(); + +builder.Services.AddLoveNetJwt(builder.Configuration); +builder.Services.AddKafkaEventBus(builder.Configuration); +builder.Services.AddOutboxPublisher<AppIdentityDbContext>(); +builder.Services.AddHostedService<UserUpdatedConsumer>(); +builder.Services.AddCloudinaryMedia(builder.Configuration); + +builder.Services.AddScoped<IIdentityService, IdentityService>(); +builder.Services.AddScoped<IEmailFlowService, EmailFlowService>(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddLoveNetHealthChecks(builder.Configuration, postgres: true, kafka: true); + +var app = builder.Build(); + +app.UseLoveNetDefaults(); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.MapLoveNetHealth(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService<AppIdentityDbContext>(); + await db.Database.MigrateAsync(); + await IdentitySeeder.SeedAsync(scope.ServiceProvider); +} + +await app.RunAsync(); diff --git a/src/Services/Identity/LoveNet.Identity.Api/Properties/launchSettings.json b/src/Services/Identity/LoveNet.Identity.Api/Properties/launchSettings.json new file mode 100644 index 0000000..2461d76 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:23723", + "sslPort": 44373 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5198", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7106;http://localhost:5198", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Services/EmailFlowService.cs b/src/Services/Identity/LoveNet.Identity.Api/Services/EmailFlowService.cs new file mode 100644 index 0000000..df6567f --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Services/EmailFlowService.cs @@ -0,0 +1,131 @@ +using System.Text; +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.Web; +using LoveNet.Identity.Api.Data; +using LoveNet.Identity.Api.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.WebUtilities; + +namespace LoveNet.Identity.Api.Services; + +public class EmailFlowService : IEmailFlowService +{ + // {0}=origin {1}=route {2}=token {3}=email — same link format the SPA parses. + private const string LinkFormat = "{0}/{1}?token={2}&email={3}"; + private const string VerifyRoute = "verify"; + private const string ResetPasswordRoute = "resetPassword"; + + private readonly UserManager<ApplicationUser> userManager; + private readonly AppIdentityDbContext db; + + public EmailFlowService(UserManager<ApplicationUser> userManager, AppIdentityDbContext db) + { + this.userManager = userManager; + this.db = db; + } + + public async Task SendEmailConfirmationAsync(string? origin, ApplicationUser user, string? correlationId) + { + var token = await userManager.GenerateEmailConfirmationTokenAsync(user); + token = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token)); + + var link = string.Format(LinkFormat, origin, VerifyRoute, token, user.Email); + + db.AddOutboxMessage( + KafkaTopics.EmailRequested, + user.Id, + new EmailRequestedEvent(EmailType.ConfirmEmail, user.Email!, link), + correlationId); + await db.SaveChangesAsync(); + } + + public async Task<Result> SendResetPasswordLinkAsync(string email, string? origin, string? correlationId) + { + var user = await userManager.FindByEmailAsync(email); + + if (user is null) + { + return IdentityMessages.EmailDoesntMatch; + } + + var token = await userManager.GeneratePasswordResetTokenAsync(user); + token = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token)); + + var link = string.Format(LinkFormat, origin, ResetPasswordRoute, token, user.Email); + + db.AddOutboxMessage( + KafkaTopics.EmailRequested, + user.Id, + new EmailRequestedEvent(EmailType.ResetPassword, user.Email!, link), + correlationId); + await db.SaveChangesAsync(); + + return true; + } + + public async Task<Result> ResendEmailConfirmationLinkAsync(string email, string? origin, string? correlationId) + { + var user = await userManager.FindByEmailAsync(email); + + if (user is null) + { + return IdentityMessages.EmailDoesntMatch; + } + + // Quirk ported from the monolith: LockoutEnabled doubles as the + // "email verified" marker (set by VerifyEmailAsync below). + if (user.LockoutEnabled) + { + return IdentityMessages.EmailAlreadyVerified; + } + + await SendEmailConfirmationAsync(origin, user, correlationId); + + return true; + } + + public async Task<Result> VerifyEmailAsync(string email, string token) + { + var user = await userManager.FindByEmailAsync(email); + + if (user is null) + { + return "Unauthorized"; + } + + var decodedToken = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(token)); + var result = await userManager.ConfirmEmailAsync(user, decodedToken); + + if (!result.Succeeded) + { + return IdentityMessages.IncorrectEmail; + } + + user.LockoutEnabled = true; + await userManager.UpdateAsync(user); + + return true; + } + + public async Task<Result> ResetPasswordAsync(ResetPasswordModel model) + { + var user = await userManager.FindByEmailAsync(model.Email); + + if (user is null) + { + return "Unauthorized"; + } + + var decodedToken = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(model.Token)); + var result = await userManager.ResetPasswordAsync(user, decodedToken, model.Password); + + if (!result.Succeeded) + { + return string.Join("\n", result.Errors.Select(x => x.Description)); + } + + return true; + } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Services/IEmailFlowService.cs b/src/Services/Identity/LoveNet.Identity.Api/Services/IEmailFlowService.cs new file mode 100644 index 0000000..6462905 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Services/IEmailFlowService.cs @@ -0,0 +1,22 @@ +using LoveNet.BuildingBlocks.Web; +using LoveNet.Identity.Api.Data; +using LoveNet.Identity.Api.Models; + +namespace LoveNet.Identity.Api.Services; + +/// <summary> +/// Owns UserManager token generation/validation for the email flows. Actual +/// delivery happens in the Notifications service via email-requested events. +/// </summary> +public interface IEmailFlowService +{ + Task SendEmailConfirmationAsync(string? origin, ApplicationUser user, string? correlationId); + + Task<Result> SendResetPasswordLinkAsync(string email, string? origin, string? correlationId); + + Task<Result> ResendEmailConfirmationLinkAsync(string email, string? origin, string? correlationId); + + Task<Result> VerifyEmailAsync(string email, string token); + + Task<Result> ResetPasswordAsync(ResetPasswordModel model); +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Services/IIdentityService.cs b/src/Services/Identity/LoveNet.Identity.Api/Services/IIdentityService.cs new file mode 100644 index 0000000..5a8c7f9 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Services/IIdentityService.cs @@ -0,0 +1,19 @@ +using LoveNet.BuildingBlocks.Web; +using LoveNet.Identity.Api.Data; +using LoveNet.Identity.Api.Models; +using Microsoft.AspNetCore.Identity; + +namespace LoveNet.Identity.Api.Services; + +public interface IIdentityService +{ + Task<string> GenerateJwtToken(ApplicationUser user); + + Task<IdentityResult> RegisterAsync(RegisterModel model, string? correlationId); + + Task<LoginResponseModel> LoginAsync(LoginModel model); + + RefreshToken GenerateRefreshToken(); + + Task<Result> ModerateAsync(ModerateUserModel request, string? correlationId); +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/Services/IdentityService.cs b/src/Services/Identity/LoveNet.Identity.Api/Services/IdentityService.cs new file mode 100644 index 0000000..5e6f97e --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/Services/IdentityService.cs @@ -0,0 +1,232 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.Media; +using LoveNet.BuildingBlocks.Web; +using LoveNet.BuildingBlocks.Web.Auth; +using LoveNet.Identity.Api.Data; +using LoveNet.Identity.Api.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace LoveNet.Identity.Api.Services; + +public class IdentityService : IIdentityService +{ + private readonly UserManager<ApplicationUser> userManager; + private readonly AppIdentityDbContext db; + private readonly IImagesService imagesService; + private readonly JwtOptions jwtOptions; + + public IdentityService( + UserManager<ApplicationUser> userManager, + AppIdentityDbContext db, + IImagesService imagesService, + IOptions<JwtOptions> jwtOptions) + { + this.userManager = userManager; + this.db = db; + this.imagesService = imagesService; + this.jwtOptions = jwtOptions.Value; + } + + public async Task<string> GenerateJwtToken(ApplicationUser user) + { + var claims = new List<Claim> + { + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(ClaimTypes.Email, user.Email!), + new(ClaimTypes.Name, user.UserName!), + new(ClaimTypes.NameIdentifier, user.Id), + }; + + var roles = await userManager.GetRolesAsync(user); + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.Key)); + var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha512); + + var securityToken = new JwtSecurityToken( + issuer: jwtOptions.Issuer, + audience: jwtOptions.Audience, + claims: claims, + expires: DateTime.UtcNow.AddHours(1), + signingCredentials: signingCredentials); + + return new JwtSecurityTokenHandler().WriteToken(securityToken); + } + + public async Task<IdentityResult> RegisterAsync(RegisterModel model, string? correlationId) + { + var images = new List<IFormFile>(); + + if (model.Image is not null) + { + images.Add(model.Image); + } + + if (model.NewImages?.Any() == true) + { + images.AddRange(model.NewImages); + } + + var imageUrls = new List<string>(await imagesService.UploadImagesAsync(images)); + + if (model.Image is null) + { + imageUrls.Insert(0, LoveNetConstants.DefaultProfilePictureUrl); + } + + var user = new ApplicationUser + { + Email = model.Email, + UserName = model.UserName, + CreatedOn = DateTime.UtcNow, + }; + + // The user row and the outbox event must commit together, otherwise a + // crash could leave an account that no other service knows about. + IdentityResult result; + + if (db.Database.IsRelational()) + { + await using var transaction = await db.Database.BeginTransactionAsync(); + result = await CreateUserWithEventsAsync(user, model, imageUrls, correlationId); + + if (result.Succeeded) + { + await transaction.CommitAsync(); + } + } + else + { + result = await CreateUserWithEventsAsync(user, model, imageUrls, correlationId); + } + + return result; + } + + public async Task<LoginResponseModel> LoginAsync(LoginModel model) + { + var user = await userManager.Users + .Include(u => u.RefreshTokens) + .SingleOrDefaultAsync(u => u.Email == model.Email) + ?? throw new ArgumentException(IdentityMessages.UserNotFound); + + var isValidPassword = await userManager.CheckPasswordAsync(user, model.Password); + + if (!isValidPassword) + { + throw new ArgumentException(IdentityMessages.WrongPassword); + } + + if (!user.EmailConfirmed) + { + throw new ArgumentException(IdentityMessages.EmailNotConfirmed); + } + + if (user.LockoutEnd != null) + { + throw new ArgumentException(string.Format(IdentityMessages.BannedUntil, user.LockoutEnd)); + } + + var token = await GenerateJwtToken(user); + var userRoles = await userManager.GetRolesAsync(user); + var profile = await db.UserProfiles.AsNoTracking().FirstOrDefaultAsync(p => p.UserId == user.Id); + + return new LoginResponseModel + { + Id = user.Id, + Token = token, + Email = user.Email!, + UserName = user.UserName!, + IsAdmin = userRoles.Contains(LoveNetConstants.AdministratorRoleName), + Bio = profile?.Bio, + Birthdate = profile?.Birthdate ?? default, + CountryName = profile?.CountryName, + CityName = profile?.CityName, + Latitude = profile?.Latitude ?? 0, + Longitude = profile?.Longitude ?? 0, + ProfilePicture = profile?.AvatarUrl, + }; + } + + public RefreshToken GenerateRefreshToken() + { + var randomNumber = new byte[32]; + using var rng = RandomNumberGenerator.Create(); + rng.GetBytes(randomNumber); + + return new RefreshToken + { + Token = Convert.ToBase64String(randomNumber), + Expires = DateTime.UtcNow.AddHours(1.5), + CreatedOn = DateTime.UtcNow, + }; + } + + public async Task<Result> ModerateAsync(ModerateUserModel request, string? correlationId) + { + var user = await userManager.FindByIdAsync(request.UserId); + + if (user is null) + { + return IdentityMessages.UserNotFound; + } + + var result = await userManager.SetLockoutEndDateAsync(user, request.BannedUntil); + + if (!result.Succeeded) + { + return IdentityMessages.UserCouldNotBeBanned; + } + + db.AddOutboxMessage( + KafkaTopics.UserBanned, + user.Id, + new UserBannedEvent(user.Id, request.BannedUntil), + correlationId); + await db.SaveChangesAsync(); + + return true; + } + + private async Task<IdentityResult> CreateUserWithEventsAsync( + ApplicationUser user, + RegisterModel model, + IReadOnlyList<string> imageUrls, + string? correlationId) + { + var result = await userManager.CreateAsync(user, model.Password); + + if (!result.Succeeded) + { + return result; + } + + db.AddOutboxMessage( + KafkaTopics.UserRegistered, + user.Id, + new UserRegisteredEvent( + user.Id, + user.Email!, + user.UserName!, + model.Bio, + model.Birthdate, + model.GenderId, + model.CityId, + model.CountryId, + imageUrls), + correlationId); + + await db.SaveChangesAsync(); + + return result; + } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/appsettings.Development.json b/src/Services/Identity/LoveNet.Identity.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Services/Identity/LoveNet.Identity.Api/appsettings.json b/src/Services/Identity/LoveNet.Identity.Api/appsettings.json new file mode 100644 index 0000000..6780a3f --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Api/appsettings.json @@ -0,0 +1,34 @@ +{ + "ConnectionStrings": { + "Default": "Host=localhost;Port=5433;Database=lovenet_identity;Username=lovenet;Password=lovenet" + }, + "Kafka": { + "BootstrapServers": "localhost:29092" + }, + "Jwt": { + "Key": "lovenet-local-development-signing-key-do-not-use-in-production-0123456789abcdef", + "Issuer": "https://localhost:3000/", + "Audience": "https://localhost:3000/" + }, + "Cloudinary": { + "CloudName": "", + "Key": "", + "Secret": "" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Services/Identity/LoveNet.Identity.Tests/IdentityServiceTests.cs b/src/Services/Identity/LoveNet.Identity.Tests/IdentityServiceTests.cs new file mode 100644 index 0000000..a9613fb --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Tests/IdentityServiceTests.cs @@ -0,0 +1,273 @@ +using LoveNet.BuildingBlocks.EventBus.Outbox; +using LoveNet.BuildingBlocks.Media; +using LoveNet.BuildingBlocks.Web; +using LoveNet.BuildingBlocks.Web.Auth; +using LoveNet.Identity.Api; +using LoveNet.Identity.Api.Data; +using LoveNet.Identity.Api.Models; +using LoveNet.Identity.Api.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; + +namespace LoveNet.Identity.Tests; + +[TestFixture] +public class IdentityServiceTests +{ + private const string Password = "123456"; + + private AppIdentityDbContext db = null!; + private UserManager<ApplicationUser> userManager = null!; + private IdentityService identityService = null!; + private ApplicationUser user = null!; + + [SetUp] + public async Task Setup() + { + var options = new DbContextOptionsBuilder<AppIdentityDbContext>() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + db = new AppIdentityDbContext(options); + + var store = new UserStore<ApplicationUser, ApplicationRole, AppIdentityDbContext>(db); + userManager = new UserManager<ApplicationUser>( + store, + Options.Create(new IdentityOptions()), + new PasswordHasher<ApplicationUser>(), + Array.Empty<IUserValidator<ApplicationUser>>(), + Array.Empty<IPasswordValidator<ApplicationUser>>(), + new UpperInvariantLookupNormalizer(), + new IdentityErrorDescriber(), + null!, + NullLogger<UserManager<ApplicationUser>>.Instance); + + var imagesService = new Mock<IImagesService>(); + imagesService + .Setup(s => s.UploadImagesAsync(It.IsAny<IEnumerable<IFormFile>>())) + .ReturnsAsync(Enumerable.Empty<string>()); + + var jwtOptions = Options.Create(new JwtOptions + { + Key = new string('k', 80), + Issuer = "https://localhost:3000/", + Audience = "https://localhost:3000/", + }); + + identityService = new IdentityService(userManager, db, imagesService.Object, jwtOptions); + + user = new ApplicationUser + { + Email = "user@lovenet.test", + UserName = "testuser", + EmailConfirmed = true, + }; + await userManager.CreateAsync(user, Password); + } + + [TearDown] + public void TearDown() + { + userManager.Dispose(); + db.Dispose(); + } + + [Test] + public async Task SuccessGenerateJwtToken() + { + var token = await identityService.GenerateJwtToken(user); + + Assert.That(token, Is.Not.Null.And.Not.Empty); + Assert.That(token.Split('.'), Has.Length.EqualTo(3)); + } + + [Test] + public async Task SuccessLogin() + { + var response = await identityService.LoginAsync(new LoginModel + { + Email = user.Email!, + Password = Password, + }); + + Assert.Multiple(() => + { + Assert.That(response.Id, Is.EqualTo(user.Id)); + Assert.That(response.Token, Is.Not.Empty); + Assert.That(response.IsAdmin, Is.False); + }); + } + + [Test] + public async Task SuccessLoginIncludesProfileReadModel() + { + db.UserProfiles.Add(new UserProfileLite + { + UserId = user.Id, + Bio = "hello", + CityName = "Sofia", + Latitude = 42.7, + Longitude = 23.3, + AvatarUrl = "https://img/pfp.png", + }); + await db.SaveChangesAsync(); + + var response = await identityService.LoginAsync(new LoginModel + { + Email = user.Email!, + Password = Password, + }); + + Assert.Multiple(() => + { + Assert.That(response.CityName, Is.EqualTo("Sofia")); + Assert.That(response.ProfilePicture, Is.EqualTo("https://img/pfp.png")); + Assert.That(response.Latitude, Is.EqualTo(42.7)); + }); + } + + [Test] + public void FailLoginIncorrectPassword() + { + var ex = Assert.ThrowsAsync<ArgumentException>(() => identityService.LoginAsync(new LoginModel + { + Email = user.Email!, + Password = "wrong-password", + })); + + Assert.That(ex!.Message, Is.EqualTo(IdentityMessages.WrongPassword)); + } + + [Test] + public async Task FailLoginNotConfirmedEmail() + { + user.EmailConfirmed = false; + await userManager.UpdateAsync(user); + + var ex = Assert.ThrowsAsync<ArgumentException>(() => identityService.LoginAsync(new LoginModel + { + Email = user.Email!, + Password = Password, + })); + + Assert.That(ex!.Message, Is.EqualTo(IdentityMessages.EmailNotConfirmed)); + } + + [Test] + public void FailLoginNotExistingEmail() + { + var ex = Assert.ThrowsAsync<ArgumentException>(() => identityService.LoginAsync(new LoginModel + { + Email = "missing@lovenet.test", + Password = Password, + })); + + Assert.That(ex!.Message, Is.EqualTo(IdentityMessages.UserNotFound)); + } + + [Test] + public async Task FailLoginBannedUser() + { + user.LockoutEnd = DateTimeOffset.UtcNow.AddDays(1); + await userManager.UpdateAsync(user); + + Assert.ThrowsAsync<ArgumentException>(() => identityService.LoginAsync(new LoginModel + { + Email = user.Email!, + Password = Password, + })); + } + + [Test] + public void SuccessGenerateRefreshToken() + { + var token = identityService.GenerateRefreshToken(); + + Assert.Multiple(() => + { + Assert.That(token.Token, Is.Not.Empty); + Assert.That(token.IsActive, Is.True); + Assert.That(token.Expires, Is.GreaterThan(DateTime.UtcNow)); + }); + } + + [Test] + public async Task SuccessRegisterEmitsUserRegisteredOutboxEvent() + { + var result = await identityService.RegisterAsync( + new RegisterModel + { + Email = "new@lovenet.test", + Password = Password, + ConfirmPassword = Password, + UserName = "newcomer", + Bio = "hi", + Birthdate = DateTime.UtcNow.AddYears(-20), + GenderId = 1, + CityId = 1, + CountryId = 1, + }, + correlationId: null); + + Assert.That(result.Succeeded, Is.True); + + var outbox = await db.Set<OutboxMessage>().SingleAsync(); + Assert.Multiple(() => + { + Assert.That(outbox.Topic, Is.EqualTo("identity.user-registered")); + Assert.That(outbox.Payload, Does.Contain("newcomer")); + // No uploaded image → the default profile picture leads the list. + Assert.That(outbox.Payload, Does.Contain(LoveNetConstants.DefaultProfilePictureUrl)); + }); + } + + [Test] + public async Task SuccessBanUserEmitsUserBannedOutboxEvent() + { + var result = await identityService.ModerateAsync( + new ModerateUserModel { UserId = user.Id, BannedUntil = DateTimeOffset.UtcNow.AddDays(3) }, + correlationId: null); + + Assert.That(result.Succeeded, Is.True); + + var updated = await userManager.FindByIdAsync(user.Id); + Assert.That(updated!.LockoutEnd, Is.Not.Null); + + var outbox = await db.Set<OutboxMessage>().SingleAsync(); + Assert.That(outbox.Topic, Is.EqualTo("identity.user-banned")); + } + + [Test] + public async Task SuccessUnbanUser() + { + await userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddDays(3)); + + var result = await identityService.ModerateAsync( + new ModerateUserModel { UserId = user.Id, BannedUntil = null }, + correlationId: null); + + Assert.That(result.Succeeded, Is.True); + + var updated = await userManager.FindByIdAsync(user.Id); + Assert.That(updated!.LockoutEnd, Is.Null); + } + + [Test] + public async Task FailModerateUnknownUser() + { + var result = await identityService.ModerateAsync( + new ModerateUserModel { UserId = Guid.NewGuid().ToString(), BannedUntil = null }, + correlationId: null); + + Assert.Multiple(() => + { + Assert.That(result.Failure, Is.True); + Assert.That(result.Errors, Does.Contain(IdentityMessages.UserNotFound)); + }); + } +} diff --git a/src/Services/Identity/LoveNet.Identity.Tests/LoveNet.Identity.Tests.csproj b/src/Services/Identity/LoveNet.Identity.Tests/LoveNet.Identity.Tests.csproj new file mode 100644 index 0000000..2af9717 --- /dev/null +++ b/src/Services/Identity/LoveNet.Identity.Tests/LoveNet.Identity.Tests.csproj @@ -0,0 +1,30 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="coverlet.collector" Version="6.0.0" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> + <PackageReference Include="Moq" Version="4.20.70" /> + <PackageReference Include="NUnit" Version="3.14.0" /> + <PackageReference Include="NUnit.Analyzers" Version="3.9.0" /> + <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> + </ItemGroup> + + <ItemGroup> + <Using Include="NUnit.Framework" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\LoveNet.Identity.Api\LoveNet.Identity.Api.csproj" /> + </ItemGroup> + +</Project> diff --git a/src/Services/Matching/LoveNet.Matching.Api/Consumers/UserUpdatedConsumer.cs b/src/Services/Matching/LoveNet.Matching.Api/Consumers/UserUpdatedConsumer.cs new file mode 100644 index 0000000..74d34e5 --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Api/Consumers/UserUpdatedConsumer.cs @@ -0,0 +1,62 @@ +using Confluent.Kafka; +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.EventBus.Consuming; +using LoveNet.Matching.Api.Data; +using Microsoft.Extensions.Options; + +namespace LoveNet.Matching.Api.Consumers; + +/// <summary>Maintains the candidate_profiles read model for the swipe deck.</summary> +public class UserUpdatedConsumer : KafkaConsumerService<UserUpdatedEvent> +{ + private const string ConsumerName = "matching-service"; + + public UserUpdatedConsumer( + IServiceScopeFactory scopeFactory, + IProducer<string, string> producer, + IOptions<KafkaOptions> options, + ILogger<UserUpdatedConsumer> logger) + : base(scopeFactory, producer, options, logger, KafkaTopics.UserUpdated, ConsumerName) + { + } + + protected override async Task HandleAsync(IServiceProvider services, EventEnvelope<UserUpdatedEvent> envelope, CancellationToken cancellationToken) + { + var db = services.GetRequiredService<MatchingDbContext>(); + + if (!await db.TryRegisterInboxAsync(envelope.MessageId, ConsumerName, cancellationToken)) + { + return; + } + + var data = envelope.Data; + var candidate = await db.CandidateProfiles.FindAsync(new object[] { data.UserId }, cancellationToken); + + if (candidate is null) + { + candidate = new CandidateProfile { UserId = data.UserId }; + db.CandidateProfiles.Add(candidate); + } + + candidate.UserName = data.UserName; + candidate.Email = data.Email; + candidate.Bio = data.Bio; + candidate.Birthdate = data.Birthdate; + candidate.GenderId = data.GenderId; + candidate.GenderName = data.GenderName; + candidate.CityId = data.CityId; + candidate.CityName = data.CityName; + candidate.CountryId = data.CountryId; + candidate.CountryName = data.CountryName; + candidate.Latitude = data.Latitude ?? 0; + candidate.Longitude = data.Longitude ?? 0; + candidate.AvatarUrl = data.AvatarUrl; + candidate.ImageUrls = data.ImageUrls.ToList(); + candidate.IsBanned = data.IsBanned; + candidate.IsAdmin = data.IsAdmin; + + await db.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Services/Matching/LoveNet.Matching.Api/Controllers/DatingController.cs b/src/Services/Matching/LoveNet.Matching.Api/Controllers/DatingController.cs new file mode 100644 index 0000000..f1610b1 --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Api/Controllers/DatingController.cs @@ -0,0 +1,68 @@ +using LoveNet.BuildingBlocks.Web; +using LoveNet.BuildingBlocks.Web.Auth; +using LoveNet.BuildingBlocks.Web.Middleware; +using LoveNet.Matching.Api.Models; +using LoveNet.Matching.Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LoveNet.Matching.Api.Controllers; + +[Route("api/dating")] +[ApiController] +public class DatingController : ControllerBase +{ + private readonly IMatchingService matchingService; + + public DatingController(IMatchingService matchingService) + { + this.matchingService = matchingService; + } + + [HttpGet] + [Authorize] + public async Task<IActionResult> GetNotSwipedUsersAsync() + { + var notSwipedUsers = await matchingService.GetNotSwipedUsersAsync(User.GetUserId()); + + return Ok(notSwipedUsers); + } + + [HttpPost("matches")] + [Authorize] + public async Task<IActionResult> GetMatches([FromBody] MatchesRequestModel request) + { + if (string.IsNullOrEmpty(request.UserId) || !Guid.TryParse(request.UserId, out _)) + { + return BadRequest(); + } + + if (User.GetUserId() != request.UserId) + { + return Forbid(); + } + + return Ok(await matchingService.GetMatchesAsync(request)); + } + + [HttpPost("like/{id:guid}")] + [Authorize] + public async Task<IActionResult> LikeAsync(string id) + { + var serviceResult = await matchingService.LikeAsync(User.GetUserId(), id, HttpContext.GetCorrelationId()); + + if (serviceResult.Errors.Length > 0) + { + return BadRequest((Result)string.Join('\n', serviceResult.Errors)); + } + + var result = new MatchResponseModel { IsMatch = serviceResult.Succeeded }; + + if (result.IsMatch) + { + result.User = await matchingService.GetCurrentMatchAsync(id); + } + + return Ok(result); + } +} diff --git a/src/Services/Matching/LoveNet.Matching.Api/Controllers/InternalController.cs b/src/Services/Matching/LoveNet.Matching.Api/Controllers/InternalController.cs new file mode 100644 index 0000000..3f31b66 --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Api/Controllers/InternalController.cs @@ -0,0 +1,28 @@ +using LoveNet.Matching.Api.Data; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Matching.Api.Controllers; + +/// <summary>Gateway-only endpoints — not routed publicly.</summary> +[Route("internal")] +[ApiController] +public class InternalController : ControllerBase +{ + private readonly MatchingDbContext db; + + public InternalController(MatchingDbContext db) + { + this.db = db; + } + + [HttpGet("stats")] + public async Task<IActionResult> GetStats() + { + return Ok(new + { + MatchesCount = await db.Matches.CountAsync(), + LikedUsersCount = await db.Likes.Select(l => l.LikedUserId).Distinct().CountAsync(), + }); + } +} diff --git a/src/Services/Matching/LoveNet.Matching.Api/Data/CandidateProfile.cs b/src/Services/Matching/LoveNet.Matching.Api/Data/CandidateProfile.cs new file mode 100644 index 0000000..763ba23 --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Api/Data/CandidateProfile.cs @@ -0,0 +1,43 @@ +namespace LoveNet.Matching.Api.Data; + +/// <summary> +/// Local read model of user profiles, maintained from profile.user-updated +/// events. Lets the swipe deck and match list render without calling Profile. +/// </summary> +public class CandidateProfile +{ + public string UserId { get; set; } = null!; + + public string UserName { get; set; } = null!; + + public string Email { get; set; } = null!; + + public string? Bio { get; set; } + + public DateTime Birthdate { get; set; } + + public int GenderId { get; set; } + + public string? GenderName { get; set; } + + public int CityId { get; set; } + + public string? CityName { get; set; } + + public int CountryId { get; set; } + + public string? CountryName { get; set; } + + public double Latitude { get; set; } + + public double Longitude { get; set; } + + public string? AvatarUrl { get; set; } + + /// <summary>Profile-picture first (EF Core 8 primitive collection).</summary> + public List<string> ImageUrls { get; set; } = new(); + + public bool IsBanned { get; set; } + + public bool IsAdmin { get; set; } +} diff --git a/src/Services/Matching/LoveNet.Matching.Api/Data/Like.cs b/src/Services/Matching/LoveNet.Matching.Api/Data/Like.cs new file mode 100644 index 0000000..2630f10 --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Api/Data/Like.cs @@ -0,0 +1,13 @@ +namespace LoveNet.Matching.Api.Data; + +/// <summary>One row per directed like; a reciprocal pair creates a Match.</summary> +public class Like +{ + public int Id { get; set; } + + public string UserId { get; set; } = null!; + + public string LikedUserId { get; set; } = null!; + + public DateTime CreatedOn { get; set; } +} diff --git a/src/Services/Matching/LoveNet.Matching.Api/Data/Match.cs b/src/Services/Matching/LoveNet.Matching.Api/Data/Match.cs new file mode 100644 index 0000000..862e396 --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Api/Data/Match.cs @@ -0,0 +1,22 @@ +namespace LoveNet.Matching.Api.Data; + +/// <summary> +/// Persisted match (the monolith derived these on the fly). RoomId keeps the +/// legacy convention: both user ids sorted ascending, concatenated — Chat and +/// Realtime rely on that format for their fallback authorization. +/// </summary> +public class Match +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + + public string UserAId { get; set; } = null!; + + public string UserBId { get; set; } = null!; + + public string RoomId { get; set; } = null!; + + public DateTime CreatedOn { get; set; } + + public static string BuildRoomId(string userId, string otherUserId) => + string.Join(string.Empty, new[] { userId, otherUserId }.OrderBy(id => id)); +} diff --git a/src/Services/Matching/LoveNet.Matching.Api/Data/MatchingDbContext.cs b/src/Services/Matching/LoveNet.Matching.Api/Data/MatchingDbContext.cs new file mode 100644 index 0000000..4e478e7 --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Api/Data/MatchingDbContext.cs @@ -0,0 +1,45 @@ +using LoveNet.BuildingBlocks.EventBus; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Matching.Api.Data; + +public class MatchingDbContext : DbContext +{ + public MatchingDbContext(DbContextOptions<MatchingDbContext> options) + : base(options) + { + } + + public DbSet<Like> Likes => Set<Like>(); + + public DbSet<Match> Matches => Set<Match>(); + + public DbSet<CandidateProfile> CandidateProfiles => Set<CandidateProfile>(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity<Like>(entity => + { + entity.ToTable("likes"); + entity.HasIndex(l => new { l.UserId, l.LikedUserId }).IsUnique(); + entity.HasIndex(l => l.LikedUserId); + }); + + modelBuilder.Entity<Match>(entity => + { + entity.ToTable("matches"); + entity.HasIndex(m => m.RoomId).IsUnique(); + entity.HasIndex(m => m.UserAId); + entity.HasIndex(m => m.UserBId); + }); + + modelBuilder.Entity<CandidateProfile>(entity => + { + entity.ToTable("candidate_profiles"); + entity.HasKey(p => p.UserId); + }); + + modelBuilder.AddOutbox(); + modelBuilder.AddInbox(); + } +} diff --git a/src/Services/Matching/LoveNet.Matching.Api/Data/Migrations/20260713075708_InitialCreate.Designer.cs b/src/Services/Matching/LoveNet.Matching.Api/Data/Migrations/20260713075708_InitialCreate.Designer.cs new file mode 100644 index 0000000..f1d0d87 --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Api/Data/Migrations/20260713075708_InitialCreate.Designer.cs @@ -0,0 +1,209 @@ +// <auto-generated /> +using System; +using System.Collections.Generic; +using LoveNet.Matching.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LoveNet.Matching.Api.Data.Migrations +{ + [DbContext(typeof(MatchingDbContext))] + [Migration("20260713075708_InitialCreate")] + partial class InitialCreate + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LoveNet.BuildingBlocks.EventBus.Inbox.InboxMessage", b => + { + b.Property<Guid>("MessageId") + .HasColumnType("uuid"); + + b.Property<string>("Consumer") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<DateTime>("ProcessedOn") + .HasColumnType("timestamp without time zone"); + + b.HasKey("MessageId", "Consumer"); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.BuildingBlocks.EventBus.Outbox.OutboxMessage", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<int>("Attempts") + .HasColumnType("integer"); + + b.Property<string>("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<string>("LastError") + .HasColumnType("text"); + + b.Property<DateTime>("OccurredOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime?>("ProcessedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Topic") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedOn"); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.Matching.Api.Data.CandidateProfile", b => + { + b.Property<string>("UserId") + .HasColumnType("text"); + + b.Property<string>("AvatarUrl") + .HasColumnType("text"); + + b.Property<string>("Bio") + .HasColumnType("text"); + + b.Property<DateTime>("Birthdate") + .HasColumnType("timestamp without time zone"); + + b.Property<int>("CityId") + .HasColumnType("integer"); + + b.Property<string>("CityName") + .HasColumnType("text"); + + b.Property<int>("CountryId") + .HasColumnType("integer"); + + b.Property<string>("CountryName") + .HasColumnType("text"); + + b.Property<string>("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property<int>("GenderId") + .HasColumnType("integer"); + + b.Property<string>("GenderName") + .HasColumnType("text"); + + b.Property<List<string>>("ImageUrls") + .IsRequired() + .HasColumnType("text[]"); + + b.Property<bool>("IsAdmin") + .HasColumnType("boolean"); + + b.Property<bool>("IsBanned") + .HasColumnType("boolean"); + + b.Property<double>("Latitude") + .HasColumnType("double precision"); + + b.Property<double>("Longitude") + .HasColumnType("double precision"); + + b.Property<string>("UserName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId"); + + b.ToTable("candidate_profiles", (string)null); + }); + + modelBuilder.Entity("LoveNet.Matching.Api.Data.Like", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<DateTime>("CreatedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("LikedUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("LikedUserId"); + + b.HasIndex("UserId", "LikedUserId") + .IsUnique(); + + b.ToTable("likes", (string)null); + }); + + modelBuilder.Entity("LoveNet.Matching.Api.Data.Match", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<DateTime>("CreatedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("RoomId") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("UserAId") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("UserBId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoomId") + .IsUnique(); + + b.HasIndex("UserAId"); + + b.HasIndex("UserBId"); + + b.ToTable("matches", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Matching/LoveNet.Matching.Api/Data/Migrations/20260713075708_InitialCreate.cs b/src/Services/Matching/LoveNet.Matching.Api/Data/Migrations/20260713075708_InitialCreate.cs new file mode 100644 index 0000000..0a1e8ae --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Api/Data/Migrations/20260713075708_InitialCreate.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LoveNet.Matching.Api.Data.Migrations +{ + /// <inheritdoc /> + public partial class InitialCreate : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "candidate_profiles", + columns: table => new + { + UserId = table.Column<string>(type: "text", nullable: false), + UserName = table.Column<string>(type: "text", nullable: false), + Email = table.Column<string>(type: "text", nullable: false), + Bio = table.Column<string>(type: "text", nullable: true), + Birthdate = table.Column<DateTime>(type: "timestamp without time zone", nullable: false), + GenderId = table.Column<int>(type: "integer", nullable: false), + GenderName = table.Column<string>(type: "text", nullable: true), + CityId = table.Column<int>(type: "integer", nullable: false), + CityName = table.Column<string>(type: "text", nullable: true), + CountryId = table.Column<int>(type: "integer", nullable: false), + CountryName = table.Column<string>(type: "text", nullable: true), + Latitude = table.Column<double>(type: "double precision", nullable: false), + Longitude = table.Column<double>(type: "double precision", nullable: false), + AvatarUrl = table.Column<string>(type: "text", nullable: true), + ImageUrls = table.Column<List<string>>(type: "text[]", nullable: false), + IsBanned = table.Column<bool>(type: "boolean", nullable: false), + IsAdmin = table.Column<bool>(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_candidate_profiles", x => x.UserId); + }); + + migrationBuilder.CreateTable( + name: "inbox_messages", + columns: table => new + { + MessageId = table.Column<Guid>(type: "uuid", nullable: false), + Consumer = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false), + ProcessedOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_inbox_messages", x => new { x.MessageId, x.Consumer }); + }); + + migrationBuilder.CreateTable( + name: "likes", + columns: table => new + { + Id = table.Column<int>(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column<string>(type: "text", nullable: false), + LikedUserId = table.Column<string>(type: "text", nullable: false), + CreatedOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_likes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "matches", + columns: table => new + { + Id = table.Column<string>(type: "text", nullable: false), + UserAId = table.Column<string>(type: "text", nullable: false), + UserBId = table.Column<string>(type: "text", nullable: false), + RoomId = table.Column<string>(type: "text", nullable: false), + CreatedOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_matches", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "outbox_messages", + columns: table => new + { + Id = table.Column<Guid>(type: "uuid", nullable: false), + Topic = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false), + Key = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false), + Payload = table.Column<string>(type: "text", nullable: false), + OccurredOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: false), + ProcessedOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: true), + Attempts = table.Column<int>(type: "integer", nullable: false), + LastError = table.Column<string>(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_outbox_messages", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_likes_LikedUserId", + table: "likes", + column: "LikedUserId"); + + migrationBuilder.CreateIndex( + name: "IX_likes_UserId_LikedUserId", + table: "likes", + columns: new[] { "UserId", "LikedUserId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_matches_RoomId", + table: "matches", + column: "RoomId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_matches_UserAId", + table: "matches", + column: "UserAId"); + + migrationBuilder.CreateIndex( + name: "IX_matches_UserBId", + table: "matches", + column: "UserBId"); + + migrationBuilder.CreateIndex( + name: "IX_outbox_messages_ProcessedOn", + table: "outbox_messages", + column: "ProcessedOn"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "candidate_profiles"); + + migrationBuilder.DropTable( + name: "inbox_messages"); + + migrationBuilder.DropTable( + name: "likes"); + + migrationBuilder.DropTable( + name: "matches"); + + migrationBuilder.DropTable( + name: "outbox_messages"); + } + } +} diff --git a/src/Services/Matching/LoveNet.Matching.Api/Data/Migrations/MatchingDbContextModelSnapshot.cs b/src/Services/Matching/LoveNet.Matching.Api/Data/Migrations/MatchingDbContextModelSnapshot.cs new file mode 100644 index 0000000..5ac209c --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Api/Data/Migrations/MatchingDbContextModelSnapshot.cs @@ -0,0 +1,206 @@ +// <auto-generated /> +using System; +using System.Collections.Generic; +using LoveNet.Matching.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LoveNet.Matching.Api.Data.Migrations +{ + [DbContext(typeof(MatchingDbContext))] + partial class MatchingDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LoveNet.BuildingBlocks.EventBus.Inbox.InboxMessage", b => + { + b.Property<Guid>("MessageId") + .HasColumnType("uuid"); + + b.Property<string>("Consumer") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<DateTime>("ProcessedOn") + .HasColumnType("timestamp without time zone"); + + b.HasKey("MessageId", "Consumer"); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.BuildingBlocks.EventBus.Outbox.OutboxMessage", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<int>("Attempts") + .HasColumnType("integer"); + + b.Property<string>("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<string>("LastError") + .HasColumnType("text"); + + b.Property<DateTime>("OccurredOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime?>("ProcessedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Topic") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedOn"); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.Matching.Api.Data.CandidateProfile", b => + { + b.Property<string>("UserId") + .HasColumnType("text"); + + b.Property<string>("AvatarUrl") + .HasColumnType("text"); + + b.Property<string>("Bio") + .HasColumnType("text"); + + b.Property<DateTime>("Birthdate") + .HasColumnType("timestamp without time zone"); + + b.Property<int>("CityId") + .HasColumnType("integer"); + + b.Property<string>("CityName") + .HasColumnType("text"); + + b.Property<int>("CountryId") + .HasColumnType("integer"); + + b.Property<string>("CountryName") + .HasColumnType("text"); + + b.Property<string>("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property<int>("GenderId") + .HasColumnType("integer"); + + b.Property<string>("GenderName") + .HasColumnType("text"); + + b.Property<List<string>>("ImageUrls") + .IsRequired() + .HasColumnType("text[]"); + + b.Property<bool>("IsAdmin") + .HasColumnType("boolean"); + + b.Property<bool>("IsBanned") + .HasColumnType("boolean"); + + b.Property<double>("Latitude") + .HasColumnType("double precision"); + + b.Property<double>("Longitude") + .HasColumnType("double precision"); + + b.Property<string>("UserName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId"); + + b.ToTable("candidate_profiles", (string)null); + }); + + modelBuilder.Entity("LoveNet.Matching.Api.Data.Like", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<DateTime>("CreatedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("LikedUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("LikedUserId"); + + b.HasIndex("UserId", "LikedUserId") + .IsUnique(); + + b.ToTable("likes", (string)null); + }); + + modelBuilder.Entity("LoveNet.Matching.Api.Data.Match", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<DateTime>("CreatedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("RoomId") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("UserAId") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("UserBId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoomId") + .IsUnique(); + + b.HasIndex("UserAId"); + + b.HasIndex("UserBId"); + + b.ToTable("matches", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Matching/LoveNet.Matching.Api/Dockerfile b/src/Services/Matching/LoveNet.Matching.Api/Dockerfile new file mode 100644 index 0000000..d9d66ed --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Api/Dockerfile @@ -0,0 +1,17 @@ +# Build context is the repository root. +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY NuGet.config . +COPY src/Directory.Build.props src/ +COPY src/BuildingBlocks/ src/BuildingBlocks/ +COPY src/Services/Matching/ src/Services/Matching/ +RUN dotnet publish src/Services/Matching/LoveNet.Matching.Api/LoveNet.Matching.Api.csproj -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +# curl is used by the compose healthcheck. +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +COPY --from=build /app/publish . +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 +ENTRYPOINT ["dotnet", "LoveNet.Matching.Api.dll"] diff --git a/src/Services/Matching/LoveNet.Matching.Api/LoveNet.Matching.Api.csproj b/src/Services/Matching/LoveNet.Matching.Api/LoveNet.Matching.Api.csproj new file mode 100644 index 0000000..97ad9fc --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Api/LoveNet.Matching.Api.csproj @@ -0,0 +1,21 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <ItemGroup> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.Contracts\LoveNet.BuildingBlocks.Contracts.csproj" /> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.EventBus\LoveNet.BuildingBlocks.EventBus.csproj" /> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.Web\LoveNet.BuildingBlocks.Web.csproj" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" /> + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" /> + <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" /> + </ItemGroup> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + </PropertyGroup> + +</Project> diff --git a/src/Services/Matching/LoveNet.Matching.Api/Models/MatchingModels.cs b/src/Services/Matching/LoveNet.Matching.Api/Models/MatchingModels.cs new file mode 100644 index 0000000..256c60d --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Api/Models/MatchingModels.cs @@ -0,0 +1,73 @@ +using System.ComponentModel.DataAnnotations; +using LoveNet.BuildingBlocks.Web; + +namespace LoveNet.Matching.Api.Models; + +/// <summary>Card/match shape the SPA renders (same contract as the monolith's UserMatchViewModel).</summary> +public class UserMatchModel +{ + public string Id { get; set; } = null!; + + public string UserName { get; set; } = null!; + + public string? Bio { get; set; } + + public DateTime Birthdate { get; set; } + + public int Age => DateHelper.CalculateAge(Birthdate); + + public List<ImageModel> Images { get; set; } = new(); + + public int GenderId { get; set; } + + public string? GenderName { get; set; } + + public int CityId { get; set; } + + public string? CityName { get; set; } + + public int CountryId { get; set; } + + public string? CountryName { get; set; } + + public double Latitude { get; set; } + + public double Longitude { get; set; } + + public string? RoomId { get; set; } +} + +/// <summary>Synthesized from the read model's image URLs; ids are positional.</summary> +public class ImageModel +{ + public int Id { get; set; } + + public string Url { get; set; } = null!; + + public bool IsProfilePicture { get; set; } +} + +public class MatchesRequestModel +{ + [Required] + public string UserId { get; set; } = null!; + + public string? Search { get; set; } + + [Required] + public int Page { get; set; } +} + +public class MatchesResponseModel +{ + public IEnumerable<UserMatchModel> Matches { get; set; } = Enumerable.Empty<UserMatchModel>(); + + public int TotalMatches { get; set; } +} + +public class MatchResponseModel +{ + public bool IsMatch { get; set; } + + public UserMatchModel? User { get; set; } +} diff --git a/src/Services/Matching/LoveNet.Matching.Api/Program.cs b/src/Services/Matching/LoveNet.Matching.Api/Program.cs new file mode 100644 index 0000000..c4fa76d --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Api/Program.cs @@ -0,0 +1,44 @@ +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.Web; +using LoveNet.BuildingBlocks.Web.Auth; +using LoveNet.Matching.Api.Consumers; +using LoveNet.Matching.Api.Data; +using LoveNet.Matching.Api.Services; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddLoveNetDefaults("matching-api"); + +builder.Services.AddDbContext<MatchingDbContext>(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("Default"))); + +builder.Services.AddLoveNetJwt(builder.Configuration); +builder.Services.AddKafkaEventBus(builder.Configuration); +builder.Services.AddOutboxPublisher<MatchingDbContext>(); +builder.Services.AddHostedService<UserUpdatedConsumer>(); + +builder.Services.AddScoped<IMatchingService, MatchingService>(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddLoveNetHealthChecks(builder.Configuration, postgres: true, kafka: true); + +var app = builder.Build(); + +app.UseLoveNetDefaults(); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.MapLoveNetHealth(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService<MatchingDbContext>(); + await db.Database.MigrateAsync(); +} + +await app.RunAsync(); diff --git a/src/Services/Matching/LoveNet.Matching.Api/Properties/launchSettings.json b/src/Services/Matching/LoveNet.Matching.Api/Properties/launchSettings.json new file mode 100644 index 0000000..d04a338 --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Api/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:58608", + "sslPort": 44381 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5165", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7192;http://localhost:5165", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Matching/LoveNet.Matching.Api/Services/IMatchingService.cs b/src/Services/Matching/LoveNet.Matching.Api/Services/IMatchingService.cs new file mode 100644 index 0000000..dc0aa8a --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Api/Services/IMatchingService.cs @@ -0,0 +1,16 @@ +using LoveNet.BuildingBlocks.Web; +using LoveNet.Matching.Api.Models; + +namespace LoveNet.Matching.Api.Services; + +public interface IMatchingService +{ + Task<List<UserMatchModel>> GetNotSwipedUsersAsync(string userId); + + Task<MatchesResponseModel> GetMatchesAsync(MatchesRequestModel request); + + Task<UserMatchModel?> GetCurrentMatchAsync(string userId); + + /// <summary>Succeeded == a mutual match was created (monolith contract).</summary> + Task<Result> LikeAsync(string userId, string likedUserId, string? correlationId); +} diff --git a/src/Services/Matching/LoveNet.Matching.Api/Services/MatchingService.cs b/src/Services/Matching/LoveNet.Matching.Api/Services/MatchingService.cs new file mode 100644 index 0000000..0a12cd4 --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Api/Services/MatchingService.cs @@ -0,0 +1,187 @@ +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.Web; +using LoveNet.Matching.Api.Data; +using LoveNet.Matching.Api.Models; +using Microsoft.EntityFrameworkCore; +using Match = LoveNet.Matching.Api.Data.Match; + +namespace LoveNet.Matching.Api.Services; + +public class MatchingService : IMatchingService +{ + public const string UserNotFound = "User not found"; + public const string YouCantLikeYourself = "You can't like yourself"; + public const string UserAlreadyLiked = "User is already liked"; + + private readonly MatchingDbContext db; + + public MatchingService(MatchingDbContext db) + { + this.db = db; + } + + public async Task<List<UserMatchModel>> GetNotSwipedUsersAsync(string userId) + { + var candidates = await db.CandidateProfiles.AsNoTracking() + .Where(c => + c.UserId != userId && + !c.IsAdmin && + !db.Likes.Any(l => l.UserId == userId && l.LikedUserId == c.UserId)) + .ToListAsync(); + + return candidates.Select(c => ToModel(c)).ToList(); + } + + public async Task<MatchesResponseModel> GetMatchesAsync(MatchesRequestModel request) + { + var userId = request.UserId; + + var matchRooms = await db.Matches.AsNoTracking() + .Where(m => m.UserAId == userId || m.UserBId == userId) + .OrderByDescending(m => m.CreatedOn) + .Select(m => new { OtherUserId = m.UserAId == userId ? m.UserBId : m.UserAId, m.RoomId }) + .ToListAsync(); + + var matchedIds = matchRooms.Select(m => m.OtherUserId).ToList(); + + var matches = db.CandidateProfiles.AsNoTracking() + .Where(c => matchedIds.Contains(c.UserId)); + + if (!string.IsNullOrEmpty(request.Search)) + { + var searchTerm = $"%{request.Search.Trim().ToLower()}%"; + + matches = matches.Where(c => + EF.Functions.Like(c.UserName.ToLower(), searchTerm) || + EF.Functions.Like(c.Bio!.ToLower(), searchTerm) || + EF.Functions.Like(c.CityName!.ToLower(), searchTerm) || + EF.Functions.Like(c.CountryName!.ToLower(), searchTerm) || + EF.Functions.Like(c.GenderName!.ToLower(), searchTerm)); + } + + var totalMatches = await matches.CountAsync(); + + var candidates = await matches.ToListAsync(); + + var page = candidates + .OrderBy(c => matchedIds.IndexOf(c.UserId)) // newest match first + .Skip((request.Page - 1) * LoveNetConstants.DefaultTake) + .Take(LoveNetConstants.DefaultTake) + .Select(c => ToModel(c, matchRooms.First(m => m.OtherUserId == c.UserId).RoomId)) + .ToList(); + + return new MatchesResponseModel + { + Matches = page, + TotalMatches = totalMatches, + }; + } + + public async Task<UserMatchModel?> GetCurrentMatchAsync(string userId) + { + var candidate = await db.CandidateProfiles.AsNoTracking() + .FirstOrDefaultAsync(c => c.UserId == userId); + + return candidate is null ? null : ToModel(candidate); + } + + public async Task<Result> LikeAsync(string userId, string likedUserId, string? correlationId) + { + if (userId == likedUserId) + { + return YouCantLikeYourself; + } + + var likedUser = await db.CandidateProfiles.AsNoTracking() + .FirstOrDefaultAsync(c => c.UserId == likedUserId); + + if (likedUser is null) + { + return UserNotFound; + } + + var alreadyLiked = await db.Likes.AnyAsync(l => l.UserId == userId && l.LikedUserId == likedUserId); + + if (alreadyLiked) + { + return UserAlreadyLiked; + } + + db.Likes.Add(new Like + { + UserId = userId, + LikedUserId = likedUserId, + CreatedOn = DateTime.UtcNow, + }); + + var isMatch = await db.Likes.AnyAsync(l => l.UserId == likedUserId && l.LikedUserId == userId); + + if (isMatch) + { + await CreateMatchAsync(userId, likedUserId, correlationId); + } + + await db.SaveChangesAsync(); + + return isMatch; + } + + private async Task CreateMatchAsync(string userId, string likedUserId, string? correlationId) + { + var match = new Match + { + UserAId = userId, + UserBId = likedUserId, + RoomId = Match.BuildRoomId(userId, likedUserId), + CreatedOn = DateTime.UtcNow, + }; + + db.Matches.Add(match); + + var users = await db.CandidateProfiles.AsNoTracking() + .Where(c => c.UserId == userId || c.UserId == likedUserId) + .ToDictionaryAsync(c => c.UserId); + + db.AddOutboxMessage( + KafkaTopics.MatchCreated, + match.RoomId, + new MatchCreatedEvent( + match.Id, + match.RoomId, + ToMatchedUser(users[userId]), + ToMatchedUser(users[likedUserId]), + match.CreatedOn), + correlationId); + } + + private static MatchedUser ToMatchedUser(CandidateProfile c) => + new(c.UserId, c.UserName, c.Email, c.AvatarUrl); + + private static UserMatchModel ToModel(CandidateProfile c, string? roomId = null) => + new() + { + Id = c.UserId, + UserName = c.UserName, + Bio = c.Bio, + Birthdate = c.Birthdate, + GenderId = c.GenderId, + GenderName = c.GenderName, + CityId = c.CityId, + CityName = c.CityName, + CountryId = c.CountryId, + CountryName = c.CountryName, + Latitude = c.Latitude, + Longitude = c.Longitude, + RoomId = roomId, + Images = c.ImageUrls + .Select((url, index) => new ImageModel + { + Id = index + 1, + Url = url, + IsProfilePicture = index == 0, + }) + .ToList(), + }; +} diff --git a/src/Services/Matching/LoveNet.Matching.Api/appsettings.Development.json b/src/Services/Matching/LoveNet.Matching.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Services/Matching/LoveNet.Matching.Api/appsettings.json b/src/Services/Matching/LoveNet.Matching.Api/appsettings.json new file mode 100644 index 0000000..898e4c8 --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Api/appsettings.json @@ -0,0 +1,29 @@ +{ + "ConnectionStrings": { + "Default": "Host=localhost;Port=5435;Database=lovenet_matching;Username=lovenet;Password=lovenet" + }, + "Kafka": { + "BootstrapServers": "localhost:29092" + }, + "Jwt": { + "Key": "lovenet-local-development-signing-key-do-not-use-in-production-0123456789abcdef", + "Issuer": "https://localhost:3000/", + "Audience": "https://localhost:3000/" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Services/Matching/LoveNet.Matching.Tests/LoveNet.Matching.Tests.csproj b/src/Services/Matching/LoveNet.Matching.Tests/LoveNet.Matching.Tests.csproj new file mode 100644 index 0000000..5dd95aa --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Tests/LoveNet.Matching.Tests.csproj @@ -0,0 +1,30 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="coverlet.collector" Version="6.0.0" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> + <PackageReference Include="Moq" Version="4.20.70" /> + <PackageReference Include="NUnit" Version="3.14.0" /> + <PackageReference Include="NUnit.Analyzers" Version="3.9.0" /> + <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> + </ItemGroup> + + <ItemGroup> + <Using Include="NUnit.Framework" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\LoveNet.Matching.Api\LoveNet.Matching.Api.csproj" /> + </ItemGroup> + +</Project> diff --git a/src/Services/Matching/LoveNet.Matching.Tests/MatchingServiceTests.cs b/src/Services/Matching/LoveNet.Matching.Tests/MatchingServiceTests.cs new file mode 100644 index 0000000..5b88f0e --- /dev/null +++ b/src/Services/Matching/LoveNet.Matching.Tests/MatchingServiceTests.cs @@ -0,0 +1,191 @@ +using LoveNet.BuildingBlocks.EventBus.Outbox; +using LoveNet.Matching.Api.Data; +using LoveNet.Matching.Api.Models; +using LoveNet.Matching.Api.Services; +using Microsoft.EntityFrameworkCore; +using Match = LoveNet.Matching.Api.Data.Match; + +namespace LoveNet.Matching.Tests; + +[TestFixture] +public class MatchingServiceTests +{ + private MatchingDbContext db = null!; + private MatchingService matchingService = null!; + + private CandidateProfile me = null!; + private CandidateProfile other = null!; + private CandidateProfile admin = null!; + + [SetUp] + public async Task Setup() + { + var options = new DbContextOptionsBuilder<MatchingDbContext>() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + db = new MatchingDbContext(options); + + me = NewCandidate("alpha", "Sofia"); + other = NewCandidate("beta", "Plovdiv"); + admin = NewCandidate("admin", "Sofia"); + admin.IsAdmin = true; + + db.CandidateProfiles.AddRange(me, other, admin); + await db.SaveChangesAsync(); + + matchingService = new MatchingService(db); + } + + [TearDown] + public void TearDown() => db.Dispose(); + + [Test] + public async Task SuccessGetNotSwipedUsersExcludesSelfAdminsAndSwiped() + { + var deck = await matchingService.GetNotSwipedUsersAsync(me.UserId); + + Assert.That(deck.Select(u => u.Id), Is.EqualTo(new[] { other.UserId })); + + await matchingService.LikeAsync(me.UserId, other.UserId, null); + + deck = await matchingService.GetNotSwipedUsersAsync(me.UserId); + Assert.That(deck, Is.Empty); + } + + [Test] + public async Task SuccessLikeUserNoMatch() + { + var result = await matchingService.LikeAsync(me.UserId, other.UserId, null); + + Assert.Multiple(() => + { + Assert.That(result.Succeeded, Is.False); // no reciprocal like yet + Assert.That(result.Errors, Is.Empty); + }); + Assert.That(await db.Matches.CountAsync(), Is.Zero); + } + + [Test] + public async Task SuccessLikeUserMatchPersistsMatchAndEmitsEvent() + { + await matchingService.LikeAsync(other.UserId, me.UserId, null); + var result = await matchingService.LikeAsync(me.UserId, other.UserId, null); + + Assert.That(result.Succeeded, Is.True); + + var match = await db.Matches.SingleAsync(); + var expectedRoomId = Match.BuildRoomId(me.UserId, other.UserId); + Assert.That(match.RoomId, Is.EqualTo(expectedRoomId)); + + var outbox = await db.Set<OutboxMessage>().SingleAsync(); + Assert.Multiple(() => + { + Assert.That(outbox.Topic, Is.EqualTo("matching.match-created")); + Assert.That(outbox.Key, Is.EqualTo(expectedRoomId)); + Assert.That(outbox.Payload, Does.Contain(me.UserId).And.Contain(other.UserId)); + }); + } + + [Test] + public async Task FailLikeUserNotExisting() + { + var result = await matchingService.LikeAsync(me.UserId, Guid.NewGuid().ToString(), null); + + Assert.That(result.Errors, Does.Contain(MatchingService.UserNotFound)); + } + + [Test] + public async Task FailLikeUserAgain() + { + await matchingService.LikeAsync(me.UserId, other.UserId, null); + var result = await matchingService.LikeAsync(me.UserId, other.UserId, null); + + Assert.That(result.Errors, Does.Contain(MatchingService.UserAlreadyLiked)); + } + + [Test] + public async Task FailLikeUserLikingYourself() + { + var result = await matchingService.LikeAsync(me.UserId, me.UserId, null); + + Assert.That(result.Errors, Does.Contain(MatchingService.YouCantLikeYourself)); + } + + [Test] + public async Task SuccessGetMatchesReadsPersistedMatchesWithRoomIds() + { + await matchingService.LikeAsync(other.UserId, me.UserId, null); + await matchingService.LikeAsync(me.UserId, other.UserId, null); + + var matches = await matchingService.GetMatchesAsync(new MatchesRequestModel + { + UserId = me.UserId, + Page = 1, + }); + + Assert.Multiple(() => + { + Assert.That(matches.TotalMatches, Is.EqualTo(1)); + Assert.That(matches.Matches.Single().Id, Is.EqualTo(other.UserId)); + Assert.That(matches.Matches.Single().RoomId, Is.EqualTo(Match.BuildRoomId(me.UserId, other.UserId))); + }); + } + + [Test] + public async Task SuccessGetMatchesSearchFiltersByCity() + { + await matchingService.LikeAsync(other.UserId, me.UserId, null); + await matchingService.LikeAsync(me.UserId, other.UserId, null); + + var hit = await matchingService.GetMatchesAsync(new MatchesRequestModel + { + UserId = me.UserId, + Page = 1, + Search = "plovdiv", + }); + var miss = await matchingService.GetMatchesAsync(new MatchesRequestModel + { + UserId = me.UserId, + Page = 1, + Search = "varna", + }); + + Assert.Multiple(() => + { + Assert.That(hit.TotalMatches, Is.EqualTo(1)); + Assert.That(miss.TotalMatches, Is.Zero); + }); + } + + [Test] + public async Task ImagesAreSynthesizedProfilePictureFirst() + { + var deck = await matchingService.GetNotSwipedUsersAsync(me.UserId); + var images = deck.Single().Images; + + Assert.Multiple(() => + { + Assert.That(images, Has.Count.EqualTo(2)); + Assert.That(images[0].IsProfilePicture, Is.True); + Assert.That(images[0].Url, Does.Contain("pfp")); + }); + } + + private static CandidateProfile NewCandidate(string name, string city) => + new() + { + UserId = Guid.NewGuid().ToString(), + UserName = name, + Email = $"{name}@lovenet.test", + Bio = $"{name} bio", + Birthdate = DateTime.UtcNow.AddYears(-25), + GenderId = 1, + GenderName = "Male", + CityId = 1, + CityName = city, + CountryId = 1, + CountryName = "Bulgaria", + ImageUrls = new List<string> { $"https://img/{name}-pfp.png", $"https://img/{name}-2.png" }, + }; +} diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/Consumers/EmailRequestedConsumer.cs b/src/Services/Notifications/LoveNet.Notifications.Api/Consumers/EmailRequestedConsumer.cs new file mode 100644 index 0000000..9bcaaf0 --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/Consumers/EmailRequestedConsumer.cs @@ -0,0 +1,30 @@ +using Confluent.Kafka; +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.EventBus.Consuming; +using LoveNet.Notifications.Api.Services; +using Microsoft.Extensions.Options; + +namespace LoveNet.Notifications.Api.Consumers; + +/// <summary>Sends the transactional emails Identity requests (verify, reset password).</summary> +public class EmailRequestedConsumer : KafkaConsumerService<EmailRequestedEvent> +{ + private const string ConsumerName = "notifications-service"; + + public EmailRequestedConsumer( + IServiceScopeFactory scopeFactory, + IProducer<string, string> producer, + IOptions<KafkaOptions> options, + ILogger<EmailRequestedConsumer> logger) + : base(scopeFactory, producer, options, logger, KafkaTopics.EmailRequested, ConsumerName) + { + } + + protected override async Task HandleAsync(IServiceProvider services, EventEnvelope<EmailRequestedEvent> envelope, CancellationToken cancellationToken) + { + var dispatch = services.GetRequiredService<IEmailDispatchService>(); + await dispatch.HandleEmailRequestedAsync(envelope.MessageId, envelope.Data, cancellationToken); + } +} diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/Consumers/MatchCreatedConsumer.cs b/src/Services/Notifications/LoveNet.Notifications.Api/Consumers/MatchCreatedConsumer.cs new file mode 100644 index 0000000..d2600b3 --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/Consumers/MatchCreatedConsumer.cs @@ -0,0 +1,30 @@ +using Confluent.Kafka; +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.EventBus.Consuming; +using LoveNet.Notifications.Api.Services; +using Microsoft.Extensions.Options; + +namespace LoveNet.Notifications.Api.Consumers; + +/// <summary>Emails both users when Matching creates a new match.</summary> +public class MatchCreatedConsumer : KafkaConsumerService<MatchCreatedEvent> +{ + private const string ConsumerName = "notifications-service"; + + public MatchCreatedConsumer( + IServiceScopeFactory scopeFactory, + IProducer<string, string> producer, + IOptions<KafkaOptions> options, + ILogger<MatchCreatedConsumer> logger) + : base(scopeFactory, producer, options, logger, KafkaTopics.MatchCreated, ConsumerName) + { + } + + protected override async Task HandleAsync(IServiceProvider services, EventEnvelope<MatchCreatedEvent> envelope, CancellationToken cancellationToken) + { + var dispatch = services.GetRequiredService<IEmailDispatchService>(); + await dispatch.HandleMatchCreatedAsync(envelope.MessageId, envelope.Data, cancellationToken); + } +} diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/Consumers/UserRegisteredConsumer.cs b/src/Services/Notifications/LoveNet.Notifications.Api/Consumers/UserRegisteredConsumer.cs new file mode 100644 index 0000000..546897a --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/Consumers/UserRegisteredConsumer.cs @@ -0,0 +1,31 @@ +using Confluent.Kafka; +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.EventBus.Consuming; +using LoveNet.Notifications.Api.Services; +using Microsoft.Extensions.Options; + +namespace LoveNet.Notifications.Api.Consumers; + +/// <summary>Seeds the contact read model when Identity registers a user.</summary> +public class UserRegisteredConsumer : KafkaConsumerService<UserRegisteredEvent> +{ + private const string ConsumerName = "notifications-service"; + + public UserRegisteredConsumer( + IServiceScopeFactory scopeFactory, + IProducer<string, string> producer, + IOptions<KafkaOptions> options, + ILogger<UserRegisteredConsumer> logger) + : base(scopeFactory, producer, options, logger, KafkaTopics.UserRegistered, ConsumerName) + { + } + + protected override async Task HandleAsync(IServiceProvider services, EventEnvelope<UserRegisteredEvent> envelope, CancellationToken cancellationToken) + { + var dispatch = services.GetRequiredService<IEmailDispatchService>(); + var data = envelope.Data; + await dispatch.UpsertContactAsync(envelope.MessageId, data.UserId, data.Email, data.UserName, cancellationToken); + } +} diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/Consumers/UserUpdatedConsumer.cs b/src/Services/Notifications/LoveNet.Notifications.Api/Consumers/UserUpdatedConsumer.cs new file mode 100644 index 0000000..c53a255 --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/Consumers/UserUpdatedConsumer.cs @@ -0,0 +1,31 @@ +using Confluent.Kafka; +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.EventBus.Consuming; +using LoveNet.Notifications.Api.Services; +using Microsoft.Extensions.Options; + +namespace LoveNet.Notifications.Api.Consumers; + +/// <summary>Keeps the contact read model fresh when Profile republishes a user.</summary> +public class UserUpdatedConsumer : KafkaConsumerService<UserUpdatedEvent> +{ + private const string ConsumerName = "notifications-service"; + + public UserUpdatedConsumer( + IServiceScopeFactory scopeFactory, + IProducer<string, string> producer, + IOptions<KafkaOptions> options, + ILogger<UserUpdatedConsumer> logger) + : base(scopeFactory, producer, options, logger, KafkaTopics.UserUpdated, ConsumerName) + { + } + + protected override async Task HandleAsync(IServiceProvider services, EventEnvelope<UserUpdatedEvent> envelope, CancellationToken cancellationToken) + { + var dispatch = services.GetRequiredService<IEmailDispatchService>(); + var data = envelope.Data; + await dispatch.UpsertContactAsync(envelope.MessageId, data.UserId, data.Email, data.UserName, cancellationToken); + } +} diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/Data/EmailLog.cs b/src/Services/Notifications/LoveNet.Notifications.Api/Data/EmailLog.cs new file mode 100644 index 0000000..33a74b2 --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/Data/EmailLog.cs @@ -0,0 +1,15 @@ +namespace LoveNet.Notifications.Api.Data; + +/// <summary>One row per successfully sent email.</summary> +public class EmailLog +{ + public Guid Id { get; set; } + + public string ToEmail { get; set; } = null!; + + public string Subject { get; set; } = null!; + + public string EmailType { get; set; } = null!; + + public DateTime SentOn { get; set; } +} diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/Data/Migrations/20260713080750_InitialCreate.Designer.cs b/src/Services/Notifications/LoveNet.Notifications.Api/Data/Migrations/20260713080750_InitialCreate.Designer.cs new file mode 100644 index 0000000..3f6670d --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/Data/Migrations/20260713080750_InitialCreate.Designer.cs @@ -0,0 +1,91 @@ +// <auto-generated /> +using System; +using LoveNet.Notifications.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LoveNet.Notifications.Api.Data.Migrations +{ + [DbContext(typeof(NotificationsDbContext))] + [Migration("20260713080750_InitialCreate")] + partial class InitialCreate + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LoveNet.BuildingBlocks.EventBus.Inbox.InboxMessage", b => + { + b.Property<Guid>("MessageId") + .HasColumnType("uuid"); + + b.Property<string>("Consumer") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<DateTime>("ProcessedOn") + .HasColumnType("timestamp without time zone"); + + b.HasKey("MessageId", "Consumer"); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.Notifications.Api.Data.EmailLog", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("EmailType") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime>("SentOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("ToEmail") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("email_log", (string)null); + }); + + modelBuilder.Entity("LoveNet.Notifications.Api.Data.UserContact", b => + { + b.Property<string>("UserId") + .HasColumnType("text"); + + b.Property<string>("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("UserName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId"); + + b.ToTable("user_contacts", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/Data/Migrations/20260713080750_InitialCreate.cs b/src/Services/Notifications/LoveNet.Notifications.Api/Data/Migrations/20260713080750_InitialCreate.cs new file mode 100644 index 0000000..5104329 --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/Data/Migrations/20260713080750_InitialCreate.cs @@ -0,0 +1,69 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LoveNet.Notifications.Api.Data.Migrations +{ + /// <inheritdoc /> + public partial class InitialCreate : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "email_log", + columns: table => new + { + Id = table.Column<Guid>(type: "uuid", nullable: false), + ToEmail = table.Column<string>(type: "text", nullable: false), + Subject = table.Column<string>(type: "text", nullable: false), + EmailType = table.Column<string>(type: "text", nullable: false), + SentOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_email_log", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "inbox_messages", + columns: table => new + { + MessageId = table.Column<Guid>(type: "uuid", nullable: false), + Consumer = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false), + ProcessedOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_inbox_messages", x => new { x.MessageId, x.Consumer }); + }); + + migrationBuilder.CreateTable( + name: "user_contacts", + columns: table => new + { + UserId = table.Column<string>(type: "text", nullable: false), + Email = table.Column<string>(type: "text", nullable: false), + UserName = table.Column<string>(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_user_contacts", x => x.UserId); + }); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "email_log"); + + migrationBuilder.DropTable( + name: "inbox_messages"); + + migrationBuilder.DropTable( + name: "user_contacts"); + } + } +} diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/Data/Migrations/NotificationsDbContextModelSnapshot.cs b/src/Services/Notifications/LoveNet.Notifications.Api/Data/Migrations/NotificationsDbContextModelSnapshot.cs new file mode 100644 index 0000000..63647f1 --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/Data/Migrations/NotificationsDbContextModelSnapshot.cs @@ -0,0 +1,88 @@ +// <auto-generated /> +using System; +using LoveNet.Notifications.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LoveNet.Notifications.Api.Data.Migrations +{ + [DbContext(typeof(NotificationsDbContext))] + partial class NotificationsDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LoveNet.BuildingBlocks.EventBus.Inbox.InboxMessage", b => + { + b.Property<Guid>("MessageId") + .HasColumnType("uuid"); + + b.Property<string>("Consumer") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<DateTime>("ProcessedOn") + .HasColumnType("timestamp without time zone"); + + b.HasKey("MessageId", "Consumer"); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.Notifications.Api.Data.EmailLog", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<string>("EmailType") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime>("SentOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("ToEmail") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("email_log", (string)null); + }); + + modelBuilder.Entity("LoveNet.Notifications.Api.Data.UserContact", b => + { + b.Property<string>("UserId") + .HasColumnType("text"); + + b.Property<string>("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("UserName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId"); + + b.ToTable("user_contacts", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/Data/NotificationsDbContext.cs b/src/Services/Notifications/LoveNet.Notifications.Api/Data/NotificationsDbContext.cs new file mode 100644 index 0000000..fe7d4ef --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/Data/NotificationsDbContext.cs @@ -0,0 +1,33 @@ +using LoveNet.BuildingBlocks.EventBus; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Notifications.Api.Data; + +public class NotificationsDbContext : DbContext +{ + public NotificationsDbContext(DbContextOptions<NotificationsDbContext> options) + : base(options) + { + } + + public DbSet<EmailLog> EmailLogs => Set<EmailLog>(); + + public DbSet<UserContact> UserContacts => Set<UserContact>(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity<EmailLog>(entity => + { + entity.ToTable("email_log"); + entity.HasKey(l => l.Id); + }); + + modelBuilder.Entity<UserContact>(entity => + { + entity.ToTable("user_contacts"); + entity.HasKey(c => c.UserId); + }); + + modelBuilder.AddInbox(); + } +} diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/Data/UserContact.cs b/src/Services/Notifications/LoveNet.Notifications.Api/Data/UserContact.cs new file mode 100644 index 0000000..fccef26 --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/Data/UserContact.cs @@ -0,0 +1,11 @@ +namespace LoveNet.Notifications.Api.Data; + +/// <summary>Read model of user contact details, kept fresh from identity/profile events.</summary> +public class UserContact +{ + public string UserId { get; set; } = null!; + + public string Email { get; set; } = null!; + + public string UserName { get; set; } = null!; +} diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/Dockerfile b/src/Services/Notifications/LoveNet.Notifications.Api/Dockerfile new file mode 100644 index 0000000..9f6487b --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/Dockerfile @@ -0,0 +1,17 @@ +# Build context is the repository root. +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY NuGet.config . +COPY src/Directory.Build.props src/ +COPY src/BuildingBlocks/ src/BuildingBlocks/ +COPY src/Services/Notifications/ src/Services/Notifications/ +RUN dotnet publish src/Services/Notifications/LoveNet.Notifications.Api/LoveNet.Notifications.Api.csproj -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +# curl is used by the compose healthcheck. +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +COPY --from=build /app/publish . +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 +ENTRYPOINT ["dotnet", "LoveNet.Notifications.Api.dll"] diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/LoveNet.Notifications.Api.csproj b/src/Services/Notifications/LoveNet.Notifications.Api/LoveNet.Notifications.Api.csproj new file mode 100644 index 0000000..91de392 --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/LoveNet.Notifications.Api.csproj @@ -0,0 +1,25 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <ItemGroup> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.Contracts\LoveNet.BuildingBlocks.Contracts.csproj" /> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.EventBus\LoveNet.BuildingBlocks.EventBus.csproj" /> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.Web\LoveNet.BuildingBlocks.Web.csproj" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" /> + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" /> + <PackageReference Include="SendGrid" Version="9.28.0" /> + </ItemGroup> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + </PropertyGroup> + + <ItemGroup> + <Content Update="Templates\*.html" CopyToOutputDirectory="PreserveNewest" /> + </ItemGroup> + +</Project> diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/Program.cs b/src/Services/Notifications/LoveNet.Notifications.Api/Program.cs new file mode 100644 index 0000000..14c99ea --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/Program.cs @@ -0,0 +1,38 @@ +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.Web; +using LoveNet.Notifications.Api.Consumers; +using LoveNet.Notifications.Api.Data; +using LoveNet.Notifications.Api.Services; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddLoveNetDefaults("notifications-api"); + +builder.Services.AddDbContext<NotificationsDbContext>(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("Default"))); + +builder.Services.AddKafkaEventBus(builder.Configuration); +builder.Services.AddHostedService<EmailRequestedConsumer>(); +builder.Services.AddHostedService<MatchCreatedConsumer>(); +builder.Services.AddHostedService<UserRegisteredConsumer>(); +builder.Services.AddHostedService<UserUpdatedConsumer>(); + +builder.Services.AddScoped<IEmailDispatchService, EmailDispatchService>(); +builder.Services.AddScoped<IEmailSender, SendGridEmailSender>(); +builder.Services.AddSingleton<IEmailTemplateProvider, FileEmailTemplateProvider>(); + +builder.Services.AddLoveNetHealthChecks(builder.Configuration, postgres: true, kafka: true); + +var app = builder.Build(); + +app.UseLoveNetDefaults(); +app.MapLoveNetHealth(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService<NotificationsDbContext>(); + await db.Database.MigrateAsync(); +} + +await app.RunAsync(); diff --git a/Web/LOVE.NET.Web/Properties/launchSettings.json b/src/Services/Notifications/LoveNet.Notifications.Api/Properties/launchSettings.json similarity index 54% rename from Web/LOVE.NET.Web/Properties/launchSettings.json rename to src/Services/Notifications/LoveNet.Notifications.Api/Properties/launchSettings.json index eae44ef..4b8433f 100644 --- a/Web/LOVE.NET.Web/Properties/launchSettings.json +++ b/src/Services/Notifications/LoveNet.Notifications.Api/Properties/launchSettings.json @@ -1,38 +1,38 @@ { + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:9076", + "sslPort": 44356 + } + }, "profiles": { - "IIS Express": { - "commandName": "IISExpress", + "http": { + "commandName": "Project", + "dotnetRunMessages": true, "launchBrowser": true, + "applicationUrl": "http://localhost:5051", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, - "LOVE.NET.Web": { + "https": { "commandName": "Project", + "dotnetRunMessages": true, "launchBrowser": true, + "applicationUrl": "https://localhost:7160;http://localhost:5051", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "https://localhost:5001;http://localhost:5000" + } }, - "Container (Dockerfile)": { - "commandName": "Docker", + "IIS Express": { + "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", "environmentVariables": { - "ASPNETCORE_HTTPS_PORTS": "8081", - "ASPNETCORE_HTTP_PORTS": "8080" - }, - "publishAllPorts": true, - "useSSL": true - } - }, - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:19901", - "sslPort": 44319 + "ASPNETCORE_ENVIRONMENT": "Development" + } } } -} \ No newline at end of file +} diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/Services/EmailDispatchService.cs b/src/Services/Notifications/LoveNet.Notifications.Api/Services/EmailDispatchService.cs new file mode 100644 index 0000000..e963dea --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/Services/EmailDispatchService.cs @@ -0,0 +1,123 @@ +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.Notifications.Api.Data; + +namespace LoveNet.Notifications.Api.Services; + +/// <summary> +/// Testable core the Kafka consumers delegate to. Every handler is idempotent +/// via the inbox: the inbox row commits in the same SaveChanges as the email +/// log, after the send succeeded. +/// </summary> +public class EmailDispatchService : IEmailDispatchService +{ + private const string ConsumerName = "notifications-service"; + + private readonly NotificationsDbContext db; + private readonly IEmailSender emailSender; + private readonly IEmailTemplateProvider templateProvider; + private readonly string fromEmail; + private readonly string fromName; + + public EmailDispatchService( + NotificationsDbContext db, + IEmailSender emailSender, + IEmailTemplateProvider templateProvider, + IConfiguration configuration) + { + this.db = db; + this.emailSender = emailSender; + this.templateProvider = templateProvider; + fromEmail = configuration["Email:FromAddress"] ?? "no-reply@lovenet.local"; + fromName = configuration["Email:FromName"] ?? "LOVE.NET"; + } + + public async Task HandleEmailRequestedAsync(Guid messageId, EmailRequestedEvent evt, CancellationToken ct) + { + if (!await db.TryRegisterInboxAsync(messageId, ConsumerName, ct)) + { + return; + } + + var (templateFile, subject) = evt.EmailType switch + { + EmailType.ConfirmEmail => ("verify.html", "Verify Email"), + EmailType.ResetPassword => ("resetPassword.html", "Reset Password"), + _ => throw new InvalidOperationException($"Unsupported email type {evt.EmailType}"), + }; + + var template = await templateProvider.GetTemplateAsync(templateFile, ct); + var content = string.Format(template, evt.Link); + + await emailSender.SendEmailAsync(fromEmail, fromName, evt.ToEmail, subject, content); + + db.EmailLogs.Add(new EmailLog + { + Id = Guid.NewGuid(), + ToEmail = evt.ToEmail, + Subject = subject, + EmailType = evt.EmailType.ToString(), + SentOn = DateTime.UtcNow, + }); + + await db.SaveChangesAsync(ct); + } + + public async Task HandleMatchCreatedAsync(Guid messageId, MatchCreatedEvent evt, CancellationToken ct) + { + if (!await db.TryRegisterInboxAsync(messageId, ConsumerName, ct)) + { + return; + } + + const string subject = "You have a new match"; + + await SendMatchEmailAsync(evt.UserA, evt.UserB, subject); + await SendMatchEmailAsync(evt.UserB, evt.UserA, subject); + + await db.SaveChangesAsync(ct); + } + + public async Task UpsertContactAsync(Guid messageId, string userId, string email, string userName, CancellationToken ct) + { + if (!await db.TryRegisterInboxAsync(messageId, ConsumerName, ct)) + { + return; + } + + var contact = await db.UserContacts.FindAsync(new object[] { userId }, ct); + + if (contact is null) + { + db.UserContacts.Add(new UserContact + { + UserId = userId, + Email = email, + UserName = userName, + }); + } + else + { + contact.Email = email; + contact.UserName = userName; + } + + await db.SaveChangesAsync(ct); + } + + private async Task SendMatchEmailAsync(MatchedUser recipient, MatchedUser other, string subject) + { + var content = $"<h1>It's a match!</h1><p>You matched with {other.UserName}. Open LOVE.NET to say hi!</p>"; + + await emailSender.SendEmailAsync(fromEmail, fromName, recipient.Email, subject, content); + + db.EmailLogs.Add(new EmailLog + { + Id = Guid.NewGuid(), + ToEmail = recipient.Email, + Subject = subject, + EmailType = "MatchCreated", + SentOn = DateTime.UtcNow, + }); + } +} diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/Services/IEmailDispatchService.cs b/src/Services/Notifications/LoveNet.Notifications.Api/Services/IEmailDispatchService.cs new file mode 100644 index 0000000..49355df --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/Services/IEmailDispatchService.cs @@ -0,0 +1,12 @@ +using LoveNet.BuildingBlocks.Contracts.Events; + +namespace LoveNet.Notifications.Api.Services; + +public interface IEmailDispatchService +{ + Task HandleEmailRequestedAsync(Guid messageId, EmailRequestedEvent evt, CancellationToken ct); + + Task HandleMatchCreatedAsync(Guid messageId, MatchCreatedEvent evt, CancellationToken ct); + + Task UpsertContactAsync(Guid messageId, string userId, string email, string userName, CancellationToken ct); +} diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/Services/IEmailSender.cs b/src/Services/Notifications/LoveNet.Notifications.Api/Services/IEmailSender.cs new file mode 100644 index 0000000..85b6b6b --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/Services/IEmailSender.cs @@ -0,0 +1,21 @@ +namespace LoveNet.Notifications.Api.Services; + +public interface IEmailSender +{ + Task SendEmailAsync( + string from, + string fromName, + string to, + string subject, + string htmlContent, + IEnumerable<EmailAttachment>? attachments = null); +} + +public class EmailAttachment +{ + public byte[] Content { get; set; } = null!; + + public string FileName { get; set; } = null!; + + public string MimeType { get; set; } = null!; +} diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/Services/IEmailTemplateProvider.cs b/src/Services/Notifications/LoveNet.Notifications.Api/Services/IEmailTemplateProvider.cs new file mode 100644 index 0000000..92c90f5 --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/Services/IEmailTemplateProvider.cs @@ -0,0 +1,13 @@ +namespace LoveNet.Notifications.Api.Services; + +public interface IEmailTemplateProvider +{ + Task<string> GetTemplateAsync(string fileName, CancellationToken cancellationToken = default); +} + +/// <summary>Reads templates from the Templates folder next to the binaries.</summary> +public class FileEmailTemplateProvider : IEmailTemplateProvider +{ + public Task<string> GetTemplateAsync(string fileName, CancellationToken cancellationToken = default) => + File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "Templates", fileName), cancellationToken); +} diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/Services/SendGridEmailSender.cs b/src/Services/Notifications/LoveNet.Notifications.Api/Services/SendGridEmailSender.cs new file mode 100644 index 0000000..9894820 --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/Services/SendGridEmailSender.cs @@ -0,0 +1,57 @@ +using SendGrid; +using SendGrid.Helpers.Mail; + +namespace LoveNet.Notifications.Api.Services; + +/// <summary> +/// SendGrid transactional email sender ported from the monolith. When no API +/// key is configured (local dev) it logs a warning and skips the send instead +/// of throwing. +/// </summary> +public class SendGridEmailSender : IEmailSender +{ + private readonly string? apiKey; + private readonly ILogger<SendGridEmailSender> logger; + + public SendGridEmailSender(IConfiguration configuration, ILogger<SendGridEmailSender> logger) + { + apiKey = configuration["SendGrid:ApiKey"]; + this.logger = logger; + } + + public async Task SendEmailAsync( + string from, + string fromName, + string to, + string subject, + string htmlContent, + IEnumerable<EmailAttachment>? attachments = null) + { + if (string.IsNullOrWhiteSpace(subject) && string.IsNullOrWhiteSpace(htmlContent)) + { + throw new ArgumentException("Subject and message should be provided."); + } + + if (string.IsNullOrWhiteSpace(apiKey)) + { + logger.LogWarning("SendGrid not configured; skipping email to {To}", to); + return; + } + + var client = new SendGridClient(apiKey); + var fromAddress = new EmailAddress(from, fromName); + var toAddress = new EmailAddress(to); + var message = MailHelper.CreateSingleEmail(fromAddress, toAddress, subject, null, htmlContent); + + if (attachments?.Any() == true) + { + foreach (var attachment in attachments) + { + message.AddAttachment(attachment.FileName, Convert.ToBase64String(attachment.Content), attachment.MimeType); + } + } + + var response = await client.SendEmailAsync(message); + logger.LogInformation("SendGrid responded {StatusCode} for email to {To}", response.StatusCode, to); + } +} diff --git a/Web/LOVE.NET.Web/wwwroot/templates/email/resetPassword.html b/src/Services/Notifications/LoveNet.Notifications.Api/Templates/resetPassword.html similarity index 100% rename from Web/LOVE.NET.Web/wwwroot/templates/email/resetPassword.html rename to src/Services/Notifications/LoveNet.Notifications.Api/Templates/resetPassword.html diff --git a/Web/LOVE.NET.Web/wwwroot/templates/email/verify.html b/src/Services/Notifications/LoveNet.Notifications.Api/Templates/verify.html similarity index 100% rename from Web/LOVE.NET.Web/wwwroot/templates/email/verify.html rename to src/Services/Notifications/LoveNet.Notifications.Api/Templates/verify.html diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/appsettings.Development.json b/src/Services/Notifications/LoveNet.Notifications.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Services/Notifications/LoveNet.Notifications.Api/appsettings.json b/src/Services/Notifications/LoveNet.Notifications.Api/appsettings.json new file mode 100644 index 0000000..fa10701 --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Api/appsettings.json @@ -0,0 +1,31 @@ +{ + "ConnectionStrings": { + "Default": "Host=localhost;Port=5437;Database=lovenet_notifications;Username=lovenet;Password=lovenet" + }, + "Kafka": { + "BootstrapServers": "localhost:29092" + }, + "SendGrid": { + "ApiKey": "" + }, + "Email": { + "FromAddress": "no-reply@lovenet.local", + "FromName": "LOVE.NET" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Services/Notifications/LoveNet.Notifications.Tests/EmailDispatchServiceTests.cs b/src/Services/Notifications/LoveNet.Notifications.Tests/EmailDispatchServiceTests.cs new file mode 100644 index 0000000..d82d754 --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Tests/EmailDispatchServiceTests.cs @@ -0,0 +1,148 @@ +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.Notifications.Api.Data; +using LoveNet.Notifications.Api.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Moq; + +namespace LoveNet.Notifications.Tests; + +[TestFixture] +public class EmailDispatchServiceTests +{ + private NotificationsDbContext db = null!; + private Mock<IEmailSender> emailSender = null!; + private EmailDispatchService service = null!; + + [SetUp] + public void SetUp() + { + var options = new DbContextOptionsBuilder<NotificationsDbContext>() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + db = new NotificationsDbContext(options); + emailSender = new Mock<IEmailSender>(); + + var templateProvider = new Mock<IEmailTemplateProvider>(); + templateProvider + .Setup(p => p.GetTemplateAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) + .ReturnsAsync("link: {0}"); + + var configuration = new ConfigurationBuilder().Build(); + service = new EmailDispatchService(db, emailSender.Object, templateProvider.Object, configuration); + } + + [TearDown] + public void TearDown() => db.Dispose(); + + [Test] + public async Task HandleEmailRequestedAsync_SendsEmailWithLinkSubstitutedAndWritesLog() + { + var evt = new EmailRequestedEvent( + EmailType.ConfirmEmail, + "user@example.com", + "https://love.net/confirm?token=abc"); + + await service.HandleEmailRequestedAsync(Guid.NewGuid(), evt, CancellationToken.None); + + emailSender.Verify( + s => s.SendEmailAsync( + It.IsAny<string>(), + It.IsAny<string>(), + "user@example.com", + "Verify Email", + "link: https://love.net/confirm?token=abc", + It.IsAny<IEnumerable<EmailAttachment>?>()), + Times.Once); + + var log = await db.EmailLogs.SingleAsync(); + Assert.Multiple(() => + { + Assert.That(log.ToEmail, Is.EqualTo("user@example.com")); + Assert.That(log.Subject, Is.EqualTo("Verify Email")); + Assert.That(log.EmailType, Is.EqualTo(nameof(EmailType.ConfirmEmail))); + }); + } + + [Test] + public async Task HandleEmailRequestedAsync_SameMessageIdTwice_SendsExactlyOnce() + { + var messageId = Guid.NewGuid(); + var evt = new EmailRequestedEvent( + EmailType.ResetPassword, + "user@example.com", + "https://love.net/reset?token=xyz"); + + await service.HandleEmailRequestedAsync(messageId, evt, CancellationToken.None); + await service.HandleEmailRequestedAsync(messageId, evt, CancellationToken.None); + + emailSender.Verify( + s => s.SendEmailAsync( + It.IsAny<string>(), + It.IsAny<string>(), + It.IsAny<string>(), + It.IsAny<string>(), + It.IsAny<string>(), + It.IsAny<IEnumerable<EmailAttachment>?>()), + Times.Once); + + Assert.That(await db.EmailLogs.CountAsync(), Is.EqualTo(1)); + } + + [Test] + public async Task HandleMatchCreatedAsync_SendsOneEmailToEachUser() + { + var userA = new MatchedUser("user-a", "Alice", "alice@example.com", null); + var userB = new MatchedUser("user-b", "Bob", "bob@example.com", null); + var evt = new MatchCreatedEvent("match-1", "room-1", userA, userB, DateTime.UtcNow); + + await service.HandleMatchCreatedAsync(Guid.NewGuid(), evt, CancellationToken.None); + + emailSender.Verify( + s => s.SendEmailAsync( + It.IsAny<string>(), + It.IsAny<string>(), + "alice@example.com", + "You have a new match", + It.Is<string>(content => content.Contains("Bob")), + It.IsAny<IEnumerable<EmailAttachment>?>()), + Times.Once); + + emailSender.Verify( + s => s.SendEmailAsync( + It.IsAny<string>(), + It.IsAny<string>(), + "bob@example.com", + "You have a new match", + It.Is<string>(content => content.Contains("Alice")), + It.IsAny<IEnumerable<EmailAttachment>?>()), + Times.Once); + + Assert.That(await db.EmailLogs.CountAsync(), Is.EqualTo(2)); + } + + [Test] + public async Task UpsertContactAsync_CreatesThenUpdatesContact() + { + await service.UpsertContactAsync(Guid.NewGuid(), "user-1", "old@example.com", "OldName", CancellationToken.None); + + var created = await db.UserContacts.SingleAsync(); + Assert.Multiple(() => + { + Assert.That(created.UserId, Is.EqualTo("user-1")); + Assert.That(created.Email, Is.EqualTo("old@example.com")); + Assert.That(created.UserName, Is.EqualTo("OldName")); + }); + + await service.UpsertContactAsync(Guid.NewGuid(), "user-1", "new@example.com", "NewName", CancellationToken.None); + + var updated = await db.UserContacts.SingleAsync(); + Assert.Multiple(() => + { + Assert.That(updated.UserId, Is.EqualTo("user-1")); + Assert.That(updated.Email, Is.EqualTo("new@example.com")); + Assert.That(updated.UserName, Is.EqualTo("NewName")); + }); + } +} diff --git a/src/Services/Notifications/LoveNet.Notifications.Tests/LoveNet.Notifications.Tests.csproj b/src/Services/Notifications/LoveNet.Notifications.Tests/LoveNet.Notifications.Tests.csproj new file mode 100644 index 0000000..16465f3 --- /dev/null +++ b/src/Services/Notifications/LoveNet.Notifications.Tests/LoveNet.Notifications.Tests.csproj @@ -0,0 +1,30 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="coverlet.collector" Version="6.0.0" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> + <PackageReference Include="Moq" Version="4.20.70" /> + <PackageReference Include="NUnit" Version="3.14.0" /> + <PackageReference Include="NUnit.Analyzers" Version="3.9.0" /> + <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> + </ItemGroup> + + <ItemGroup> + <Using Include="NUnit.Framework" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\LoveNet.Notifications.Api\LoveNet.Notifications.Api.csproj" /> + </ItemGroup> + +</Project> diff --git a/src/Services/Profile/LoveNet.Profile.Api/Consumers/UserBannedConsumer.cs b/src/Services/Profile/LoveNet.Profile.Api/Consumers/UserBannedConsumer.cs new file mode 100644 index 0000000..2dc9d67 --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Consumers/UserBannedConsumer.cs @@ -0,0 +1,51 @@ +using Confluent.Kafka; +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.EventBus.Consuming; +using LoveNet.Profile.Api.Data; +using LoveNet.Profile.Api.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace LoveNet.Profile.Api.Consumers; + +public class UserBannedConsumer : KafkaConsumerService<UserBannedEvent> +{ + private const string ConsumerName = "profile-service"; + + public UserBannedConsumer( + IServiceScopeFactory scopeFactory, + IProducer<string, string> producer, + IOptions<KafkaOptions> options, + ILogger<UserBannedConsumer> logger) + : base(scopeFactory, producer, options, logger, KafkaTopics.UserBanned, ConsumerName) + { + } + + protected override async Task HandleAsync(IServiceProvider services, EventEnvelope<UserBannedEvent> envelope, CancellationToken cancellationToken) + { + var db = services.GetRequiredService<ProfileDbContext>(); + + if (!await db.TryRegisterInboxAsync(envelope.MessageId, ConsumerName, cancellationToken)) + { + return; + } + + var profile = await db.UserProfiles + .Include(p => p.Images) + .FirstOrDefaultAsync(p => p.UserId == envelope.Data.UserId, cancellationToken); + + if (profile is null) + { + return; + } + + profile.IsBanned = envelope.Data.BannedUntil is not null; + + var publisher = services.GetRequiredService<IUserUpdatedPublisher>(); + await publisher.QueueUserUpdatedAsync(profile, envelope.CorrelationId); + + await db.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Consumers/UserRegisteredConsumer.cs b/src/Services/Profile/LoveNet.Profile.Api/Consumers/UserRegisteredConsumer.cs new file mode 100644 index 0000000..88a9bf6 --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Consumers/UserRegisteredConsumer.cs @@ -0,0 +1,85 @@ +using Confluent.Kafka; +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.EventBus.Consuming; +using LoveNet.Profile.Api.Data; +using LoveNet.Profile.Api.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace LoveNet.Profile.Api.Consumers; + +/// <summary> +/// Builds the profile aggregate when Identity registers a user, then queues +/// the enriched profile.user-updated event for the other services. +/// </summary> +public class UserRegisteredConsumer : KafkaConsumerService<UserRegisteredEvent> +{ + private const string ConsumerName = "profile-service"; + + public UserRegisteredConsumer( + IServiceScopeFactory scopeFactory, + IProducer<string, string> producer, + IOptions<KafkaOptions> options, + ILogger<UserRegisteredConsumer> logger) + : base(scopeFactory, producer, options, logger, KafkaTopics.UserRegistered, ConsumerName) + { + } + + protected override async Task HandleAsync(IServiceProvider services, EventEnvelope<UserRegisteredEvent> envelope, CancellationToken cancellationToken) + { + var db = services.GetRequiredService<ProfileDbContext>(); + + if (!await db.TryRegisterInboxAsync(envelope.MessageId, ConsumerName, cancellationToken)) + { + return; + } + + var data = envelope.Data; + + var profile = await db.UserProfiles + .Include(p => p.Images) + .FirstOrDefaultAsync(p => p.UserId == data.UserId, cancellationToken); + + if (profile is null) + { + profile = new UserProfile + { + UserId = data.UserId, + CreatedOn = DateTime.UtcNow, + }; + db.UserProfiles.Add(profile); + } + + profile.UserName = data.UserName; + profile.Email = data.Email; + profile.Bio = data.Bio; + profile.Birthdate = data.Birthdate; + profile.GenderId = data.GenderId; + profile.CityId = data.CityId; + profile.CountryId = data.CountryId; + profile.IsAdmin = data.IsAdmin; + + if (profile.Images.Count == 0) + { + var isFirst = true; + + foreach (var url in data.ImageUrls) + { + profile.Images.Add(new Image + { + Url = url, + IsProfilePicture = isFirst, + CreatedOn = DateTime.UtcNow, + }); + isFirst = false; + } + } + + var publisher = services.GetRequiredService<IUserUpdatedPublisher>(); + await publisher.QueueUserUpdatedAsync(profile, envelope.CorrelationId); + + await db.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Controllers/AdminController.cs b/src/Services/Profile/LoveNet.Profile.Api/Controllers/AdminController.cs new file mode 100644 index 0000000..f5e2862 --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Controllers/AdminController.cs @@ -0,0 +1,29 @@ +using LoveNet.BuildingBlocks.Web; +using LoveNet.BuildingBlocks.Web.Auth; +using LoveNet.Profile.Api.Models; +using LoveNet.Profile.Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LoveNet.Profile.Api.Controllers; + +[Authorize(Roles = LoveNetConstants.AdministratorRoleName)] +[Route("api/admin")] +[ApiController] +public class AdminController : ControllerBase +{ + private readonly IProfileService profileService; + + public AdminController(IProfileService profileService) + { + this.profileService = profileService; + } + + [HttpPost("users")] + public async Task<IActionResult> GetUsers([FromBody] AdminUsersRequestModel request) + { + var result = await profileService.GetUsersForAdminAsync(request, User.GetUserId()); + + return Ok(result); + } +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Controllers/CountriesController.cs b/src/Services/Profile/LoveNet.Profile.Api/Controllers/CountriesController.cs new file mode 100644 index 0000000..0262980 --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Controllers/CountriesController.cs @@ -0,0 +1,30 @@ +using LoveNet.Profile.Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LoveNet.Profile.Api.Controllers; + +[Route("api/countries")] +[ApiController] +public class CountriesController : ControllerBase +{ + private readonly ICountriesService countriesService; + + public CountriesController(ICountriesService countriesService) + { + this.countriesService = countriesService; + } + + [HttpGet] + [AllowAnonymous] + public async Task<IActionResult> GetAll() => Ok(await countriesService.GetAllAsync()); + + [HttpGet("{id:int}")] + [AllowAnonymous] + public async Task<IActionResult> Get(int id) + { + var country = await countriesService.GetAsync(id); + + return country is null ? NotFound() : Ok(country); + } +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Controllers/GendersController.cs b/src/Services/Profile/LoveNet.Profile.Api/Controllers/GendersController.cs new file mode 100644 index 0000000..3f76837 --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Controllers/GendersController.cs @@ -0,0 +1,21 @@ +using LoveNet.Profile.Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LoveNet.Profile.Api.Controllers; + +[Route("api/genders")] +[ApiController] +public class GendersController : ControllerBase +{ + private readonly IGendersService gendersService; + + public GendersController(IGendersService gendersService) + { + this.gendersService = gendersService; + } + + [HttpGet] + [AllowAnonymous] + public async Task<IActionResult> GetAll() => Ok(await gendersService.GetAllAsync()); +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Controllers/InternalController.cs b/src/Services/Profile/LoveNet.Profile.Api/Controllers/InternalController.cs new file mode 100644 index 0000000..427411a --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Controllers/InternalController.cs @@ -0,0 +1,27 @@ +using LoveNet.Profile.Api.Data; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Profile.Api.Controllers; + +/// <summary>Gateway-only endpoints — not routed publicly.</summary> +[Route("internal")] +[ApiController] +public class InternalController : ControllerBase +{ + private readonly ProfileDbContext db; + + public InternalController(ProfileDbContext db) + { + this.db = db; + } + + [HttpGet("stats")] + public async Task<IActionResult> GetStats() + { + return Ok(new + { + ImagesCount = await db.Images.CountAsync(i => !i.IsDeleted), + }); + } +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Controllers/ProfileController.cs b/src/Services/Profile/LoveNet.Profile.Api/Controllers/ProfileController.cs new file mode 100644 index 0000000..87f3480 --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Controllers/ProfileController.cs @@ -0,0 +1,70 @@ +using LoveNet.BuildingBlocks.Web; +using LoveNet.BuildingBlocks.Web.Auth; +using LoveNet.BuildingBlocks.Web.Middleware; +using LoveNet.Profile.Api.Models; +using LoveNet.Profile.Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LoveNet.Profile.Api.Controllers; + +[Route("api/profile")] +[ApiController] +public class ProfileController : ControllerBase +{ + private readonly IProfileService profileService; + + public ProfileController(IProfileService profileService) + { + this.profileService = profileService; + } + + [HttpGet("account/{id:guid}")] + [Authorize] + public async Task<IActionResult> GetAccount(string id) + { + if (User.GetUserId() != id) + { + return Forbid(); + } + + var user = await profileService.GetUserDetailsAsync(id); + + return user is null ? NotFound() : Ok(user); + } + + [HttpPut("account/{id:guid}")] + [Authorize] + public async Task<IActionResult> EditAccount([FromForm] EditUserModel model) + { + if (User.GetUserId() != model.Id) + { + return Forbid(); + } + + await ValidateEditModelAsync(model); + + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + await profileService.EditUserAsync(model, HttpContext.GetCorrelationId()); + var user = await profileService.GetUserDetailsAsync(model.Id); + + return Ok(user); + } + + private async Task ValidateEditModelAsync(EditUserModel model) + { + if (DateHelper.CalculateAge(model.Birthdate) < LoveNetConstants.MinimalAge) + { + ModelState.AddModelError("Error", "You need to be 18 years old to register"); + } + + if (!await profileService.CityBelongsToCountryAsync(model.CityId, model.CountryId)) + { + ModelState.AddModelError("Error", "Invalid city"); + } + } +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Data/City.cs b/src/Services/Profile/LoveNet.Profile.Api/Data/City.cs new file mode 100644 index 0000000..89928d4 --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Data/City.cs @@ -0,0 +1,16 @@ +namespace LoveNet.Profile.Api.Data; + +public class City +{ + public int Id { get; set; } + + public string Name { get; set; } = null!; + + public string NameAscii { get; set; } = null!; + + public double Latitude { get; set; } + + public double Longitude { get; set; } + + public int CountryId { get; set; } +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Data/Country.cs b/src/Services/Profile/LoveNet.Profile.Api/Data/Country.cs new file mode 100644 index 0000000..e1ff58a --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Data/Country.cs @@ -0,0 +1,10 @@ +namespace LoveNet.Profile.Api.Data; + +public class Country +{ + public int Id { get; set; } + + public string Name { get; set; } = null!; + + public virtual ICollection<City> Cities { get; set; } = new HashSet<City>(); +} diff --git a/Data/LOVE.NET.Data/Files/CitiesWithCountries.csv b/src/Services/Profile/LoveNet.Profile.Api/Data/Files/CitiesWithCountries.csv similarity index 100% rename from Data/LOVE.NET.Data/Files/CitiesWithCountries.csv rename to src/Services/Profile/LoveNet.Profile.Api/Data/Files/CitiesWithCountries.csv diff --git a/Data/LOVE.NET.Data/Files/Countries.csv b/src/Services/Profile/LoveNet.Profile.Api/Data/Files/Countries.csv similarity index 100% rename from Data/LOVE.NET.Data/Files/Countries.csv rename to src/Services/Profile/LoveNet.Profile.Api/Data/Files/Countries.csv diff --git a/src/Services/Profile/LoveNet.Profile.Api/Data/Gender.cs b/src/Services/Profile/LoveNet.Profile.Api/Data/Gender.cs new file mode 100644 index 0000000..0cee0f0 --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Data/Gender.cs @@ -0,0 +1,8 @@ +namespace LoveNet.Profile.Api.Data; + +public class Gender +{ + public int Id { get; set; } + + public string Name { get; set; } = null!; +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Data/Image.cs b/src/Services/Profile/LoveNet.Profile.Api/Data/Image.cs new file mode 100644 index 0000000..b8eed4b --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Data/Image.cs @@ -0,0 +1,17 @@ +namespace LoveNet.Profile.Api.Data; + +public class Image +{ + public int Id { get; set; } + + public string Url { get; set; } = null!; + + public bool IsProfilePicture { get; set; } + + /// <summary>Soft delete, preserved from the monolith's edit flow.</summary> + public bool IsDeleted { get; set; } + + public DateTime CreatedOn { get; set; } + + public string UserId { get; set; } = null!; +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Data/Migrations/20260713075156_InitialCreate.Designer.cs b/src/Services/Profile/LoveNet.Profile.Api/Data/Migrations/20260713075156_InitialCreate.Designer.cs new file mode 100644 index 0000000..a8ac8dc --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Data/Migrations/20260713075156_InitialCreate.Designer.cs @@ -0,0 +1,293 @@ +// <auto-generated /> +using System; +using LoveNet.Profile.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LoveNet.Profile.Api.Data.Migrations +{ + [DbContext(typeof(ProfileDbContext))] + [Migration("20260713075156_InitialCreate")] + partial class InitialCreate + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LoveNet.BuildingBlocks.EventBus.Inbox.InboxMessage", b => + { + b.Property<Guid>("MessageId") + .HasColumnType("uuid"); + + b.Property<string>("Consumer") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<DateTime>("ProcessedOn") + .HasColumnType("timestamp without time zone"); + + b.HasKey("MessageId", "Consumer"); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.BuildingBlocks.EventBus.Outbox.OutboxMessage", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<int>("Attempts") + .HasColumnType("integer"); + + b.Property<string>("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<string>("LastError") + .HasColumnType("text"); + + b.Property<DateTime>("OccurredOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime?>("ProcessedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Topic") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedOn"); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.City", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<int>("CountryId") + .HasColumnType("integer"); + + b.Property<double>("Latitude") + .HasColumnType("double precision"); + + b.Property<double>("Longitude") + .HasColumnType("double precision"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("NameAscii") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CountryId"); + + b.ToTable("Cities"); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.Country", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Countries"); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.Gender", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Genders"); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.Image", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<DateTime>("CreatedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<bool>("IsDeleted") + .HasColumnType("boolean"); + + b.Property<bool>("IsProfilePicture") + .HasColumnType("boolean"); + + b.Property<string>("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.UserProfile", b => + { + b.Property<string>("UserId") + .HasColumnType("text"); + + b.Property<string>("Bio") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property<DateTime>("Birthdate") + .HasColumnType("timestamp without time zone"); + + b.Property<int>("CityId") + .HasColumnType("integer"); + + b.Property<int>("CountryId") + .HasColumnType("integer"); + + b.Property<DateTime>("CreatedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<int>("GenderId") + .HasColumnType("integer"); + + b.Property<bool>("IsAdmin") + .HasColumnType("boolean"); + + b.Property<bool>("IsBanned") + .HasColumnType("boolean"); + + b.Property<DateTime?>("ModifiedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("UserName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("UserId"); + + b.HasIndex("CityId"); + + b.HasIndex("CountryId"); + + b.HasIndex("GenderId"); + + b.ToTable("user_profiles", (string)null); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.City", b => + { + b.HasOne("LoveNet.Profile.Api.Data.Country", null) + .WithMany("Cities") + .HasForeignKey("CountryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.Image", b => + { + b.HasOne("LoveNet.Profile.Api.Data.UserProfile", null) + .WithMany("Images") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.UserProfile", b => + { + b.HasOne("LoveNet.Profile.Api.Data.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LoveNet.Profile.Api.Data.Country", "Country") + .WithMany() + .HasForeignKey("CountryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LoveNet.Profile.Api.Data.Gender", "Gender") + .WithMany() + .HasForeignKey("GenderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("Country"); + + b.Navigation("Gender"); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.Country", b => + { + b.Navigation("Cities"); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.UserProfile", b => + { + b.Navigation("Images"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Data/Migrations/20260713075156_InitialCreate.cs b/src/Services/Profile/LoveNet.Profile.Api/Data/Migrations/20260713075156_InitialCreate.cs new file mode 100644 index 0000000..22d9c87 --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Data/Migrations/20260713075156_InitialCreate.cs @@ -0,0 +1,214 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LoveNet.Profile.Api.Data.Migrations +{ + /// <inheritdoc /> + public partial class InitialCreate : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Countries", + columns: table => new + { + Id = table.Column<int>(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column<string>(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Countries", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Genders", + columns: table => new + { + Id = table.Column<int>(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column<string>(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Genders", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "inbox_messages", + columns: table => new + { + MessageId = table.Column<Guid>(type: "uuid", nullable: false), + Consumer = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false), + ProcessedOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_inbox_messages", x => new { x.MessageId, x.Consumer }); + }); + + migrationBuilder.CreateTable( + name: "outbox_messages", + columns: table => new + { + Id = table.Column<Guid>(type: "uuid", nullable: false), + Topic = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false), + Key = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false), + Payload = table.Column<string>(type: "text", nullable: false), + OccurredOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: false), + ProcessedOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: true), + Attempts = table.Column<int>(type: "integer", nullable: false), + LastError = table.Column<string>(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_outbox_messages", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Cities", + columns: table => new + { + Id = table.Column<int>(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column<string>(type: "text", nullable: false), + NameAscii = table.Column<string>(type: "text", nullable: false), + Latitude = table.Column<double>(type: "double precision", nullable: false), + Longitude = table.Column<double>(type: "double precision", nullable: false), + CountryId = table.Column<int>(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Cities", x => x.Id); + table.ForeignKey( + name: "FK_Cities_Countries_CountryId", + column: x => x.CountryId, + principalTable: "Countries", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "user_profiles", + columns: table => new + { + UserId = table.Column<string>(type: "text", nullable: false), + UserName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false), + Email = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false), + Bio = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: true), + Birthdate = table.Column<DateTime>(type: "timestamp without time zone", nullable: false), + GenderId = table.Column<int>(type: "integer", nullable: false), + CountryId = table.Column<int>(type: "integer", nullable: false), + CityId = table.Column<int>(type: "integer", nullable: false), + IsBanned = table.Column<bool>(type: "boolean", nullable: false), + IsAdmin = table.Column<bool>(type: "boolean", nullable: false), + CreatedOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: false), + ModifiedOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_user_profiles", x => x.UserId); + table.ForeignKey( + name: "FK_user_profiles_Cities_CityId", + column: x => x.CityId, + principalTable: "Cities", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_user_profiles_Countries_CountryId", + column: x => x.CountryId, + principalTable: "Countries", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_user_profiles_Genders_GenderId", + column: x => x.GenderId, + principalTable: "Genders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Images", + columns: table => new + { + Id = table.Column<int>(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Url = table.Column<string>(type: "text", nullable: false), + IsProfilePicture = table.Column<bool>(type: "boolean", nullable: false), + IsDeleted = table.Column<bool>(type: "boolean", nullable: false), + CreatedOn = table.Column<DateTime>(type: "timestamp without time zone", nullable: false), + UserId = table.Column<string>(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Images", x => x.Id); + table.ForeignKey( + name: "FK_Images_user_profiles_UserId", + column: x => x.UserId, + principalTable: "user_profiles", + principalColumn: "UserId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Cities_CountryId", + table: "Cities", + column: "CountryId"); + + migrationBuilder.CreateIndex( + name: "IX_Images_UserId", + table: "Images", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_outbox_messages_ProcessedOn", + table: "outbox_messages", + column: "ProcessedOn"); + + migrationBuilder.CreateIndex( + name: "IX_user_profiles_CityId", + table: "user_profiles", + column: "CityId"); + + migrationBuilder.CreateIndex( + name: "IX_user_profiles_CountryId", + table: "user_profiles", + column: "CountryId"); + + migrationBuilder.CreateIndex( + name: "IX_user_profiles_GenderId", + table: "user_profiles", + column: "GenderId"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Images"); + + migrationBuilder.DropTable( + name: "inbox_messages"); + + migrationBuilder.DropTable( + name: "outbox_messages"); + + migrationBuilder.DropTable( + name: "user_profiles"); + + migrationBuilder.DropTable( + name: "Cities"); + + migrationBuilder.DropTable( + name: "Genders"); + + migrationBuilder.DropTable( + name: "Countries"); + } + } +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Data/Migrations/ProfileDbContextModelSnapshot.cs b/src/Services/Profile/LoveNet.Profile.Api/Data/Migrations/ProfileDbContextModelSnapshot.cs new file mode 100644 index 0000000..3e5a256 --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Data/Migrations/ProfileDbContextModelSnapshot.cs @@ -0,0 +1,290 @@ +// <auto-generated /> +using System; +using LoveNet.Profile.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LoveNet.Profile.Api.Data.Migrations +{ + [DbContext(typeof(ProfileDbContext))] + partial class ProfileDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LoveNet.BuildingBlocks.EventBus.Inbox.InboxMessage", b => + { + b.Property<Guid>("MessageId") + .HasColumnType("uuid"); + + b.Property<string>("Consumer") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<DateTime>("ProcessedOn") + .HasColumnType("timestamp without time zone"); + + b.HasKey("MessageId", "Consumer"); + + b.ToTable("inbox_messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.BuildingBlocks.EventBus.Outbox.OutboxMessage", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property<int>("Attempts") + .HasColumnType("integer"); + + b.Property<string>("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property<string>("LastError") + .HasColumnType("text"); + + b.Property<DateTime>("OccurredOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime?>("ProcessedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Topic") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ProcessedOn"); + + b.ToTable("outbox_messages", (string)null); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.City", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<int>("CountryId") + .HasColumnType("integer"); + + b.Property<double>("Latitude") + .HasColumnType("double precision"); + + b.Property<double>("Longitude") + .HasColumnType("double precision"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("NameAscii") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CountryId"); + + b.ToTable("Cities"); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.Country", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Countries"); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.Gender", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Genders"); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.Image", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<DateTime>("CreatedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<bool>("IsDeleted") + .HasColumnType("boolean"); + + b.Property<bool>("IsProfilePicture") + .HasColumnType("boolean"); + + b.Property<string>("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.UserProfile", b => + { + b.Property<string>("UserId") + .HasColumnType("text"); + + b.Property<string>("Bio") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property<DateTime>("Birthdate") + .HasColumnType("timestamp without time zone"); + + b.Property<int>("CityId") + .HasColumnType("integer"); + + b.Property<int>("CountryId") + .HasColumnType("integer"); + + b.Property<DateTime>("CreatedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property<int>("GenderId") + .HasColumnType("integer"); + + b.Property<bool>("IsAdmin") + .HasColumnType("boolean"); + + b.Property<bool>("IsBanned") + .HasColumnType("boolean"); + + b.Property<DateTime?>("ModifiedOn") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("UserName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("UserId"); + + b.HasIndex("CityId"); + + b.HasIndex("CountryId"); + + b.HasIndex("GenderId"); + + b.ToTable("user_profiles", (string)null); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.City", b => + { + b.HasOne("LoveNet.Profile.Api.Data.Country", null) + .WithMany("Cities") + .HasForeignKey("CountryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.Image", b => + { + b.HasOne("LoveNet.Profile.Api.Data.UserProfile", null) + .WithMany("Images") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.UserProfile", b => + { + b.HasOne("LoveNet.Profile.Api.Data.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LoveNet.Profile.Api.Data.Country", "Country") + .WithMany() + .HasForeignKey("CountryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LoveNet.Profile.Api.Data.Gender", "Gender") + .WithMany() + .HasForeignKey("GenderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("Country"); + + b.Navigation("Gender"); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.Country", b => + { + b.Navigation("Cities"); + }); + + modelBuilder.Entity("LoveNet.Profile.Api.Data.UserProfile", b => + { + b.Navigation("Images"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Data/ProfileDbContext.cs b/src/Services/Profile/LoveNet.Profile.Api/Data/ProfileDbContext.cs new file mode 100644 index 0000000..facfb76 --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Data/ProfileDbContext.cs @@ -0,0 +1,44 @@ +using LoveNet.BuildingBlocks.EventBus; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Profile.Api.Data; + +public class ProfileDbContext : DbContext +{ + public ProfileDbContext(DbContextOptions<ProfileDbContext> options) + : base(options) + { + } + + public DbSet<UserProfile> UserProfiles => Set<UserProfile>(); + + public DbSet<Image> Images => Set<Image>(); + + public DbSet<Country> Countries => Set<Country>(); + + public DbSet<City> Cities => Set<City>(); + + public DbSet<Gender> Genders => Set<Gender>(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity<UserProfile>(entity => + { + entity.ToTable("user_profiles"); + entity.HasKey(p => p.UserId); + entity.HasMany(p => p.Images) + .WithOne() + .HasForeignKey(i => i.UserId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity<Country>() + .HasMany(c => c.Cities) + .WithOne() + .HasForeignKey(c => c.CountryId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.AddOutbox(); + modelBuilder.AddInbox(); + } +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Data/Seeding/ProfileSeeder.cs b/src/Services/Profile/LoveNet.Profile.Api/Data/Seeding/ProfileSeeder.cs new file mode 100644 index 0000000..cac2922 --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Data/Seeding/ProfileSeeder.cs @@ -0,0 +1,81 @@ +using System.Globalization; +using CsvHelper; +using CsvHelper.Configuration; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Profile.Api.Data.Seeding; + +/// <summary>Seeds the geo/gender reference data from the monolith's CSV files.</summary> +public static class ProfileSeeder +{ + public static async Task SeedAsync(ProfileDbContext db) + { + await SeedGendersAsync(db); + await SeedCountriesAndCitiesAsync(db); + } + + private static async Task SeedGendersAsync(ProfileDbContext db) + { + if (await db.Genders.AnyAsync()) + { + return; + } + + db.Genders.AddRange( + new Gender { Name = "Male" }, + new Gender { Name = "Female" }, + new Gender { Name = "Other" }); + + await db.SaveChangesAsync(); + } + + private static async Task SeedCountriesAndCitiesAsync(ProfileDbContext db) + { + if (await db.Countries.AnyAsync()) + { + return; + } + + var config = new CsvConfiguration(CultureInfo.InvariantCulture) { Delimiter = ";" }; + + using (var reader = new StreamReader(SeedFile("Countries.csv"))) + using (var csv = new CsvReader(reader, config)) + { + csv.Context.RegisterClassMap<CountriesCsvMap>(); + db.Countries.AddRange(csv.GetRecords<Country>().ToList()); + await db.SaveChangesAsync(); + } + + using (var reader = new StreamReader(SeedFile("CitiesWithCountries.csv"))) + using (var csv = new CsvReader(reader, config)) + { + csv.Context.RegisterClassMap<CitiesCsvMap>(); + db.Cities.AddRange(csv.GetRecords<City>().ToList()); + await db.SaveChangesAsync(); + } + } + + private static string SeedFile(string fileName) => + Path.Combine(AppContext.BaseDirectory, "Data", "Files", fileName); + + private sealed class CountriesCsvMap : ClassMap<Country> + { + private CountriesCsvMap() + { + Map(m => m.Id).Ignore(); + Map(m => m.Name); + } + } + + private sealed class CitiesCsvMap : ClassMap<City> + { + private CitiesCsvMap() + { + Map(m => m.Name); + Map(m => m.NameAscii); + Map(m => m.Latitude); + Map(m => m.Longitude); + Map(m => m.CountryId); + } + } +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Data/UserProfile.cs b/src/Services/Profile/LoveNet.Profile.Api/Data/UserProfile.cs new file mode 100644 index 0000000..984edeb --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Data/UserProfile.cs @@ -0,0 +1,49 @@ +using System.ComponentModel.DataAnnotations; + +namespace LoveNet.Profile.Api.Data; + +/// <summary> +/// Profile aggregate owned by this service. The user id comes from Identity +/// via the identity.user-registered event and is shared across all services. +/// </summary> +public class UserProfile +{ + public string UserId { get; set; } = null!; + + [Required] + [MaxLength(100)] + public string UserName { get; set; } = null!; + + [Required] + [MaxLength(100)] + public string Email { get; set; } = null!; + + [MaxLength(255)] + public string? Bio { get; set; } + + public DateTime Birthdate { get; set; } + + public int GenderId { get; set; } + + public virtual Gender Gender { get; set; } = null!; + + public int CountryId { get; set; } + + public virtual Country Country { get; set; } = null!; + + public int CityId { get; set; } + + public virtual City City { get; set; } = null!; + + /// <summary>Mirrors Identity's lockout via identity.user-banned events.</summary> + public bool IsBanned { get; set; } + + /// <summary>Mirrors the Administrator role; downstream services exclude admins from the deck.</summary> + public bool IsAdmin { get; set; } + + public DateTime CreatedOn { get; set; } + + public DateTime? ModifiedOn { get; set; } + + public virtual ICollection<Image> Images { get; set; } = new HashSet<Image>(); +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Dockerfile b/src/Services/Profile/LoveNet.Profile.Api/Dockerfile new file mode 100644 index 0000000..8f59251 --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Dockerfile @@ -0,0 +1,17 @@ +# Build context is the repository root. +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY NuGet.config . +COPY src/Directory.Build.props src/ +COPY src/BuildingBlocks/ src/BuildingBlocks/ +COPY src/Services/Profile/ src/Services/Profile/ +RUN dotnet publish src/Services/Profile/LoveNet.Profile.Api/LoveNet.Profile.Api.csproj -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +# curl is used by the compose healthcheck. +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +COPY --from=build /app/publish . +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 +ENTRYPOINT ["dotnet", "LoveNet.Profile.Api.dll"] diff --git a/src/Services/Profile/LoveNet.Profile.Api/LoveNet.Profile.Api.csproj b/src/Services/Profile/LoveNet.Profile.Api/LoveNet.Profile.Api.csproj new file mode 100644 index 0000000..9ec4ef2 --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/LoveNet.Profile.Api.csproj @@ -0,0 +1,28 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <ItemGroup> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.Contracts\LoveNet.BuildingBlocks.Contracts.csproj" /> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.EventBus\LoveNet.BuildingBlocks.EventBus.csproj" /> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.Web\LoveNet.BuildingBlocks.Web.csproj" /> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.Media\LoveNet.BuildingBlocks.Media.csproj" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="CsvHelper" Version="33.0.1" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" /> + <PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.11" /> + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" /> + <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" /> + </ItemGroup> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + </PropertyGroup> + + <ItemGroup> + <None Update="Data\Files\*.csv" CopyToOutputDirectory="PreserveNewest" /> + </ItemGroup> + +</Project> diff --git a/src/Services/Profile/LoveNet.Profile.Api/Models/ProfileModels.cs b/src/Services/Profile/LoveNet.Profile.Api/Models/ProfileModels.cs new file mode 100644 index 0000000..632312c --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Models/ProfileModels.cs @@ -0,0 +1,128 @@ +using System.ComponentModel.DataAnnotations; +using LoveNet.BuildingBlocks.Web; +using LoveNet.BuildingBlocks.Web.Validation; + +namespace LoveNet.Profile.Api.Models; + +/// <summary>Account response — same shape the SPA already consumes (minus the unused Matches list).</summary> +public class UserDetailsModel +{ + public string Id { get; set; } = null!; + + public string Email { get; set; } = null!; + + public string UserName { get; set; } = null!; + + public string? Bio { get; set; } + + public DateTime Birthdate { get; set; } + + public List<ImageModel> Images { get; set; } = new(); + + public int GenderId { get; set; } + + public string? GenderName { get; set; } + + public int CityId { get; set; } + + public string? CityName { get; set; } + + public int CountryId { get; set; } + + public string? CountryName { get; set; } + + public double Latitude { get; set; } + + public double Longitude { get; set; } + + public bool IsBanned { get; set; } +} + +public class ImageModel +{ + public int Id { get; set; } + + public string Url { get; set; } = null!; + + public bool IsProfilePicture { get; set; } +} + +/// <summary>Multipart account edit form; Images items are JSON-serialized ImageModel strings (SPA quirk kept).</summary> +public class EditUserModel +{ + [Required] + public string Id { get; set; } = null!; + + [Required] + [StringLength(100)] + [EmailAddress] + public string Email { get; set; } = null!; + + [Required] + [StringLength(100)] + public string UserName { get; set; } = null!; + + [Required] + [MaxLength(255)] + public string Bio { get; set; } = null!; + + public DateTime Birthdate { get; set; } + + public int CountryId { get; set; } + + public int GenderId { get; set; } + + public int CityId { get; set; } + + public IEnumerable<string>? Images { get; set; } + + [MaxFileSize(LoveNetConstants.MaxFileSizeInBytes)] + [AllowedFileExtensions(new[] { ".jpg", ".png" })] + public IFormFile[]? NewImages { get; set; } +} + +public class CountryModel +{ + public int CountryId { get; set; } + + public string CountryName { get; set; } = null!; +} + +public class CityModel +{ + public int CityId { get; set; } + + public string CityName { get; set; } = null!; +} + +public class CountryCitiesModel +{ + public int CountryId { get; set; } + + public string CountryName { get; set; } = null!; + + public List<CityModel> Cities { get; set; } = new(); +} + +public class GenderModel +{ + public int Id { get; set; } + + public string Name { get; set; } = null!; +} + +public class AdminUsersRequestModel +{ + public bool ShowBanned { get; set; } + + public string? Search { get; set; } + + public int Page { get; set; } +} + +public class AdminUsersResponseModel +{ + public IEnumerable<UserDetailsModel> Users { get; set; } = Enumerable.Empty<UserDetailsModel>(); + + public int TotalUsers { get; set; } +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Program.cs b/src/Services/Profile/LoveNet.Profile.Api/Program.cs new file mode 100644 index 0000000..368a5ac --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Program.cs @@ -0,0 +1,52 @@ +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.Media; +using LoveNet.BuildingBlocks.Web; +using LoveNet.BuildingBlocks.Web.Auth; +using LoveNet.Profile.Api.Consumers; +using LoveNet.Profile.Api.Data; +using LoveNet.Profile.Api.Data.Seeding; +using LoveNet.Profile.Api.Services; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddLoveNetDefaults("profile-api"); + +builder.Services.AddDbContext<ProfileDbContext>(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("Default"))); + +builder.Services.AddLoveNetJwt(builder.Configuration); +builder.Services.AddKafkaEventBus(builder.Configuration); +builder.Services.AddOutboxPublisher<ProfileDbContext>(); +builder.Services.AddHostedService<UserRegisteredConsumer>(); +builder.Services.AddHostedService<UserBannedConsumer>(); +builder.Services.AddCloudinaryMedia(builder.Configuration); + +builder.Services.AddScoped<IProfileService, ProfileService>(); +builder.Services.AddScoped<IUserUpdatedPublisher, UserUpdatedPublisher>(); +builder.Services.AddScoped<ICountriesService, CountriesService>(); +builder.Services.AddScoped<IGendersService, GendersService>(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddLoveNetHealthChecks(builder.Configuration, postgres: true, kafka: true); + +var app = builder.Build(); + +app.UseLoveNetDefaults(); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.MapLoveNetHealth(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService<ProfileDbContext>(); + await db.Database.MigrateAsync(); + await ProfileSeeder.SeedAsync(db); +} + +await app.RunAsync(); diff --git a/src/Services/Profile/LoveNet.Profile.Api/Properties/launchSettings.json b/src/Services/Profile/LoveNet.Profile.Api/Properties/launchSettings.json new file mode 100644 index 0000000..4f52e9a --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:55431", + "sslPort": 44340 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5183", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7202;http://localhost:5183", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Services/CountriesService.cs b/src/Services/Profile/LoveNet.Profile.Api/Services/CountriesService.cs new file mode 100644 index 0000000..d54664a --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Services/CountriesService.cs @@ -0,0 +1,60 @@ +using LoveNet.Profile.Api.Data; +using LoveNet.Profile.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Profile.Api.Services; + +public interface ICountriesService +{ + Task<List<CountryModel>> GetAllAsync(); + + Task<CountryCitiesModel?> GetAsync(int id); +} + +public class CountriesService : ICountriesService +{ + private readonly ProfileDbContext db; + + public CountriesService(ProfileDbContext db) + { + this.db = db; + } + + public async Task<List<CountryModel>> GetAllAsync() + { + var countries = await db.Countries.AsNoTracking() + .Select(c => new CountryModel { CountryId = c.Id, CountryName = c.Name }) + .ToListAsync(); + + // Placeholder option the SPA dropdown expects at index 0. + countries.Insert(0, new CountryModel { CountryId = 0, CountryName = "Chooce country here" }); + + return countries; + } + + public async Task<CountryCitiesModel?> GetAsync(int id) + { + var country = await db.Countries.AsNoTracking() + .Where(c => c.Id == id) + .Select(c => new CountryCitiesModel + { + CountryId = c.Id, + CountryName = c.Name, + Cities = c.Cities + .Select(city => new CityModel { CityId = city.Id, CityName = city.NameAscii }) + .ToList(), + }) + .FirstOrDefaultAsync(); + + if (country is null) + { + return null; + } + + var orderedCities = country.Cities.OrderBy(c => c.CityName).ToList(); + orderedCities.Insert(0, new CityModel { CityId = 0, CityName = "Choose city here" }); + country.Cities = orderedCities; + + return country; + } +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Services/GendersService.cs b/src/Services/Profile/LoveNet.Profile.Api/Services/GendersService.cs new file mode 100644 index 0000000..1ba6b5e --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Services/GendersService.cs @@ -0,0 +1,25 @@ +using LoveNet.Profile.Api.Data; +using LoveNet.Profile.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Profile.Api.Services; + +public interface IGendersService +{ + Task<List<GenderModel>> GetAllAsync(); +} + +public class GendersService : IGendersService +{ + private readonly ProfileDbContext db; + + public GendersService(ProfileDbContext db) + { + this.db = db; + } + + public Task<List<GenderModel>> GetAllAsync() => + db.Genders.AsNoTracking() + .Select(g => new GenderModel { Id = g.Id, Name = g.Name }) + .ToListAsync(); +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Services/IProfileService.cs b/src/Services/Profile/LoveNet.Profile.Api/Services/IProfileService.cs new file mode 100644 index 0000000..4cadcd6 --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Services/IProfileService.cs @@ -0,0 +1,14 @@ +using LoveNet.Profile.Api.Models; + +namespace LoveNet.Profile.Api.Services; + +public interface IProfileService +{ + Task<UserDetailsModel?> GetUserDetailsAsync(string userId); + + Task EditUserAsync(EditUserModel model, string? correlationId); + + Task<AdminUsersResponseModel> GetUsersForAdminAsync(AdminUsersRequestModel request, string loggedUserId); + + Task<bool> CityBelongsToCountryAsync(int cityId, int countryId); +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Services/ProfileService.cs b/src/Services/Profile/LoveNet.Profile.Api/Services/ProfileService.cs new file mode 100644 index 0000000..15e19cb --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Services/ProfileService.cs @@ -0,0 +1,153 @@ +using System.Text.Json; +using LoveNet.BuildingBlocks.Media; +using LoveNet.BuildingBlocks.Web; +using LoveNet.Profile.Api.Data; +using LoveNet.Profile.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Profile.Api.Services; + +public class ProfileService : IProfileService +{ + private const string AdministratorEmail = "admin@admin.admin"; + + private static readonly JsonSerializerOptions ImageJsonOptions = new(JsonSerializerDefaults.Web); + + private readonly ProfileDbContext db; + private readonly IImagesService imagesService; + private readonly IUserUpdatedPublisher userUpdatedPublisher; + + public ProfileService( + ProfileDbContext db, + IImagesService imagesService, + IUserUpdatedPublisher userUpdatedPublisher) + { + this.db = db; + this.imagesService = imagesService; + this.userUpdatedPublisher = userUpdatedPublisher; + } + + public async Task<UserDetailsModel?> GetUserDetailsAsync(string userId) + { + return await QueryDetails(db.UserProfiles.AsNoTracking().Where(p => p.UserId == userId)) + .FirstOrDefaultAsync(); + } + + public async Task EditUserAsync(EditUserModel model, string? correlationId) + { + var user = await db.UserProfiles + .Include(p => p.Images) + .FirstAsync(p => p.UserId == model.Id); + + var uploads = model.NewImages?.Where(f => f is not null).ToList() ?? new List<IFormFile>(); + var uploadedUrls = (await imagesService.UploadImagesAsync(uploads)).ToList(); + + var updatedImages = (model.Images ?? Enumerable.Empty<string>()) + .Select(json => JsonSerializer.Deserialize<ImageModel>(json, ImageJsonOptions)!) + .ToList(); + + foreach (var image in user.Images) + { + var updatedImage = updatedImages.FirstOrDefault(i => i.Id == image.Id); + + if (updatedImage is null) + { + image.IsDeleted = true; + } + else + { + image.IsProfilePicture = updatedImage.IsProfilePicture; + } + } + + foreach (var url in uploadedUrls) + { + user.Images.Add(new Image + { + CreatedOn = DateTime.UtcNow, + Url = url, + IsProfilePicture = false, + }); + } + + user.UserName = model.UserName; + user.Bio = model.Bio; + user.Birthdate = model.Birthdate; + user.CountryId = model.CountryId; + user.CityId = model.CityId; + user.GenderId = model.GenderId; + user.ModifiedOn = DateTime.UtcNow; + + await userUpdatedPublisher.QueueUserUpdatedAsync(user, correlationId); + await db.SaveChangesAsync(); + } + + public async Task<AdminUsersResponseModel> GetUsersForAdminAsync(AdminUsersRequestModel request, string loggedUserId) + { + var users = db.UserProfiles.AsNoTracking() + .Where(u => u.Email != AdministratorEmail && u.UserId != loggedUserId); + + if (!string.IsNullOrEmpty(request.Search)) + { + var searchTerm = $"%{request.Search.Trim().ToLower()}%"; + + users = users.Where(u => + EF.Functions.Like(u.Email.ToLower(), searchTerm) || + EF.Functions.Like(u.UserName.ToLower(), searchTerm) || + EF.Functions.Like(u.Bio!.ToLower(), searchTerm) || + EF.Functions.Like(u.City.Name.ToLower(), searchTerm) || + EF.Functions.Like(u.Country.Name.ToLower(), searchTerm) || + EF.Functions.Like(u.Gender.Name.ToLower(), searchTerm)); + } + + if (request.ShowBanned) + { + users = users.Where(u => u.IsBanned); + } + + var totalUsers = await users.CountAsync(); + + var page = users + .OrderByDescending(u => u.CreatedOn) + .Skip((request.Page - 1) * LoveNetConstants.DefaultTake) + .Take(LoveNetConstants.DefaultTake); + + return new AdminUsersResponseModel + { + Users = await QueryDetails(page).ToListAsync(), + TotalUsers = totalUsers, + }; + } + + public Task<bool> CityBelongsToCountryAsync(int cityId, int countryId) => + db.Cities.AsNoTracking().AnyAsync(c => c.Id == cityId && c.CountryId == countryId); + + private static IQueryable<UserDetailsModel> QueryDetails(IQueryable<UserProfile> query) => + query.Select(p => new UserDetailsModel + { + Id = p.UserId, + Email = p.Email, + UserName = p.UserName, + Bio = p.Bio, + Birthdate = p.Birthdate, + GenderId = p.GenderId, + GenderName = p.Gender.Name, + CityId = p.CityId, + CityName = p.City.Name, + CountryId = p.CountryId, + CountryName = p.Country.Name, + Latitude = p.City.Latitude, + Longitude = p.City.Longitude, + IsBanned = p.IsBanned, + Images = p.Images + .Where(i => !i.IsDeleted) + .OrderByDescending(i => i.IsProfilePicture) + .Select(i => new ImageModel + { + Id = i.Id, + Url = i.Url, + IsProfilePicture = i.IsProfilePicture, + }) + .ToList(), + }); +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/Services/UserUpdatedPublisher.cs b/src/Services/Profile/LoveNet.Profile.Api/Services/UserUpdatedPublisher.cs new file mode 100644 index 0000000..2a71b74 --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/Services/UserUpdatedPublisher.cs @@ -0,0 +1,60 @@ +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.Profile.Api.Data; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Profile.Api.Services; + +public interface IUserUpdatedPublisher +{ + /// <summary> + /// Queues an enriched profile.user-updated outbox event reflecting the + /// (possibly not yet saved) state of the given profile. The caller's + /// SaveChanges commits event and state together. + /// </summary> + Task QueueUserUpdatedAsync(UserProfile profile, string? correlationId); +} + +public class UserUpdatedPublisher : IUserUpdatedPublisher +{ + private readonly ProfileDbContext db; + + public UserUpdatedPublisher(ProfileDbContext db) + { + this.db = db; + } + + public async Task QueueUserUpdatedAsync(UserProfile profile, string? correlationId) + { + var gender = await db.Genders.AsNoTracking().FirstOrDefaultAsync(g => g.Id == profile.GenderId); + var city = await db.Cities.AsNoTracking().FirstOrDefaultAsync(c => c.Id == profile.CityId); + var country = await db.Countries.AsNoTracking().FirstOrDefaultAsync(c => c.Id == profile.CountryId); + + var visibleImages = profile.Images + .Where(i => !i.IsDeleted) + .OrderByDescending(i => i.IsProfilePicture) + .ToList(); + + var payload = new UserUpdatedEvent( + profile.UserId, + profile.Email, + profile.UserName, + profile.Bio, + profile.Birthdate, + profile.GenderId, + gender?.Name, + profile.CityId, + city?.Name, + city?.Latitude, + city?.Longitude, + profile.CountryId, + country?.Name, + visibleImages.FirstOrDefault(i => i.IsProfilePicture)?.Url ?? visibleImages.FirstOrDefault()?.Url, + visibleImages.Select(i => i.Url).ToList(), + profile.IsBanned, + profile.IsAdmin); + + db.AddOutboxMessage(KafkaTopics.UserUpdated, profile.UserId, payload, correlationId); + } +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/appsettings.Development.json b/src/Services/Profile/LoveNet.Profile.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Services/Profile/LoveNet.Profile.Api/appsettings.json b/src/Services/Profile/LoveNet.Profile.Api/appsettings.json new file mode 100644 index 0000000..6a0cdb6 --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Api/appsettings.json @@ -0,0 +1,34 @@ +{ + "ConnectionStrings": { + "Default": "Host=localhost;Port=5434;Database=lovenet_profile;Username=lovenet;Password=lovenet" + }, + "Kafka": { + "BootstrapServers": "localhost:29092" + }, + "Jwt": { + "Key": "lovenet-local-development-signing-key-do-not-use-in-production-0123456789abcdef", + "Issuer": "https://localhost:3000/", + "Audience": "https://localhost:3000/" + }, + "Cloudinary": { + "CloudName": "", + "Key": "", + "Secret": "" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Services/Profile/LoveNet.Profile.Tests/CountriesServiceTests.cs b/src/Services/Profile/LoveNet.Profile.Tests/CountriesServiceTests.cs new file mode 100644 index 0000000..2bbd462 --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Tests/CountriesServiceTests.cs @@ -0,0 +1,69 @@ +using LoveNet.Profile.Api.Data; +using LoveNet.Profile.Api.Services; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Profile.Tests; + +[TestFixture] +public class CountriesServiceTests +{ + private ProfileDbContext db = null!; + private CountriesService countriesService = null!; + + [SetUp] + public async Task Setup() + { + db = TestDb.Create(); + + db.Countries.Add(new Country + { + Id = 1, + Name = "Bulgaria", + Cities = + { + new City { Id = 2, Name = "София", NameAscii = "Sofia", Latitude = 42.7, Longitude = 23.3, CountryId = 1 }, + new City { Id = 1, Name = "Пловдив", NameAscii = "Plovdiv", Latitude = 42.15, Longitude = 24.75, CountryId = 1 }, + }, + }); + await db.SaveChangesAsync(); + + countriesService = new CountriesService(db); + } + + [TearDown] + public void TearDown() => db.Dispose(); + + [Test] + public async Task SuccessGetAllPrependsPlaceholder() + { + var countries = await countriesService.GetAllAsync(); + + Assert.Multiple(() => + { + Assert.That(countries, Has.Count.EqualTo(2)); + Assert.That(countries[0].CountryId, Is.EqualTo(0)); + Assert.That(countries[1].CountryName, Is.EqualTo("Bulgaria")); + }); + } + + [Test] + public async Task SuccessGetByIdReturnsOrderedCities() + { + var country = await countriesService.GetAsync(1); + + Assert.That(country, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(country!.Cities, Has.Count.EqualTo(3)); + Assert.That(country.Cities[0].CityId, Is.EqualTo(0)); + Assert.That(country.Cities[1].CityName, Is.EqualTo("Plovdiv")); + Assert.That(country.Cities[2].CityName, Is.EqualTo("Sofia")); + }); + } + + [Test] + public async Task GetByIdReturnsNullForUnknownCountry() + { + Assert.That(await countriesService.GetAsync(999), Is.Null); + } +} diff --git a/src/Services/Profile/LoveNet.Profile.Tests/GendersServiceTests.cs b/src/Services/Profile/LoveNet.Profile.Tests/GendersServiceTests.cs new file mode 100644 index 0000000..f0859cf --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Tests/GendersServiceTests.cs @@ -0,0 +1,23 @@ +using LoveNet.Profile.Api.Data; +using LoveNet.Profile.Api.Services; + +namespace LoveNet.Profile.Tests; + +[TestFixture] +public class GendersServiceTests +{ + [Test] + public async Task SuccessGetAll() + { + using var db = TestDb.Create(); + db.Genders.AddRange( + new Gender { Name = "Male" }, + new Gender { Name = "Female" }, + new Gender { Name = "Other" }); + await db.SaveChangesAsync(); + + var genders = await new GendersService(db).GetAllAsync(); + + Assert.That(genders.Select(g => g.Name), Is.EqualTo(new[] { "Male", "Female", "Other" })); + } +} diff --git a/src/Services/Profile/LoveNet.Profile.Tests/LoveNet.Profile.Tests.csproj b/src/Services/Profile/LoveNet.Profile.Tests/LoveNet.Profile.Tests.csproj new file mode 100644 index 0000000..50daf9a --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Tests/LoveNet.Profile.Tests.csproj @@ -0,0 +1,30 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="coverlet.collector" Version="6.0.0" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> + <PackageReference Include="Moq" Version="4.20.70" /> + <PackageReference Include="NUnit" Version="3.14.0" /> + <PackageReference Include="NUnit.Analyzers" Version="3.9.0" /> + <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> + </ItemGroup> + + <ItemGroup> + <Using Include="NUnit.Framework" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\LoveNet.Profile.Api\LoveNet.Profile.Api.csproj" /> + </ItemGroup> + +</Project> diff --git a/src/Services/Profile/LoveNet.Profile.Tests/ProfileServiceTests.cs b/src/Services/Profile/LoveNet.Profile.Tests/ProfileServiceTests.cs new file mode 100644 index 0000000..8fd129a --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Tests/ProfileServiceTests.cs @@ -0,0 +1,168 @@ +using System.Text.Json; +using LoveNet.BuildingBlocks.EventBus.Outbox; +using LoveNet.BuildingBlocks.Media; +using LoveNet.Profile.Api.Data; +using LoveNet.Profile.Api.Models; +using LoveNet.Profile.Api.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace LoveNet.Profile.Tests; + +[TestFixture] +public class ProfileServiceTests +{ + private ProfileDbContext db = null!; + private ProfileService profileService = null!; + private UserProfile profile = null!; + + [SetUp] + public async Task Setup() + { + db = TestDb.Create(); + + db.Genders.Add(new Gender { Id = 1, Name = "Male" }); + db.Countries.Add(new Country + { + Id = 1, + Name = "Bulgaria", + Cities = { new City { Id = 1, Name = "София", NameAscii = "Sofia", Latitude = 42.7, Longitude = 23.3, CountryId = 1 } }, + }); + + profile = new UserProfile + { + UserId = Guid.NewGuid().ToString(), + UserName = "testuser", + Email = "user@lovenet.test", + Bio = "hi", + Birthdate = DateTime.UtcNow.AddYears(-25), + GenderId = 1, + CountryId = 1, + CityId = 1, + CreatedOn = DateTime.UtcNow, + Images = + { + new Image { Id = 1, Url = "https://img/1.png", IsProfilePicture = true, CreatedOn = DateTime.UtcNow }, + new Image { Id = 2, Url = "https://img/2.png", CreatedOn = DateTime.UtcNow }, + }, + }; + db.UserProfiles.Add(profile); + await db.SaveChangesAsync(); + + var imagesService = new Mock<IImagesService>(); + imagesService + .Setup(s => s.UploadImagesAsync(It.IsAny<IEnumerable<IFormFile>>())) + .ReturnsAsync(Enumerable.Empty<string>()); + + profileService = new ProfileService(db, imagesService.Object, new UserUpdatedPublisher(db)); + } + + [TearDown] + public void TearDown() => db.Dispose(); + + [Test] + public async Task SuccessGetUserDetails() + { + var details = await profileService.GetUserDetailsAsync(profile.UserId); + + Assert.That(details, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(details!.UserName, Is.EqualTo("testuser")); + Assert.That(details.CityName, Is.EqualTo("София")); + Assert.That(details.Latitude, Is.EqualTo(42.7)); + Assert.That(details.Images, Has.Count.EqualTo(2)); + Assert.That(details.Images[0].IsProfilePicture, Is.True); + }); + } + + [Test] + public async Task SuccessEditUserSoftDeletesRemovedImagesAndEmitsUserUpdated() + { + // Keep only image 2 and promote it to profile picture; image 1 is soft-deleted. + var keptImage = JsonSerializer.Serialize( + new ImageModel { Id = 2, Url = "https://img/2.png", IsProfilePicture = true }, + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + await profileService.EditUserAsync( + new EditUserModel + { + Id = profile.UserId, + Email = profile.Email, + UserName = "renamed", + Bio = "new bio", + Birthdate = profile.Birthdate, + CountryId = 1, + GenderId = 1, + CityId = 1, + Images = new[] { keptImage }, + }, + correlationId: null); + + var updated = await db.UserProfiles.Include(p => p.Images).SingleAsync(); + Assert.Multiple(() => + { + Assert.That(updated.UserName, Is.EqualTo("renamed")); + Assert.That(updated.Images.Single(i => i.Id == 1).IsDeleted, Is.True); + Assert.That(updated.Images.Single(i => i.Id == 2).IsProfilePicture, Is.True); + }); + + var outbox = await db.Set<OutboxMessage>().SingleAsync(); + Assert.Multiple(() => + { + Assert.That(outbox.Topic, Is.EqualTo("profile.user-updated")); + Assert.That(outbox.Payload, Does.Contain("renamed")); + Assert.That(outbox.Payload, Does.Contain("https://img/2.png")); + Assert.That(outbox.Payload, Does.Not.Contain("https://img/1.png")); + }); + } + + [Test] + public async Task SuccessGetUsersForAdminFiltersAndPages() + { + for (var i = 0; i < 12; i++) + { + db.UserProfiles.Add(new UserProfile + { + UserId = Guid.NewGuid().ToString(), + UserName = $"extra{i}", + Email = $"extra{i}@lovenet.test", + Bio = "filler", + GenderId = 1, + CountryId = 1, + CityId = 1, + IsBanned = i % 2 == 0, + CreatedOn = DateTime.UtcNow.AddMinutes(i), + }); + } + + await db.SaveChangesAsync(); + + var firstPage = await profileService.GetUsersForAdminAsync( + new AdminUsersRequestModel { Page = 1 }, loggedUserId: profile.UserId); + + Assert.Multiple(() => + { + Assert.That(firstPage.TotalUsers, Is.EqualTo(12)); + Assert.That(firstPage.Users.Count(), Is.EqualTo(10)); + }); + + var banned = await profileService.GetUsersForAdminAsync( + new AdminUsersRequestModel { Page = 1, ShowBanned = true }, loggedUserId: profile.UserId); + + Assert.That(banned.TotalUsers, Is.EqualTo(6)); + + var searched = await profileService.GetUsersForAdminAsync( + new AdminUsersRequestModel { Page = 1, Search = "extra1@lovenet" }, loggedUserId: profile.UserId); + + Assert.That(searched.TotalUsers, Is.EqualTo(1)); + } + + [Test] + public async Task CityBelongsToCountryChecksGeoConsistency() + { + Assert.That(await profileService.CityBelongsToCountryAsync(1, 1), Is.True); + Assert.That(await profileService.CityBelongsToCountryAsync(1, 2), Is.False); + } +} diff --git a/src/Services/Profile/LoveNet.Profile.Tests/TestDb.cs b/src/Services/Profile/LoveNet.Profile.Tests/TestDb.cs new file mode 100644 index 0000000..07e8ebf --- /dev/null +++ b/src/Services/Profile/LoveNet.Profile.Tests/TestDb.cs @@ -0,0 +1,16 @@ +using LoveNet.Profile.Api.Data; +using Microsoft.EntityFrameworkCore; + +namespace LoveNet.Profile.Tests; + +public static class TestDb +{ + public static ProfileDbContext Create() + { + var options = new DbContextOptionsBuilder<ProfileDbContext>() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + return new ProfileDbContext(options); + } +} diff --git a/src/Services/Realtime/LoveNet.Realtime.Api/Consumers/MatchCreatedConsumer.cs b/src/Services/Realtime/LoveNet.Realtime.Api/Consumers/MatchCreatedConsumer.cs new file mode 100644 index 0000000..267ce3f --- /dev/null +++ b/src/Services/Realtime/LoveNet.Realtime.Api/Consumers/MatchCreatedConsumer.cs @@ -0,0 +1,44 @@ +using Confluent.Kafka; +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.EventBus.Consuming; +using LoveNet.Realtime.Api.Hubs; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Options; + +namespace LoveNet.Realtime.Api.Consumers; + +/// <summary> +/// Pushes a "MatchReceived" toast to both matched users. No inbox by design: +/// redelivery only repeats a toast and the client dedupes. +/// </summary> +public class MatchCreatedConsumer : KafkaConsumerService<MatchCreatedEvent> +{ + private const string ConsumerName = "realtime-service"; + + public MatchCreatedConsumer( + IServiceScopeFactory scopeFactory, + IProducer<string, string> producer, + IOptions<KafkaOptions> options, + ILogger<MatchCreatedConsumer> logger) + : base(scopeFactory, producer, options, logger, KafkaTopics.MatchCreated, ConsumerName) + { + } + + protected override async Task HandleAsync(IServiceProvider services, EventEnvelope<MatchCreatedEvent> envelope, CancellationToken cancellationToken) + { + var hub = services.GetRequiredService<IHubContext<ChatHub>>(); + var data = envelope.Data; + + await hub.Clients.User(data.UserA.Id).SendAsync( + "MatchReceived", + new { data.MatchId, data.RoomId, User = data.UserB }, + cancellationToken); + + await hub.Clients.User(data.UserB.Id).SendAsync( + "MatchReceived", + new { data.MatchId, data.RoomId, User = data.UserA }, + cancellationToken); + } +} diff --git a/src/Services/Realtime/LoveNet.Realtime.Api/Consumers/RoomProvisionedConsumer.cs b/src/Services/Realtime/LoveNet.Realtime.Api/Consumers/RoomProvisionedConsumer.cs new file mode 100644 index 0000000..f6ae7ad --- /dev/null +++ b/src/Services/Realtime/LoveNet.Realtime.Api/Consumers/RoomProvisionedConsumer.cs @@ -0,0 +1,35 @@ +using Confluent.Kafka; +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.EventBus.Consuming; +using LoveNet.Realtime.Api.Services; +using Microsoft.Extensions.Options; + +namespace LoveNet.Realtime.Api.Consumers; + +/// <summary>Registers public rooms for join authorization (SADD is idempotent).</summary> +public class RoomProvisionedConsumer : KafkaConsumerService<RoomProvisionedEvent> +{ + private const string ConsumerName = "realtime-service"; + + public RoomProvisionedConsumer( + IServiceScopeFactory scopeFactory, + IProducer<string, string> producer, + IOptions<KafkaOptions> options, + ILogger<RoomProvisionedConsumer> logger) + : base(scopeFactory, producer, options, logger, KafkaTopics.RoomProvisioned, ConsumerName) + { + } + + protected override async Task HandleAsync(IServiceProvider services, EventEnvelope<RoomProvisionedEvent> envelope, CancellationToken cancellationToken) + { + if (!envelope.Data.IsPublic) + { + return; + } + + var roomRegistry = services.GetRequiredService<IRoomRegistry>(); + await roomRegistry.AddPublicRoomAsync(envelope.Data.RoomId); + } +} diff --git a/src/Services/Realtime/LoveNet.Realtime.Api/Dockerfile b/src/Services/Realtime/LoveNet.Realtime.Api/Dockerfile new file mode 100644 index 0000000..cb016f7 --- /dev/null +++ b/src/Services/Realtime/LoveNet.Realtime.Api/Dockerfile @@ -0,0 +1,17 @@ +# Build context is the repository root. +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY NuGet.config . +COPY src/Directory.Build.props src/ +COPY src/BuildingBlocks/ src/BuildingBlocks/ +COPY src/Services/Realtime/ src/Services/Realtime/ +RUN dotnet publish src/Services/Realtime/LoveNet.Realtime.Api/LoveNet.Realtime.Api.csproj -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +WORKDIR /app +# curl is used by the compose healthcheck. +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +COPY --from=build /app/publish . +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 +ENTRYPOINT ["dotnet", "LoveNet.Realtime.Api.dll"] diff --git a/src/Services/Realtime/LoveNet.Realtime.Api/Hubs/ChatHub.cs b/src/Services/Realtime/LoveNet.Realtime.Api/Hubs/ChatHub.cs new file mode 100644 index 0000000..500d262 --- /dev/null +++ b/src/Services/Realtime/LoveNet.Realtime.Api/Hubs/ChatHub.cs @@ -0,0 +1,142 @@ +using LoveNet.BuildingBlocks.Contracts; +using LoveNet.BuildingBlocks.Contracts.Events; +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.Web.Auth; +using LoveNet.Realtime.Api.Models; +using LoveNet.Realtime.Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace LoveNet.Realtime.Api.Hubs; + +[Authorize] +public class ChatHub : Hub +{ + private const string RoomIdKey = "roomId"; + private const string UserIdKey = "userId"; + + private readonly IPresenceService presence; + private readonly IRoomRegistry roomRegistry; + private readonly IEventPublisher eventPublisher; + + public ChatHub( + IPresenceService presence, + IRoomRegistry roomRegistry, + IEventPublisher eventPublisher) + { + this.presence = presence; + this.roomRegistry = roomRegistry; + this.eventPublisher = eventPublisher; + } + + public async Task JoinRoom(UserConnectionModel connection) + { + var userId = Context.User!.GetUserId(); + var userName = Context.User!.GetUserName(); + var roomId = connection.RoomId; + + await EnsureRoomMemberAsync(roomId, userId); + + await Groups.AddToGroupAsync(Context.ConnectionId, roomId); + await presence.AddUserToRoomAsync(roomId, new UserInRoomModel + { + Id = userId, + UserName = userName, + ProfilePictureUrl = connection.ProfilePictureUrl, + }); + + Context.Items[RoomIdKey] = roomId; + Context.Items[UserIdKey] = userId; + + await BroadcastUsersAsync(roomId, hasLeft: false); + } + + public async Task LeaveRoom(UserConnectionModel connection) + { + var userId = Context.User!.GetUserId(); + await RemoveFromRoomAsync(connection.RoomId, userId); + } + + /// <summary> + /// Cleans up presence when the client never invoked LeaveRoom (tab close, + /// network drop) — the monolith leaked ghost users here. + /// </summary> + public override async Task OnDisconnectedAsync(Exception? exception) + { + if (Context.Items.TryGetValue(RoomIdKey, out var roomId) && roomId is string room && + Context.Items.TryGetValue(UserIdKey, out var userId) && userId is string user) + { + await RemoveFromRoomAsync(room, user); + } + + await base.OnDisconnectedAsync(exception); + } + + public async Task SendMessage(SendMessageModel message) + { + var userId = Context.User!.GetUserId(); + var userName = Context.User!.GetUserName(); + + await EnsureRoomMemberAsync(message.RoomId, userId); + + var evt = new MessageSentEvent( + Guid.NewGuid().ToString(), + message.RoomId, + userId, + userName, + message.ProfilePicture, + message.Text, + message.ImageUrl, + DateTime.UtcNow); + + // Durable-before-display: broker ack first, so a message a client saw + // can never be lost by a crash between broadcast and publish. + await eventPublisher.PublishAsync(KafkaTopics.MessageSent, message.RoomId, evt); + + await Clients.Group(message.RoomId).SendAsync("ReceiveMessage", new + { + RoomId = message.RoomId, + UserId = userId, + Text = message.Text, + ProfilePicture = message.ProfilePicture, + ImageUrl = message.ImageUrl, + CreatedOn = evt.CreatedOn, + }); + } + + /// <summary> + /// A room is joinable when it is a provisioned public room or the caller's + /// id appears in the deterministic 1:1 room id (two sorted user GUIDs + /// concatenated). + /// </summary> + private async Task EnsureRoomMemberAsync(string roomId, string userId) + { + if (await roomRegistry.IsPublicRoomAsync(roomId) || roomId.Contains(userId)) + { + return; + } + + throw new HubException("Not a member of this room"); + } + + private async Task RemoveFromRoomAsync(string roomId, string userId) + { + await presence.RemoveUserFromRoomAsync(roomId, userId); + await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomId); + + Context.Items.Remove(RoomIdKey); + Context.Items.Remove(UserIdKey); + + await BroadcastUsersAsync(roomId, hasLeft: true); + } + + private async Task BroadcastUsersAsync(string roomId, bool hasLeft) + { + var users = await presence.GetUsersInRoomAsync(roomId); + await Clients.Group(roomId).SendAsync("RefreshUsersList", new + { + Users = users, + HasLeft = hasLeft, + }); + } +} diff --git a/src/Services/Realtime/LoveNet.Realtime.Api/LoveNet.Realtime.Api.csproj b/src/Services/Realtime/LoveNet.Realtime.Api/LoveNet.Realtime.Api.csproj new file mode 100644 index 0000000..fbd204a --- /dev/null +++ b/src/Services/Realtime/LoveNet.Realtime.Api/LoveNet.Realtime.Api.csproj @@ -0,0 +1,21 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <ItemGroup> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.Contracts\LoveNet.BuildingBlocks.Contracts.csproj" /> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.EventBus\LoveNet.BuildingBlocks.EventBus.csproj" /> + <ProjectReference Include="..\..\..\BuildingBlocks\LoveNet.BuildingBlocks.Web\LoveNet.BuildingBlocks.Web.csproj" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="8.0.11" /> + <PackageReference Include="StackExchange.Redis" Version="2.8.16" /> + <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" /> + </ItemGroup> + + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <Nullable>enable</Nullable> + <ImplicitUsings>enable</ImplicitUsings> + </PropertyGroup> + +</Project> diff --git a/src/Services/Realtime/LoveNet.Realtime.Api/Models/RealtimeModels.cs b/src/Services/Realtime/LoveNet.Realtime.Api/Models/RealtimeModels.cs new file mode 100644 index 0000000..fba3fc5 --- /dev/null +++ b/src/Services/Realtime/LoveNet.Realtime.Api/Models/RealtimeModels.cs @@ -0,0 +1,33 @@ +namespace LoveNet.Realtime.Api.Models; + +/// <summary> +/// Join/leave payload. The SPA still sends the legacy UserConnection shape +/// with userId/userName; those extra JSON fields are ignored on binding — +/// identity comes from JWT claims. ProfilePictureUrl is cosmetic only. +/// </summary> +public class UserConnectionModel +{ + public string RoomId { get; set; } = string.Empty; + + public string? ProfilePictureUrl { get; set; } +} + +public class SendMessageModel +{ + public string RoomId { get; set; } = string.Empty; + + public string? Text { get; set; } + + public string? ImageUrl { get; set; } + + public string? ProfilePicture { get; set; } +} + +public class UserInRoomModel +{ + public string Id { get; set; } = string.Empty; + + public string? ProfilePictureUrl { get; set; } + + public string? UserName { get; set; } +} diff --git a/src/Services/Realtime/LoveNet.Realtime.Api/Program.cs b/src/Services/Realtime/LoveNet.Realtime.Api/Program.cs new file mode 100644 index 0000000..e732ab6 --- /dev/null +++ b/src/Services/Realtime/LoveNet.Realtime.Api/Program.cs @@ -0,0 +1,38 @@ +using LoveNet.BuildingBlocks.EventBus; +using LoveNet.BuildingBlocks.Web; +using LoveNet.BuildingBlocks.Web.Auth; +using LoveNet.Realtime.Api.Consumers; +using LoveNet.Realtime.Api.Hubs; +using LoveNet.Realtime.Api.Services; +using StackExchange.Redis; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddLoveNetDefaults("realtime-api"); + +builder.Services.AddLoveNetJwt(builder.Configuration); + +builder.Services.AddSingleton<IConnectionMultiplexer>(_ => + ConnectionMultiplexer.Connect(builder.Configuration["Redis:Connection"]!)); + +// Redis backplane so replicas share SignalR groups. +builder.Services.AddSignalR().AddStackExchangeRedis(builder.Configuration["Redis:Connection"]!); + +builder.Services.AddKafkaEventBus(builder.Configuration); +builder.Services.AddHostedService<MatchCreatedConsumer>(); +builder.Services.AddHostedService<RoomProvisionedConsumer>(); + +builder.Services.AddSingleton<IPresenceService, RedisPresenceService>(); +builder.Services.AddSingleton<IRoomRegistry, RedisRoomRegistry>(); + +builder.Services.AddLoveNetHealthChecks(builder.Configuration, kafka: true, redis: true); + +var app = builder.Build(); + +app.UseLoveNetDefaults(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapHub<ChatHub>("/chat"); +app.MapLoveNetHealth(); + +await app.RunAsync(); diff --git a/src/Services/Realtime/LoveNet.Realtime.Api/Properties/launchSettings.json b/src/Services/Realtime/LoveNet.Realtime.Api/Properties/launchSettings.json new file mode 100644 index 0000000..28673c5 --- /dev/null +++ b/src/Services/Realtime/LoveNet.Realtime.Api/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:33080", + "sslPort": 44305 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5070", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7069;http://localhost:5070", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Services/Realtime/LoveNet.Realtime.Api/Services/IPresenceService.cs b/src/Services/Realtime/LoveNet.Realtime.Api/Services/IPresenceService.cs new file mode 100644 index 0000000..b33c46a --- /dev/null +++ b/src/Services/Realtime/LoveNet.Realtime.Api/Services/IPresenceService.cs @@ -0,0 +1,12 @@ +using LoveNet.Realtime.Api.Models; + +namespace LoveNet.Realtime.Api.Services; + +public interface IPresenceService +{ + Task AddUserToRoomAsync(string roomId, UserInRoomModel user); + + Task RemoveUserFromRoomAsync(string roomId, string userId); + + Task<List<UserInRoomModel>> GetUsersInRoomAsync(string roomId); +} diff --git a/src/Services/Realtime/LoveNet.Realtime.Api/Services/IRoomRegistry.cs b/src/Services/Realtime/LoveNet.Realtime.Api/Services/IRoomRegistry.cs new file mode 100644 index 0000000..44d0229 --- /dev/null +++ b/src/Services/Realtime/LoveNet.Realtime.Api/Services/IRoomRegistry.cs @@ -0,0 +1,8 @@ +namespace LoveNet.Realtime.Api.Services; + +public interface IRoomRegistry +{ + Task AddPublicRoomAsync(string roomId); + + Task<bool> IsPublicRoomAsync(string roomId); +} diff --git a/src/Services/Realtime/LoveNet.Realtime.Api/Services/RedisPresenceService.cs b/src/Services/Realtime/LoveNet.Realtime.Api/Services/RedisPresenceService.cs new file mode 100644 index 0000000..02c4791 --- /dev/null +++ b/src/Services/Realtime/LoveNet.Realtime.Api/Services/RedisPresenceService.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using LoveNet.Realtime.Api.Models; +using StackExchange.Redis; + +namespace LoveNet.Realtime.Api.Services; + +/// <summary> +/// Redis-backed room presence shared across replicas (replaces the monolith's +/// in-memory singleton). Hash per room keyed by user id; the 24h TTL sweeps +/// rooms whose members never disconnected cleanly. +/// </summary> +public class RedisPresenceService : IPresenceService +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private readonly IConnectionMultiplexer redis; + + public RedisPresenceService(IConnectionMultiplexer redis) + { + this.redis = redis; + } + + public async Task AddUserToRoomAsync(string roomId, UserInRoomModel user) + { + var db = redis.GetDatabase(); + var key = RoomKey(roomId); + + await db.HashSetAsync(key, user.Id, JsonSerializer.Serialize(user, JsonOptions)); + await db.KeyExpireAsync(key, TimeSpan.FromHours(24)); + } + + public async Task RemoveUserFromRoomAsync(string roomId, string userId) + { + var db = redis.GetDatabase(); + await db.HashDeleteAsync(RoomKey(roomId), userId); + } + + public async Task<List<UserInRoomModel>> GetUsersInRoomAsync(string roomId) + { + var db = redis.GetDatabase(); + var entries = await db.HashGetAllAsync(RoomKey(roomId)); + + return entries + .Select(entry => JsonSerializer.Deserialize<UserInRoomModel>(entry.Value.ToString(), JsonOptions)!) + .ToList(); + } + + private static string RoomKey(string roomId) => $"presence:room:{roomId}"; +} diff --git a/src/Services/Realtime/LoveNet.Realtime.Api/Services/RedisRoomRegistry.cs b/src/Services/Realtime/LoveNet.Realtime.Api/Services/RedisRoomRegistry.cs new file mode 100644 index 0000000..be3d7eb --- /dev/null +++ b/src/Services/Realtime/LoveNet.Realtime.Api/Services/RedisRoomRegistry.cs @@ -0,0 +1,22 @@ +using StackExchange.Redis; + +namespace LoveNet.Realtime.Api.Services; + +/// <summary>Public room ids in a Redis set, populated from RoomProvisioned events.</summary> +public class RedisRoomRegistry : IRoomRegistry +{ + private const string PublicRoomsKey = "rooms:public"; + + private readonly IConnectionMultiplexer redis; + + public RedisRoomRegistry(IConnectionMultiplexer redis) + { + this.redis = redis; + } + + public Task AddPublicRoomAsync(string roomId) => + redis.GetDatabase().SetAddAsync(PublicRoomsKey, roomId); + + public Task<bool> IsPublicRoomAsync(string roomId) => + redis.GetDatabase().SetContainsAsync(PublicRoomsKey, roomId); +} diff --git a/src/Services/Realtime/LoveNet.Realtime.Api/appsettings.Development.json b/src/Services/Realtime/LoveNet.Realtime.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/Services/Realtime/LoveNet.Realtime.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Services/Realtime/LoveNet.Realtime.Api/appsettings.json b/src/Services/Realtime/LoveNet.Realtime.Api/appsettings.json new file mode 100644 index 0000000..00b2b32 --- /dev/null +++ b/src/Services/Realtime/LoveNet.Realtime.Api/appsettings.json @@ -0,0 +1,29 @@ +{ + "Kafka": { + "BootstrapServers": "localhost:29092" + }, + "Redis": { + "Connection": "localhost:6380" + }, + "Jwt": { + "Key": "lovenet-local-development-signing-key-do-not-use-in-production-0123456789abcdef", + "Issuer": "https://localhost:3000/", + "Audience": "https://localhost:3000/" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/stylecop.json b/stylecop.json deleted file mode 100644 index 2d6c351..0000000 --- a/stylecop.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", - "settings": { - "layoutRules": { - "newlineAtEndOfFile": "require" - }, - "namingRules": { - "allowCommonHungarianPrefixes": true, - "allowedHungarianPrefixes": [ "db", "at", "or", "up", "it", "un", "x", "y", "id", "ip", "bg" ] - }, - "documentationRules": { - "companyName": "LOVE.NET", - "copyrightText": "Copyright (c) {companyName}. All Rights Reserved.", - "documentInterfaces": false, - "documentInternalElements": false - }, - "orderingRules": { - "usingDirectivesPlacement": "insideNamespace", - "systemUsingDirectivesFirst": true, - "blankLinesBetweenUsingGroups": "require" - } - } -}