From 1de4e24f22d35408d08a27e52e0b4ddb09e65093 Mon Sep 17 00:00:00 2001 From: cuqmbr Date: Sun, 27 Nov 2022 16:08:33 +0200 Subject: [PATCH] feat: add "Popular stations" statistic --- Server/Controllers/StatisticsController.cs | 13 +- Server/Data/ApplicationDbContext.cs | 1 + ...0221124184433_Add_TicketGroups.Designer.cs | 860 ++++++++++++++++++ .../20221124184433_Add_TicketGroups.cs | 129 +++ .../ApplicationDbContextModelSnapshot.cs | 56 +- Server/Models/Ticket.cs | 6 +- Server/Models/TicketGroup.cs | 14 + Server/Models/User.cs | 2 +- Server/Program.cs | 1 + Server/Services/IStatisticsService.cs | 11 +- Server/Services/StatisticsService.cs | 175 +++- Server/Services/TicketManagementService.cs | 15 +- .../QueryParameters/PagingMetadata.cs | 2 +- .../Statistics/EngagedUserParameters.cs | 2 +- .../Statistics/PopularAddressesParameters.cs | 14 + 15 files changed, 1243 insertions(+), 58 deletions(-) create mode 100644 Server/Migrations/20221124184433_Add_TicketGroups.Designer.cs create mode 100644 Server/Migrations/20221124184433_Add_TicketGroups.cs create mode 100644 Server/Models/TicketGroup.cs create mode 100644 SharedModels/QueryParameters/Statistics/PopularAddressesParameters.cs diff --git a/Server/Controllers/StatisticsController.cs b/Server/Controllers/StatisticsController.cs index be9ebff..3ab6598 100644 --- a/Server/Controllers/StatisticsController.cs +++ b/Server/Controllers/StatisticsController.cs @@ -54,9 +54,18 @@ public class StatisticsController : ControllerBase } [HttpGet("stations")] - public async Task GetPopularStations([FromQuery] int amount = 10) + public async Task GetPopularStations([FromQuery] PopularAddressesParameters parameters) { - return Ok(); + var result = await _statisticsService.GetPopularStations(parameters); + + if (!result.IsSucceed) + { + return BadRequest(result.message); + } + + Response.Headers.Add("X-Pagination", JsonConvert.SerializeObject(result.pagingMetadata)); + + return Ok(result.stations); } } diff --git a/Server/Data/ApplicationDbContext.cs b/Server/Data/ApplicationDbContext.cs index 62ea5f8..2dfc8f0 100644 --- a/Server/Data/ApplicationDbContext.cs +++ b/Server/Data/ApplicationDbContext.cs @@ -21,6 +21,7 @@ public class ApplicationDbContext : IdentityDbContext public DbSet Cities { get; set; } = null!; public DbSet States { get; set; } = null!; public DbSet Countries { get; set; } = null!; + public DbSet TicketGroups { get; set; } = null!; public DbSet Tickets { get; set; } = null!; public DbSet Reviews { get; set; } = null!; } \ No newline at end of file diff --git a/Server/Migrations/20221124184433_Add_TicketGroups.Designer.cs b/Server/Migrations/20221124184433_Add_TicketGroups.Designer.cs new file mode 100644 index 0000000..f7dc7fa --- /dev/null +++ b/Server/Migrations/20221124184433_Add_TicketGroups.Designer.cs @@ -0,0 +1,860 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Server.Data; + +#nullable disable + +namespace Server.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20221124184433_Add_TicketGroups")] + partial class Add_TicketGroups + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Server.Models.Address", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("integer"); + + b.Property("Latitude") + .HasColumnType("double precision"); + + b.Property("Longitude") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("Server.Models.City", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("StateId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("StateId"); + + b.ToTable("Cities"); + }); + + modelBuilder.Entity("Server.Models.Company", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OwnerId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Companies"); + }); + + modelBuilder.Entity("Server.Models.Country", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Countries"); + }); + + modelBuilder.Entity("Server.Models.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Comment") + .HasColumnType("text"); + + b.Property("PostDateTimeUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("VehicleEnrollmentId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("VehicleEnrollmentId"); + + b.ToTable("Reviews"); + }); + + modelBuilder.Entity("Server.Models.Route", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Routes"); + }); + + modelBuilder.Entity("Server.Models.RouteAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AddressId") + .HasColumnType("integer"); + + b.Property("CostToNextCity") + .HasColumnType("double precision"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("RouteId") + .HasColumnType("integer"); + + b.Property("TimeSpanToNextCity") + .HasColumnType("interval"); + + b.Property("WaitTimeSpan") + .HasColumnType("interval"); + + b.HasKey("Id"); + + b.HasIndex("AddressId"); + + b.HasIndex("RouteId"); + + b.ToTable("RouteAddresses"); + }); + + modelBuilder.Entity("Server.Models.State", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CountryId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CountryId"); + + b.ToTable("States"); + }); + + modelBuilder.Entity("Server.Models.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FirstRouteAddressId") + .HasColumnType("integer"); + + b.Property("IsMissed") + .HasColumnType("boolean"); + + b.Property("IsReturned") + .HasColumnType("boolean"); + + b.Property("LastRouteAddressId") + .HasColumnType("integer"); + + b.Property("PurchaseDateTimeUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TicketGroupId") + .HasColumnType("integer"); + + b.Property("VehicleEnrollmentId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TicketGroupId"); + + b.HasIndex("VehicleEnrollmentId"); + + b.ToTable("Tickets"); + }); + + modelBuilder.Entity("Server.Models.TicketGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("TicketGroups"); + }); + + modelBuilder.Entity("Server.Models.User", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FirstName") + .HasColumnType("text"); + + b.Property("LastName") + .HasColumnType("text"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("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("Server.Models.Vehicle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Capacity") + .HasColumnType("integer"); + + b.Property("CompanyId") + .HasColumnType("integer"); + + b.Property("HasBelts") + .HasColumnType("boolean"); + + b.Property("HasClimateControl") + .HasColumnType("boolean"); + + b.Property("HasOutlet") + .HasColumnType("boolean"); + + b.Property("HasStewardess") + .HasColumnType("boolean"); + + b.Property("HasTV") + .HasColumnType("boolean"); + + b.Property("HasWC") + .HasColumnType("boolean"); + + b.Property("HasWiFi") + .HasColumnType("boolean"); + + b.Property("Number") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("Vehicles"); + }); + + modelBuilder.Entity("Server.Models.VehicleEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CancelationComment") + .HasColumnType("text"); + + b.Property("DelayTimeSpan") + .HasColumnType("interval"); + + b.Property("DepartureDateTimeUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCanceled") + .HasColumnType("boolean"); + + b.Property("RouteId") + .HasColumnType("integer"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RouteId"); + + b.HasIndex("VehicleId"); + + b.ToTable("VehicleEnrollments"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Server.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Server.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Server.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Server.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Server.Models.Address", b => + { + b.HasOne("Server.Models.City", "City") + .WithMany("Addresses") + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("City"); + }); + + modelBuilder.Entity("Server.Models.City", b => + { + b.HasOne("Server.Models.State", "State") + .WithMany("Cities") + .HasForeignKey("StateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("State"); + }); + + modelBuilder.Entity("Server.Models.Company", b => + { + b.HasOne("Server.Models.User", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Server.Models.Review", b => + { + b.HasOne("Server.Models.User", "User") + .WithMany("Reviews") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Server.Models.VehicleEnrollment", "VehicleEnrollment") + .WithMany("Reviews") + .HasForeignKey("VehicleEnrollmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("VehicleEnrollment"); + }); + + modelBuilder.Entity("Server.Models.RouteAddress", b => + { + b.HasOne("Server.Models.Address", "Address") + .WithMany("RouteAddresses") + .HasForeignKey("AddressId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Server.Models.Route", "Route") + .WithMany("RouteAddresses") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Address"); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("Server.Models.State", b => + { + b.HasOne("Server.Models.Country", "Country") + .WithMany("States") + .HasForeignKey("CountryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Country"); + }); + + modelBuilder.Entity("Server.Models.Ticket", b => + { + b.HasOne("Server.Models.TicketGroup", "TicketGroup") + .WithMany("Tickets") + .HasForeignKey("TicketGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Server.Models.VehicleEnrollment", "VehicleEnrollment") + .WithMany("Tickets") + .HasForeignKey("VehicleEnrollmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TicketGroup"); + + b.Navigation("VehicleEnrollment"); + }); + + modelBuilder.Entity("Server.Models.TicketGroup", b => + { + b.HasOne("Server.Models.User", "User") + .WithMany("TicketGroups") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Server.Models.User", b => + { + b.OwnsMany("Server.Models.RefreshToken", "RefreshTokens", b1 => + { + b1.Property("UserId") + .HasColumnType("text"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("CreationDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("ExpiryDateTime") + .HasColumnType("timestamp with time zone"); + + b1.Property("Revoked") + .HasColumnType("timestamp with time zone"); + + b1.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("UserId", "Id"); + + b1.ToTable("RefreshToken"); + + b1.WithOwner() + .HasForeignKey("UserId"); + }); + + b.Navigation("RefreshTokens"); + }); + + modelBuilder.Entity("Server.Models.Vehicle", b => + { + b.HasOne("Server.Models.Company", "Company") + .WithMany("Vehicles") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Server.Models.VehicleEnrollment", b => + { + b.HasOne("Server.Models.Route", "Route") + .WithMany("VehicleEnrollments") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Server.Models.Vehicle", "Vehicle") + .WithMany("VehicleEnrollments") + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("Server.Models.Address", b => + { + b.Navigation("RouteAddresses"); + }); + + modelBuilder.Entity("Server.Models.City", b => + { + b.Navigation("Addresses"); + }); + + modelBuilder.Entity("Server.Models.Company", b => + { + b.Navigation("Vehicles"); + }); + + modelBuilder.Entity("Server.Models.Country", b => + { + b.Navigation("States"); + }); + + modelBuilder.Entity("Server.Models.Route", b => + { + b.Navigation("RouteAddresses"); + + b.Navigation("VehicleEnrollments"); + }); + + modelBuilder.Entity("Server.Models.State", b => + { + b.Navigation("Cities"); + }); + + modelBuilder.Entity("Server.Models.TicketGroup", b => + { + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("Server.Models.User", b => + { + b.Navigation("Reviews"); + + b.Navigation("TicketGroups"); + }); + + modelBuilder.Entity("Server.Models.Vehicle", b => + { + b.Navigation("VehicleEnrollments"); + }); + + modelBuilder.Entity("Server.Models.VehicleEnrollment", b => + { + b.Navigation("Reviews"); + + b.Navigation("Tickets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Server/Migrations/20221124184433_Add_TicketGroups.cs b/Server/Migrations/20221124184433_Add_TicketGroups.cs new file mode 100644 index 0000000..d0dbe24 --- /dev/null +++ b/Server/Migrations/20221124184433_Add_TicketGroups.cs @@ -0,0 +1,129 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Server.Migrations +{ + public partial class Add_TicketGroups : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Tickets_AspNetUsers_UserId", + table: "Tickets"); + + migrationBuilder.DropIndex( + name: "IX_Tickets_UserId", + table: "Tickets"); + + migrationBuilder.DropColumn( + name: "UserId", + table: "Tickets"); + + migrationBuilder.AddColumn( + name: "FirstRouteAddressId", + table: "Tickets", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "LastRouteAddressId", + table: "Tickets", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "TicketGroupId", + table: "Tickets", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "TicketGroups", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TicketGroups", x => x.Id); + table.ForeignKey( + name: "FK_TicketGroups_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Tickets_TicketGroupId", + table: "Tickets", + column: "TicketGroupId"); + + migrationBuilder.CreateIndex( + name: "IX_TicketGroups_UserId", + table: "TicketGroups", + column: "UserId"); + + migrationBuilder.AddForeignKey( + name: "FK_Tickets_TicketGroups_TicketGroupId", + table: "Tickets", + column: "TicketGroupId", + principalTable: "TicketGroups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Tickets_TicketGroups_TicketGroupId", + table: "Tickets"); + + migrationBuilder.DropTable( + name: "TicketGroups"); + + migrationBuilder.DropIndex( + name: "IX_Tickets_TicketGroupId", + table: "Tickets"); + + migrationBuilder.DropColumn( + name: "FirstRouteAddressId", + table: "Tickets"); + + migrationBuilder.DropColumn( + name: "LastRouteAddressId", + table: "Tickets"); + + migrationBuilder.DropColumn( + name: "TicketGroupId", + table: "Tickets"); + + migrationBuilder.AddColumn( + name: "UserId", + table: "Tickets", + type: "text", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateIndex( + name: "IX_Tickets_UserId", + table: "Tickets", + column: "UserId"); + + migrationBuilder.AddForeignKey( + name: "FK_Tickets_AspNetUsers_UserId", + table: "Tickets", + column: "UserId", + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/Server/Migrations/ApplicationDbContextModelSnapshot.cs b/Server/Migrations/ApplicationDbContextModelSnapshot.cs index 857f1b9..8d956f8 100644 --- a/Server/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Server/Migrations/ApplicationDbContextModelSnapshot.cs @@ -363,31 +363,55 @@ namespace Server.Migrations NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("FirstRouteAddressId") + .HasColumnType("integer"); + b.Property("IsMissed") .HasColumnType("boolean"); b.Property("IsReturned") .HasColumnType("boolean"); + b.Property("LastRouteAddressId") + .HasColumnType("integer"); + b.Property("PurchaseDateTimeUtc") .HasColumnType("timestamp with time zone"); - b.Property("UserId") - .IsRequired() - .HasColumnType("text"); + b.Property("TicketGroupId") + .HasColumnType("integer"); b.Property("VehicleEnrollmentId") .HasColumnType("integer"); b.HasKey("Id"); - b.HasIndex("UserId"); + b.HasIndex("TicketGroupId"); b.HasIndex("VehicleEnrollmentId"); b.ToTable("Tickets"); }); + modelBuilder.Entity("Server.Models.TicketGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("TicketGroups"); + }); + modelBuilder.Entity("Server.Models.User", b => { b.Property("Id") @@ -678,9 +702,9 @@ namespace Server.Migrations modelBuilder.Entity("Server.Models.Ticket", b => { - b.HasOne("Server.Models.User", "User") + b.HasOne("Server.Models.TicketGroup", "TicketGroup") .WithMany("Tickets") - .HasForeignKey("UserId") + .HasForeignKey("TicketGroupId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -690,11 +714,22 @@ namespace Server.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.Navigation("User"); + b.Navigation("TicketGroup"); b.Navigation("VehicleEnrollment"); }); + modelBuilder.Entity("Server.Models.TicketGroup", b => + { + b.HasOne("Server.Models.User", "User") + .WithMany("TicketGroups") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Server.Models.User", b => { b.OwnsMany("Server.Models.RefreshToken", "RefreshTokens", b1 => @@ -794,11 +829,16 @@ namespace Server.Migrations b.Navigation("Cities"); }); + modelBuilder.Entity("Server.Models.TicketGroup", b => + { + b.Navigation("Tickets"); + }); + modelBuilder.Entity("Server.Models.User", b => { b.Navigation("Reviews"); - b.Navigation("Tickets"); + b.Navigation("TicketGroups"); }); modelBuilder.Entity("Server.Models.Vehicle", b => diff --git a/Server/Models/Ticket.cs b/Server/Models/Ticket.cs index 6c9099d..ec6d57b 100644 --- a/Server/Models/Ticket.cs +++ b/Server/Models/Ticket.cs @@ -9,14 +9,16 @@ public class Ticket public int Id { get; set; } [ForeignKey("UserId")] - public string UserId { get; set; } = null!; - public User User { get; set; } = null!; + public int TicketGroupId { get; set; } + public TicketGroup TicketGroup { get; set; } = null!; [ForeignKey("VehicleEnrollmentId")] public int VehicleEnrollmentId { get; set; } public VehicleEnrollment VehicleEnrollment { get; set; } = null!; public DateTime PurchaseDateTimeUtc { get; set; } = DateTime.UtcNow; + public int FirstRouteAddressId { get; set; } + public int LastRouteAddressId { get; set; } public bool IsReturned { get; set; } = false; public bool IsMissed { get; set; } = false; } \ No newline at end of file diff --git a/Server/Models/TicketGroup.cs b/Server/Models/TicketGroup.cs new file mode 100644 index 0000000..38e53bb --- /dev/null +++ b/Server/Models/TicketGroup.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Server.Models; + +public class TicketGroup +{ + [Key] + public int Id { get; set; } + + public string UserId { get; set; } = null!; + public User User { get; set; } = null!; + + public virtual IList Tickets { get; set; } +} \ No newline at end of file diff --git a/Server/Models/User.cs b/Server/Models/User.cs index 384e610..63d439f 100644 --- a/Server/Models/User.cs +++ b/Server/Models/User.cs @@ -7,6 +7,6 @@ public class User : IdentityUser public string? FirstName { get; set; } public string? LastName { get; set; } public IList RefreshTokens { get; set; } = null!; - public virtual IList Tickets { get; set; } = null!; + public virtual IList TicketGroups { get; set; } = null!; public virtual IList Reviews { get; set; } = null!; } \ No newline at end of file diff --git a/Server/Program.cs b/Server/Program.cs index c2217bf..7ed9313 100644 --- a/Server/Program.cs +++ b/Server/Program.cs @@ -134,6 +134,7 @@ builder.Services.AddScoped, DataShaper>( builder.Services.AddScoped, DataShaper>(); builder.Services.AddScoped, DataShaper>(); +builder.Services.AddScoped, DataShaper>(); // Adding DB Context with PostgreSQL var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); diff --git a/Server/Services/IStatisticsService.cs b/Server/Services/IStatisticsService.cs index 6e1b7fa..da456d5 100644 --- a/Server/Services/IStatisticsService.cs +++ b/Server/Services/IStatisticsService.cs @@ -10,12 +10,15 @@ public interface IStatisticsService Task<(bool IsSucceed, string? message, IEnumerable route)> GetPopularRoutes(int amount); - Task<(bool IsSucceed, string? message, IEnumerable users, PagingMetadata pagingMetadata)> + Task<(bool IsSucceed, string? message, IEnumerable users, + PagingMetadata pagingMetadata)> GetEngagedUsers(EngagedUserParameters parameters); - Task<(bool IsSucceed, string? message, IEnumerable companies, PagingMetadata pagingMetadata)> + Task<(bool IsSucceed, string? message, IEnumerable companies, + PagingMetadata pagingMetadata)> GetPopularCompanies(PopularCompanyParameters parameters); - Task<(bool IsSucceed, string? message, IEnumerable stations)> - GetPopularStations(int amount); + Task<(bool IsSucceed, string? message, IEnumerable stations, + PagingMetadata pagingMetadata)> + GetPopularStations(PopularAddressesParameters parameters); } \ No newline at end of file diff --git a/Server/Services/StatisticsService.cs b/Server/Services/StatisticsService.cs index 2810472..3857560 100644 --- a/Server/Services/StatisticsService.cs +++ b/Server/Services/StatisticsService.cs @@ -16,14 +16,17 @@ public class StatisticsService : IStatisticsService private readonly IMapper _mapper; private readonly IDataShaper _userDataShaper; private readonly IDataShaper _companyDataShaper; + private readonly IDataShaper _addressDataShaper; public StatisticsService(ApplicationDbContext dbContext, IMapper mapper, - IDataShaper userDataShaper, IDataShaper companyDataShaper) + IDataShaper userDataShaper, IDataShaper companyDataShaper, + IDataShaper addressDataShaper) { _dbContext = dbContext; _mapper = mapper; _userDataShaper = userDataShaper; _companyDataShaper = companyDataShaper; + _addressDataShaper = addressDataShaper; } // Popularity is measured in number of purchased tickets @@ -33,48 +36,56 @@ public class StatisticsService : IStatisticsService throw new NotImplementedException(); } - // Engagement is measured in number of tickets bought in last 60 days - public async Task<(bool IsSucceed, string? message, IEnumerable users, PagingMetadata pagingMetadata)> + // Engagement is measured in number of purchases made in past N days + // One purchase contains one (direct route) or more (route with transfers) tickets + public async Task<(bool IsSucceed, string? message, IEnumerable users, + PagingMetadata pagingMetadata)> GetEngagedUsers(EngagedUserParameters parameters) { - var fromDateUtc = DateTime.UtcNow - TimeSpan.FromDays(parameters.Days ?? parameters.DefaultDays); - + var fromDateUtc = + DateTime.UtcNow - TimeSpan.FromDays(parameters.Days ?? parameters.DefaultDays); + var resultObjects = _dbContext.Users - .Include(u => u.Tickets) + .Include(u => u.TicketGroups) + .ThenInclude(tg => tg.Tickets) .Select(u => new { User = u, - Tickets = u.Tickets.Where(t => t.PurchaseDateTimeUtc >= fromDateUtc) + TicketGroups = u.TicketGroups.Where(tg => + tg.Tickets.First().PurchaseDateTimeUtc >= fromDateUtc) }) - .OrderByDescending(o => o.User.Tickets.Count) + .OrderByDescending(o => o.TicketGroups.Count()) .Take(parameters.Amount); var dbUsers = resultObjects.Select(i => i.User); - var pagingMetadata = ApplyPaging(ref dbUsers, parameters.PageNumber, - parameters.PageSize); - var userDtos = _mapper.ProjectTo(dbUsers).ToArray(); - var shapedData = _userDataShaper + var shapedDataArray = _userDataShaper .ShapeData(userDtos, parameters.Fields ?? parameters.DefaultFields) .ToArray(); - + if (parameters.Fields != null && - parameters.Fields.ToLower().Contains("ticketCount".ToLower())) + parameters.Fields.ToLower().Contains("purchaseCount".ToLower())) { var dbUsersArray = await dbUsers.ToArrayAsync(); for (int i = 0; i < dbUsersArray.Length; i++) { - var ticketCount = dbUsersArray[i].Tickets.Count; - shapedData[i].TryAdd("TicketCount", ticketCount); + var ticketCount = dbUsersArray[i].TicketGroups.Count; + shapedDataArray[i].TryAdd("PurchaseCount", ticketCount); } } - return (true, null, shapedData, pagingMetadata); + var shapedData = shapedDataArray.AsQueryable(); + var pagingMetadata = ApplyPaging(ref shapedData, parameters.PageNumber, + parameters.PageSize); + shapedDataArray = shapedData.ToArray(); + + return (true, null, shapedDataArray, pagingMetadata); } // Popularity is measured in average rating of all VehicleEnrollments of a company - public async Task<(bool IsSucceed, string? message, IEnumerable companies, PagingMetadata pagingMetadata)> + public async Task<(bool IsSucceed, string? message, IEnumerable companies, + PagingMetadata pagingMetadata)> GetPopularCompanies(PopularCompanyParameters parameters) { var dbCompanies = _dbContext.Companies @@ -132,33 +143,133 @@ public class StatisticsService : IStatisticsService } } - companiesAvgRatings = companiesAvgRatings.Skip(companiesAvgRatings.Length - parameters.Amount).Reverse().ToArray(); - var popularCompanies = dbCompaniesArray.Skip(companiesAvgRatings.Length - parameters.Amount).Reverse().AsQueryable(); + companiesAvgRatings = companiesAvgRatings + .Skip(companiesAvgRatings.Length - parameters.Amount).Reverse() + .ToArray(); + var popularCompanies = dbCompaniesArray + .Skip(companiesAvgRatings.Length - parameters.Amount).Reverse() + .AsQueryable(); - // Apply paging, convert to DTOs and shape data - - var pagingMetadata = ApplyPaging(ref popularCompanies, parameters.PageNumber, parameters.PageSize); - var companyDtos = _mapper.ProjectTo(popularCompanies).ToArray(); + // Convert to DTOs, shape data and apply paging - var shapedData = _companyDataShaper.ShapeData(companyDtos, parameters.Fields ?? parameters.DefaultFields).ToArray(); + var companyDtos = _mapper.ProjectTo(popularCompanies); + var shapedDataArray = _companyDataShaper.ShapeData(companyDtos, + parameters.Fields ?? parameters.DefaultFields).ToArray(); if (parameters.Fields != null && parameters.Fields.ToLower().Contains("rating".ToLower())) { - for (int i = 0; i < shapedData.Length; i++) + for (int i = 0; i < shapedDataArray.Length; i++) { - shapedData[i].TryAdd("Rating", companiesAvgRatings[i]); + shapedDataArray[i].TryAdd("Rating", companiesAvgRatings[i]); } } - return (true, null, shapedData, pagingMetadata); + var shapedData = shapedDataArray.AsQueryable(); + var pagingMetadata = ApplyPaging(ref shapedData, parameters.PageNumber, + parameters.PageSize); + shapedDataArray = shapedData.ToArray(); + + return (true, null, shapedDataArray, pagingMetadata); } - // Popularity is measured in number of routes using the station - public async Task<(bool IsSucceed, string? message, IEnumerable stations)> - GetPopularStations(int amount) + // Popularity is measured in number tickets in which the address is the first or last station + public async Task<(bool IsSucceed, string? message, IEnumerable stations, + PagingMetadata pagingMetadata)> + GetPopularStations(PopularAddressesParameters parameters) { - throw new NotImplementedException(); + // throw new NotImplementedException(); + + var fromDateUtc = + DateTime.UtcNow - TimeSpan.FromDays(parameters.Days ?? parameters.DefaultDays); + + var dbTicketGroupsArray = await _dbContext.TicketGroups + .Include(tg => tg.Tickets) + .Where(tg => tg.Tickets.First().PurchaseDateTimeUtc >= fromDateUtc) + .ToArrayAsync(); + + // Count appearances for each address id + var addressCountDict = new Dictionary(); + + foreach (var tg in dbTicketGroupsArray) + { + if (!addressCountDict.ContainsKey(tg.Tickets.First().FirstRouteAddressId)) + { + addressCountDict.Add(tg.Tickets.First().FirstRouteAddressId, 1); + } + else + { + addressCountDict[tg.Tickets.First().FirstRouteAddressId] += 1; + } + + if (!addressCountDict.ContainsKey(tg.Tickets.Last().LastRouteAddressId)) + { + addressCountDict.Add(tg.Tickets.Last().LastRouteAddressId, 1); + } + else + { + addressCountDict[tg.Tickets.Last().LastRouteAddressId] += 1; + } + } + + // Sort by number of appearances in descending order -> + // Take amount given in parameters -> + // Order by Id in Ascending order (needed for further sorting of two arrays simultaneously) + addressCountDict = addressCountDict.OrderByDescending(a => a.Value) + .Take(parameters.Amount).OrderBy(a => a.Key) + .ToDictionary(x => x.Key, x => x.Value); + + // Separate Ids and counts into two arrays + var addressIds = addressCountDict.Keys.ToArray(); + var addressCountArray = addressCountDict.Values.ToArray(); + + // Get top addresses from database ordered by Id (same as + // addressIds addressCountDict and ) + var dbAddressesArray = await _dbContext.Addresses + .Where(a => addressIds.Any(id => a.Id == id)) + .OrderBy(a => a.Id).ToArrayAsync(); + + // Sort addressCountArray and simultaneously sort dbAddressesArray + // in the same manner + int n = addressCountArray.Length; + for (int i = 0; i < n - 1; i++) + { + for (int j = 0; j < n - i - 1; j++) + { + if (addressCountArray[j] > addressCountArray[j + 1]) + { + // swap temp and arr[i] + (addressCountArray[j], addressCountArray[j + 1]) = (addressCountArray[j + 1], addressCountArray[j]); + (dbAddressesArray[j], dbAddressesArray[j + 1]) = (dbAddressesArray[j + 1], dbAddressesArray[j]); + } + } + } + + // Reverse sorted arrays (the result will be two "linked" arrays sorterd + // in descending order by addressCount) + addressCountArray = addressCountArray.Reverse().ToArray(); + dbAddressesArray = dbAddressesArray.Reverse().ToArray(); + + var addressDtos = + _mapper.ProjectTo(dbAddressesArray.AsQueryable()); + var shapedDataArray = _addressDataShaper.ShapeData(addressDtos, + parameters.Fields ?? parameters.DefaultFields).ToArray(); + + if (parameters.Fields != null && + parameters.Fields.ToLower().Contains("purchaseCount".ToLower())) + { + for (int i = 0; i < shapedDataArray.Length; i++) + { + shapedDataArray[i].TryAdd("purchaseCount", addressCountArray[i]); + } + } + + var shapedData = shapedDataArray.AsQueryable(); + var pagingMetadata = ApplyPaging(ref shapedData, parameters.PageNumber, + parameters.PageSize); + shapedDataArray = shapedData.ToArray(); + + return (true, null, shapedDataArray, pagingMetadata); } PagingMetadata ApplyPaging(ref IQueryable obj, diff --git a/Server/Services/TicketManagementService.cs b/Server/Services/TicketManagementService.cs index 60c8c9e..eb6ee7c 100644 --- a/Server/Services/TicketManagementService.cs +++ b/Server/Services/TicketManagementService.cs @@ -92,16 +92,17 @@ public class TicketManagementService : ITicketManagementService tickets = tickets.Where(t => t.IsReturned == isReturned); } + // TODO: change TicketParameters void FilterByTicketUserId(ref IQueryable tickets, string? userId) { - if (!tickets.Any() || String.IsNullOrWhiteSpace(userId)) - { - return; - } - - tickets = tickets.Where(t => - t.UserId.ToLower().Contains(userId.ToLower())); + // if (!tickets.Any() || String.IsNullOrWhiteSpace(userId)) + // { + // return; + // } + // + // tickets = tickets.Where(t => + // t.UserId.ToLower().Contains(userId.ToLower())); } PagingMetadata ApplyPaging(ref IQueryable tickets, diff --git a/SharedModels/QueryParameters/PagingMetadata.cs b/SharedModels/QueryParameters/PagingMetadata.cs index 137202b..40329f9 100644 --- a/SharedModels/QueryParameters/PagingMetadata.cs +++ b/SharedModels/QueryParameters/PagingMetadata.cs @@ -10,7 +10,7 @@ public class PagingMetadata public bool HasPrevious => CurrentPage > 1; public bool HasNext => CurrentPage < TotalPages; - public PagingMetadata(IQueryable source, int pageNumber, int pageSize) + public PagingMetadata(IEnumerable source, int pageNumber, int pageSize) { TotalCount = source.Count(); PageSize = pageSize; diff --git a/SharedModels/QueryParameters/Statistics/EngagedUserParameters.cs b/SharedModels/QueryParameters/Statistics/EngagedUserParameters.cs index 55ae1bd..f460d7f 100644 --- a/SharedModels/QueryParameters/Statistics/EngagedUserParameters.cs +++ b/SharedModels/QueryParameters/Statistics/EngagedUserParameters.cs @@ -2,7 +2,7 @@ namespace SharedModels.QueryParameters.Statistics; public class EngagedUserParameters : ParametersBase { - public readonly string DefaultFields = "id,firstName,lastName,username,email,phoneNumber,ticketCount"; + public readonly string DefaultFields = "id,firstName,lastName,username,email,phoneNumber,purchaseCount"; public readonly int DefaultDays = 60; public EngagedUserParameters() diff --git a/SharedModels/QueryParameters/Statistics/PopularAddressesParameters.cs b/SharedModels/QueryParameters/Statistics/PopularAddressesParameters.cs new file mode 100644 index 0000000..7fb684d --- /dev/null +++ b/SharedModels/QueryParameters/Statistics/PopularAddressesParameters.cs @@ -0,0 +1,14 @@ +namespace SharedModels.QueryParameters.Statistics; + +public class PopularAddressesParameters : ParametersBase +{ + public readonly string DefaultFields = "id,name,purchaseCount"; + public readonly int DefaultDays = 60; + + public PopularAddressesParameters() + { + Fields = DefaultFields; + } + + public int? Days { get; set; } +} \ No newline at end of file