From ea4669e3051fbe9ac0362097ba40c722d1985f66 Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 08:13:06 +0200 Subject: [PATCH 01/28] updated appsettings with my credentials --- .../api-cinema-challenge/api-cinema-challenge.csproj | 8 ++++++++ .../api-cinema-challenge/appsettings.example.json | 12 ------------ 2 files changed, 8 insertions(+), 12 deletions(-) delete mode 100644 api-cinema-challenge/api-cinema-challenge/appsettings.example.json diff --git a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj index 11e5c66b..ac5bf732 100644 --- a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj +++ b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj @@ -14,6 +14,10 @@ + + + + @@ -31,4 +35,8 @@ + + + + diff --git a/api-cinema-challenge/api-cinema-challenge/appsettings.example.json b/api-cinema-challenge/api-cinema-challenge/appsettings.example.json deleted file mode 100644 index b9175fe6..00000000 --- a/api-cinema-challenge/api-cinema-challenge/appsettings.example.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "ConnectionStrings": { - "DefaultConnectionString": "Host=HOST; Database=DATABASE; Username=USERNAME; Password=PASSWORD;" - } -} \ No newline at end of file From a7437be594b289f7adea5dc68ff5bf612eb6685d Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 08:15:53 +0200 Subject: [PATCH 02/28] updated gitignore to ignore migrations --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index cf332414..48f4c7b6 100644 --- a/.gitignore +++ b/.gitignore @@ -362,9 +362,11 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd +# prevents build directories and sensitive config files (like your database credentials) from being uploaded. */**/appsettings.json */**/appsettings.Development.json */**/bin/Debug */**/bin/Release */**/obj/Debug */**/obj/Release +*/Migrations \ No newline at end of file From 1818439ea0707514425f75ee4e218741bf70f431 Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 08:19:34 +0200 Subject: [PATCH 03/28] installed packages --- .../api-cinema-challenge/api-cinema-challenge.csproj | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj index ac5bf732..63d28ee3 100644 --- a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj +++ b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj @@ -21,6 +21,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + all @@ -28,6 +32,7 @@ + From 5e1dc8e5cd3d21661f11077f4dd3c199afa8a8c1 Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 08:22:08 +0200 Subject: [PATCH 04/28] updated program.cs --- .../api-cinema-challenge/Program.cs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/api-cinema-challenge/api-cinema-challenge/Program.cs b/api-cinema-challenge/api-cinema-challenge/Program.cs index e55d9d54..afdf327a 100644 --- a/api-cinema-challenge/api-cinema-challenge/Program.cs +++ b/api-cinema-challenge/api-cinema-challenge/Program.cs @@ -1,20 +1,32 @@ using api_cinema_challenge.Data; +using Microsoft.EntityFrameworkCore; +using Scalar.AspNetCore; +using System.Diagnostics; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -builder.Services.AddDbContext(); +builder.Services.AddOpenApi(); +builder.Services.AddDbContext(options => { + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnectionString")); + options.LogTo(message => Debug.WriteLine(message)); +}); var app = builder.Build(); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { - app.UseSwagger(); - app.UseSwaggerUI(); + app.MapOpenApi(); + app.UseSwaggerUI(options => + { + options.SwaggerEndpoint("/openapi/v1.json", "Demo API"); + }); + app.MapScalarApiReference(); } app.UseHttpsRedirection(); + app.Run(); From 6e768a1f68e53fe11821fe1721a25b0729aad5b7 Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 08:38:57 +0200 Subject: [PATCH 05/28] added folder to seperate future code --- .../api-cinema-challenge/Repository/IRepository.cs | 6 ++++++ .../api-cinema-challenge/Repository/Repository.cs | 6 ++++++ .../api-cinema-challenge/api-cinema-challenge.csproj | 3 +++ 3 files changed, 15 insertions(+) create mode 100644 api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs new file mode 100644 index 00000000..f890e988 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs @@ -0,0 +1,6 @@ +namespace api_cinema_challenge.Repository +{ + public interface IRepository + { + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs new file mode 100644 index 00000000..0428cb98 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs @@ -0,0 +1,6 @@ +namespace api_cinema_challenge.Repository +{ + public class Repository + { + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj index 63d28ee3..24c6d141 100644 --- a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj +++ b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj @@ -38,6 +38,9 @@ + + + From b829dde7cd53646db0e233bcea35476adf284051 Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 08:46:43 +0200 Subject: [PATCH 06/28] implemented a generic repository --- .../Repository/IRepository.cs | 14 +++++- .../Repository/Repository.cs | 47 ++++++++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs index f890e988..f03a0a4f 100644 --- a/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs +++ b/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs @@ -1,6 +1,16 @@ -namespace api_cinema_challenge.Repository +using System.Linq.Expressions; + +namespace api_cinema_challenge.Repository { - public interface IRepository + public interface IRepository { + Task> GetAll(); + Task GetById(int id); + Task Delete(int id); + Task Add(T entity); + Task Update(T entity); + + Task> GetWithIncludes(params Expression>[] includes); + } } diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs index 0428cb98..9d5481c6 100644 --- a/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs +++ b/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs @@ -1,6 +1,49 @@ -namespace api_cinema_challenge.Repository +using api_cinema_challenge.Data; +using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; + +namespace api_cinema_challenge.Repository { - public class Repository + public class Repository : IRepository where T : class { + private CinemaContext _db; + private DbSet _table = null!; + + public Repository(CinemaContext db) + { + _db = db; + _table = db.Set(); + } + + + public async Task Add(T entity) + { + throw new NotImplementedException(); + } + + public async Task Delete(int id) + { + throw new NotImplementedException(); + } + + public async Task> GetAll() + { + return await _table.ToListAsync(); + } + + public async Task GetById(int id) + { + throw new NotImplementedException(); + } + + public async Task> GetWithIncludes(params Expression>[] includes) + { + throw new NotImplementedException(); + } + + public async Task Update(T entity) + { + throw new NotImplementedException(); + } } } From e5eb5ea21e2cddf6d98099eff34ca6426c383392 Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 09:02:09 +0200 Subject: [PATCH 07/28] impemented customer model and customergetdto, and seeded 3 customers in onmodelcreating in datacontext --- .../DTOs/CustomerDTOs/CustomerGetDto.cs | 12 +++++++++ .../Data/CinemaContext.cs | 25 +++++++++-------- .../api-cinema-challenge/Models/Customer.cs | 27 +++++++++++++++++++ .../api-cinema-challenge.csproj | 3 --- 4 files changed, 51 insertions(+), 16 deletions(-) create mode 100644 api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerGetDto.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/Models/Customer.cs diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerGetDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerGetDto.cs new file mode 100644 index 00000000..a03c3e79 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerGetDto.cs @@ -0,0 +1,12 @@ +namespace api_cinema_challenge.DTOs.CustomerDTOs +{ + public class CustomerGetDto + { + public int Id { get; set; } + public required string Name { get; set; } + public required string Email { get; set; } + public required string Phone { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs index ad4fe854..d03fee2f 100644 --- a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs +++ b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs @@ -1,26 +1,25 @@ -using Microsoft.EntityFrameworkCore; +using api_cinema_challenge.Models; +using Microsoft.EntityFrameworkCore; using Newtonsoft.Json.Linq; namespace api_cinema_challenge.Data { public class CinemaContext : DbContext { - private string _connectionString; - public CinemaContext(DbContextOptions options) : base(options) - { - var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); - _connectionString = configuration.GetValue("ConnectionStrings:DefaultConnectionString")!; - this.Database.EnsureCreated(); - } - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseNpgsql(_connectionString); - } + public CinemaContext(DbContextOptions options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { + // Seed data for the database + modelBuilder.Entity().HasData( + new Customer { Id = 1, Name = "Lionel Messi", Email = "messi@messi.messi", Phone = "90121413" }, + new Customer { Id = 2, Name = "Cristiano Ronaldo", Email = "ronaldo@ronaldo.ronaldo", Phone = "90121414" }, + new Customer { Id = 3, Name = "Wayne Rooney", Email = "rooney@rooney.rooney", Phone = "90121415" } + ); + } + + public DbSet Customers { get; set; } } } diff --git a/api-cinema-challenge/api-cinema-challenge/Models/Customer.cs b/api-cinema-challenge/api-cinema-challenge/Models/Customer.cs new file mode 100644 index 00000000..d0321092 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Customer.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace api_cinema_challenge.Models +{ + [Table("Customers")] + public class Customer + { + [Key] + [Column("CustomerId")] + public int Id { get; set; } + + [Required] + [Column("CustomerName")] + public required string Name { get; set; } + + [Required] + [Column("CustomerEmail")] + [EmailAddress] + public required string Email { get; set; } + + [Required] + [Column("CustomerPhone")] + [Phone] + public required string Phone { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj index 24c6d141..a079a4fe 100644 --- a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj +++ b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj @@ -37,10 +37,7 @@ - - - From 53fcf430aa9ed7eeaeb50bd0f5eb054bcf36acef Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 09:10:50 +0200 Subject: [PATCH 08/28] implemented getCustomers endpoint, updated program.cs to coinfigure endpoints of customers --- .../Endpoints/CustomerEndpoints.cs | 38 +++++++++ ...erModelAndSeededThreeCustomers.Designer.cs | 81 +++++++++++++++++++ ...AddCustomerModelAndSeededThreeCustomers.cs | 49 +++++++++++ .../Migrations/CinemaContextModelSnapshot.cs | 78 ++++++++++++++++++ .../api-cinema-challenge/Program.cs | 7 +- .../api-cinema-challenge.csproj | 4 - 6 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/Migrations/20250822070238_AddCustomerModelAndSeededThreeCustomers.Designer.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/Migrations/20250822070238_AddCustomerModelAndSeededThreeCustomers.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs new file mode 100644 index 00000000..8c9b4dd3 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs @@ -0,0 +1,38 @@ +using api_cinema_challenge.DTOs.CustomerDTOs; +using api_cinema_challenge.Models; +using api_cinema_challenge.Repository; +using Microsoft.AspNetCore.Mvc; + +namespace api_cinema_challenge.Endpoints +{ + public static class CustomerEndpoints + { + public static void ConfigureCustomerEndpoint(this WebApplication app) + { + var customers = app.MapGroup("customers"); + + customers.MapGet("/", GetCustomers); + + } + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public static async Task GetCustomers(IRepository repository) + { + var customers = await repository.GetAll(); + if (customers == null || !customers.Any()) { return Results.NotFound("No customers found."); } + + var customerDto = customers.Select(c => new CustomerGetDto + { + Id = c.Id, + Name = c.Name, + Email = c.Email, + Phone = c.Phone + }).ToList(); + + return Results.Ok(customerDto); + + } + + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822070238_AddCustomerModelAndSeededThreeCustomers.Designer.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822070238_AddCustomerModelAndSeededThreeCustomers.Designer.cs new file mode 100644 index 00000000..1041da16 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822070238_AddCustomerModelAndSeededThreeCustomers.Designer.cs @@ -0,0 +1,81 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using api_cinema_challenge.Data; + +#nullable disable + +namespace api_cinema_challenge.Migrations +{ + [DbContext(typeof(CinemaContext))] + [Migration("20250822070238_AddCustomerModelAndSeededThreeCustomers")] + partial class AddCustomerModelAndSeededThreeCustomers + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("api_cinema_challenge.Models.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("CustomerId"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CustomerEmail"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CustomerName"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CustomerPhone"); + + b.HasKey("Id"); + + b.ToTable("Customers"); + + b.HasData( + new + { + Id = 1, + Email = "messi@messi.messi", + Name = "Lionel Messi", + Phone = "90121413" + }, + new + { + Id = 2, + Email = "ronaldo@ronaldo.ronaldo", + Name = "Cristiano Ronaldo", + Phone = "90121414" + }, + new + { + Id = 3, + Email = "rooney@rooney.rooney", + Name = "Wayne Rooney", + Phone = "90121415" + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822070238_AddCustomerModelAndSeededThreeCustomers.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822070238_AddCustomerModelAndSeededThreeCustomers.cs new file mode 100644 index 00000000..c9a84711 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822070238_AddCustomerModelAndSeededThreeCustomers.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace api_cinema_challenge.Migrations +{ + /// + public partial class AddCustomerModelAndSeededThreeCustomers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Customers", + columns: table => new + { + CustomerId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CustomerName = table.Column(type: "text", nullable: false), + CustomerEmail = table.Column(type: "text", nullable: false), + CustomerPhone = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Customers", x => x.CustomerId); + }); + + migrationBuilder.InsertData( + table: "Customers", + columns: new[] { "CustomerId", "CustomerEmail", "CustomerName", "CustomerPhone" }, + values: new object[,] + { + { 1, "messi@messi.messi", "Lionel Messi", "90121413" }, + { 2, "ronaldo@ronaldo.ronaldo", "Cristiano Ronaldo", "90121414" }, + { 3, "rooney@rooney.rooney", "Wayne Rooney", "90121415" } + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Customers"); + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs new file mode 100644 index 00000000..f12b061a --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs @@ -0,0 +1,78 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using api_cinema_challenge.Data; + +#nullable disable + +namespace api_cinema_challenge.Migrations +{ + [DbContext(typeof(CinemaContext))] + partial class CinemaContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("api_cinema_challenge.Models.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("CustomerId"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CustomerEmail"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CustomerName"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CustomerPhone"); + + b.HasKey("Id"); + + b.ToTable("Customers"); + + b.HasData( + new + { + Id = 1, + Email = "messi@messi.messi", + Name = "Lionel Messi", + Phone = "90121413" + }, + new + { + Id = 2, + Email = "ronaldo@ronaldo.ronaldo", + Name = "Cristiano Ronaldo", + Phone = "90121414" + }, + new + { + Id = 3, + Email = "rooney@rooney.rooney", + Name = "Wayne Rooney", + Phone = "90121415" + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Program.cs b/api-cinema-challenge/api-cinema-challenge/Program.cs index afdf327a..242000e6 100644 --- a/api-cinema-challenge/api-cinema-challenge/Program.cs +++ b/api-cinema-challenge/api-cinema-challenge/Program.cs @@ -1,4 +1,7 @@ using api_cinema_challenge.Data; +using api_cinema_challenge.Endpoints; +using api_cinema_challenge.Models; +using api_cinema_challenge.Repository; using Microsoft.EntityFrameworkCore; using Scalar.AspNetCore; using System.Diagnostics; @@ -14,6 +17,8 @@ options.LogTo(message => Debug.WriteLine(message)); }); +builder.Services.AddScoped, Repository>(); + var app = builder.Build(); // Configure the HTTP request pipeline. @@ -28,5 +33,5 @@ } app.UseHttpsRedirection(); - +app.ConfigureCustomerEndpoint(); app.Run(); diff --git a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj index a079a4fe..17cbc89b 100644 --- a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj +++ b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj @@ -36,10 +36,6 @@ - - - - From 6edd21fba94f3b99bdfbe4d27699548bede56d7c Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 09:34:28 +0200 Subject: [PATCH 09/28] updated add endpoint for customer --- .../{CustomerGetDto.cs => CustomerDto.cs} | 2 +- .../DTOs/CustomerDTOs/CustomerPostDto.cs | 14 +++++++++ .../Endpoints/CustomerEndpoints.cs | 30 +++++++++++++++++-- .../Repository/IRepository.cs | 1 - .../Repository/Repository.cs | 9 ++---- .../api-cinema-challenge.csproj | 2 ++ 6 files changed, 47 insertions(+), 11 deletions(-) rename api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/{CustomerGetDto.cs => CustomerDto.cs} (92%) create mode 100644 api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerPostDto.cs diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerGetDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerDto.cs similarity index 92% rename from api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerGetDto.cs rename to api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerDto.cs index a03c3e79..d7277671 100644 --- a/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerGetDto.cs +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerDto.cs @@ -1,6 +1,6 @@ namespace api_cinema_challenge.DTOs.CustomerDTOs { - public class CustomerGetDto + public class CustomerDto { public int Id { get; set; } public required string Name { get; set; } diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerPostDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerPostDto.cs new file mode 100644 index 00000000..bc17e795 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerPostDto.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace api_cinema_challenge.DTOs.CustomerDTOs +{ + public class CustomerPostDto + { + public required string Name { get; set; } + [EmailAddress] + public required string Email { get; set; } + [Phone] + public required string Phone { get; set; } + + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs index 8c9b4dd3..7e7775bd 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs @@ -2,6 +2,7 @@ using api_cinema_challenge.Models; using api_cinema_challenge.Repository; using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; namespace api_cinema_challenge.Endpoints { @@ -12,7 +13,8 @@ public static void ConfigureCustomerEndpoint(this WebApplication app) var customers = app.MapGroup("customers"); customers.MapGet("/", GetCustomers); - + customers.MapPost("/", AddCustomer); + } [ProducesResponseType(StatusCodes.Status200OK)] @@ -22,7 +24,7 @@ public static async Task GetCustomers(IRepository repository) var customers = await repository.GetAll(); if (customers == null || !customers.Any()) { return Results.NotFound("No customers found."); } - var customerDto = customers.Select(c => new CustomerGetDto + var customerDto = customers.Select(c => new CustomerDto { Id = c.Id, Name = c.Name, @@ -30,7 +32,29 @@ public static async Task GetCustomers(IRepository repository) Phone = c.Phone }).ToList(); - return Results.Ok(customerDto); + return TypedResults.Ok(customerDto); + + } + + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public static async Task AddCustomer(IRepository repository, [FromBody] CustomerPostDto model, HttpRequest request) + { + if (model == null) return TypedResults.BadRequest("Invalid customer data"); + if (string.IsNullOrWhiteSpace(model.Name) || string.IsNullOrWhiteSpace(model.Email)) return TypedResults.BadRequest("Invalid customer data"); + + var phoneAttribute = new PhoneAttribute(); + if (!phoneAttribute.IsValid(model.Phone)) + return TypedResults.BadRequest("Invalid phone number format."); + + var newCustomer = new Customer { Name = model.Name, Email = model.Email, Phone = model.Phone }; + var addedCustomer = await repository.Add(newCustomer); + + var customerDto = new CustomerDto { Id = addedCustomer.Id, Name = addedCustomer.Name, Email=addedCustomer.Email, Phone = addedCustomer.Phone }; + + var baseUrl = $"{request.Scheme}://{request.Host}{request.PathBase}"; + var location = $"{baseUrl}/patients/{addedCustomer.Id}"; + return TypedResults.Created(location, customerDto); } diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs index f03a0a4f..d95cbca7 100644 --- a/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs +++ b/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs @@ -5,7 +5,6 @@ namespace api_cinema_challenge.Repository public interface IRepository { Task> GetAll(); - Task GetById(int id); Task Delete(int id); Task Add(T entity); Task Update(T entity); diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs index 9d5481c6..988b3438 100644 --- a/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs +++ b/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs @@ -18,7 +18,9 @@ public Repository(CinemaContext db) public async Task Add(T entity) { - throw new NotImplementedException(); + await _table.AddAsync(entity); + await _db.SaveChangesAsync(); + return entity; } public async Task Delete(int id) @@ -31,11 +33,6 @@ public async Task> GetAll() return await _table.ToListAsync(); } - public async Task GetById(int id) - { - throw new NotImplementedException(); - } - public async Task> GetWithIncludes(params Expression>[] includes) { throw new NotImplementedException(); diff --git a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj index 17cbc89b..445d6699 100644 --- a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj +++ b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj @@ -19,6 +19,8 @@ + + From 7d00ae37569e408e547f3f046c420b8abd58f90f Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 10:31:26 +0200 Subject: [PATCH 10/28] added getcustomerbyid endpoint --- .../Endpoints/CustomerEndpoints.cs | 47 +++++++++++++++---- .../api-cinema-challenge/Program.cs | 4 ++ .../Repository/IRepository.cs | 3 +- .../Repository/Repository.cs | 12 ++++- .../Validators/CustomerPostValidator.cs | 25 ++++++++++ .../Validators/ValidationFilter.cs | 32 +++++++++++++ 6 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 api-cinema-challenge/api-cinema-challenge/Validators/CustomerPostValidator.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/Validators/ValidationFilter.cs diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs index 7e7775bd..fcf1c543 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs @@ -1,6 +1,7 @@ using api_cinema_challenge.DTOs.CustomerDTOs; using api_cinema_challenge.Models; using api_cinema_challenge.Repository; +using FluentValidation; using Microsoft.AspNetCore.Mvc; using System.ComponentModel.DataAnnotations; @@ -12,11 +13,32 @@ public static void ConfigureCustomerEndpoint(this WebApplication app) { var customers = app.MapGroup("customers"); + customers.MapGet("/{id}", GetCustomerById); customers.MapGet("/", GetCustomers); customers.MapPost("/", AddCustomer); + // customers.MapDelete("/{id}"DeleteCustomer); } + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public static async Task GetCustomerById(int id, IRepository repository) + { + var customers = await repository.GetAll(); + var targetCustomer = customers.FirstOrDefault(c => c.Id == id); + if (targetCustomer == null) { return TypedResults.NotFound($"Customer with id {id} not found."); } + + var customerDto = new CustomerDto + { + Id = targetCustomer.Id, + Name = targetCustomer.Name, + Email = targetCustomer.Email, + Phone = targetCustomer.Phone + }; + + return TypedResults.Ok(customerDto); + } + [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public static async Task GetCustomers(IRepository repository) @@ -38,14 +60,16 @@ public static async Task GetCustomers(IRepository repository) [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public static async Task AddCustomer(IRepository repository, [FromBody] CustomerPostDto model, HttpRequest request) + public static async Task AddCustomer(IRepository repository, [FromBody] CustomerPostDto model, IValidator validator, HttpRequest request) { - if (model == null) return TypedResults.BadRequest("Invalid customer data"); - if (string.IsNullOrWhiteSpace(model.Name) || string.IsNullOrWhiteSpace(model.Email)) return TypedResults.BadRequest("Invalid customer data"); - - var phoneAttribute = new PhoneAttribute(); - if (!phoneAttribute.IsValid(model.Phone)) - return TypedResults.BadRequest("Invalid phone number format."); + if (model == null) { return TypedResults.BadRequest("Invalid customer data"); } + + var validationResult = await validator.ValidateAsync(model); + if (!validationResult.IsValid) + { + var errors = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)); + return TypedResults.BadRequest(errors); + } var newCustomer = new Customer { Name = model.Name, Email = model.Email, Phone = model.Phone }; var addedCustomer = await repository.Add(newCustomer); @@ -53,10 +77,15 @@ public static async Task AddCustomer(IRepository repository, var customerDto = new CustomerDto { Id = addedCustomer.Id, Name = addedCustomer.Name, Email=addedCustomer.Email, Phone = addedCustomer.Phone }; var baseUrl = $"{request.Scheme}://{request.Host}{request.PathBase}"; - var location = $"{baseUrl}/patients/{addedCustomer.Id}"; + var location = $"{baseUrl}/customers/{addedCustomer.Id}"; return TypedResults.Created(location, customerDto); - + } + public static async Task DeleteCustomer(int id, IRepository repository) + { + throw new NotImplementedException(); + + } } } diff --git a/api-cinema-challenge/api-cinema-challenge/Program.cs b/api-cinema-challenge/api-cinema-challenge/Program.cs index 242000e6..584393a4 100644 --- a/api-cinema-challenge/api-cinema-challenge/Program.cs +++ b/api-cinema-challenge/api-cinema-challenge/Program.cs @@ -1,7 +1,10 @@ using api_cinema_challenge.Data; +using api_cinema_challenge.DTOs.CustomerDTOs; using api_cinema_challenge.Endpoints; using api_cinema_challenge.Models; using api_cinema_challenge.Repository; +using api_cinema_challenge.Validators; +using FluentValidation; using Microsoft.EntityFrameworkCore; using Scalar.AspNetCore; using System.Diagnostics; @@ -18,6 +21,7 @@ }); builder.Services.AddScoped, Repository>(); +builder.Services.AddValidatorsFromAssemblyContaining(typeof(CustomerPostValidator)); var app = builder.Build(); diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs index d95cbca7..88fba549 100644 --- a/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs +++ b/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs @@ -5,7 +5,8 @@ namespace api_cinema_challenge.Repository public interface IRepository { Task> GetAll(); - Task Delete(int id); + Task GetById(int id); + Task Delete(object id); Task Add(T entity); Task Update(T entity); diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs index 988b3438..952f63e0 100644 --- a/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs +++ b/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs @@ -23,9 +23,12 @@ public async Task Add(T entity) return entity; } - public async Task Delete(int id) + public async Task Delete(object id) { - throw new NotImplementedException(); + T entity = await _table.FindAsync(id); + _table.Remove(entity); + await _db.SaveChangesAsync(); + return entity; } public async Task> GetAll() @@ -33,6 +36,11 @@ public async Task> GetAll() return await _table.ToListAsync(); } + public async Task GetById(int id) + { + return await _table.FindAsync(id); + } + public async Task> GetWithIncludes(params Expression>[] includes) { throw new NotImplementedException(); diff --git a/api-cinema-challenge/api-cinema-challenge/Validators/CustomerPostValidator.cs b/api-cinema-challenge/api-cinema-challenge/Validators/CustomerPostValidator.cs new file mode 100644 index 00000000..b4096913 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Validators/CustomerPostValidator.cs @@ -0,0 +1,25 @@ +using api_cinema_challenge.DTOs.CustomerDTOs; +using FluentValidation; + +namespace api_cinema_challenge.Validators +{ + public class CustomerPostValidator : AbstractValidator + { + public CustomerPostValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required.") + .Must(name => !string.IsNullOrWhiteSpace(name)).WithMessage("Name cannot be whitespace."); + + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required.") + .EmailAddress().WithMessage("Email must be a valid email address."); + + RuleFor(x => x.Phone) + .NotEmpty().WithMessage("Phone is required.") + .Matches(@"^\+?\d{7,15}$").WithMessage("Phone must be a valid phone number."); + + } + + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Validators/ValidationFilter.cs b/api-cinema-challenge/api-cinema-challenge/Validators/ValidationFilter.cs new file mode 100644 index 00000000..9359cacc --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Validators/ValidationFilter.cs @@ -0,0 +1,32 @@ +using FluentValidation; + +namespace api_cinema_challenge.Validators +{ + public class ValidationFilter : IEndpointFilter + { + public async ValueTask InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next) + { + var validator = ctx.HttpContext.RequestServices.GetService>(); + if (validator is not null) + { + var entity = ctx.Arguments + .OfType() + .FirstOrDefault(a => a?.GetType() == typeof(T)); + if (entity is not null) + { + var validation = await validator.ValidateAsync(entity); + if (validation.IsValid) + { + return await next(ctx); + } + return Results.ValidationProblem(validation.ToDictionary()); + } + else + { + return Results.Problem("Could not find type to validate"); + } + } + return await next(ctx); + } + } +} From fe190deb69f048860f6828a25aeae7fc96379c4e Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 10:34:54 +0200 Subject: [PATCH 11/28] implemented delete customer endpoint --- .../Endpoints/CustomerEndpoints.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs index fcf1c543..ce75a627 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs @@ -16,7 +16,7 @@ public static void ConfigureCustomerEndpoint(this WebApplication app) customers.MapGet("/{id}", GetCustomerById); customers.MapGet("/", GetCustomers); customers.MapPost("/", AddCustomer); - // customers.MapDelete("/{id}"DeleteCustomer); + customers.MapDelete("/{id}", DeleteCustomer); } @@ -84,8 +84,20 @@ public static async Task AddCustomer(IRepository repository, public static async Task DeleteCustomer(int id, IRepository repository) { - throw new NotImplementedException(); + var targetCustomer = await repository.GetById(id); + if (targetCustomer == null) { return TypedResults.NotFound($"Customer with id {id} not found."); } + var deletedCustomer = await repository.Delete(id); + + var customerDto = new CustomerDto + { + Id = deletedCustomer.Id, + Name = deletedCustomer.Name, + Email = deletedCustomer.Email, + Phone = deletedCustomer.Phone + }; + + return TypedResults.Ok(customerDto); } } } From 8fa207b4249491d2c3bb22dbff7882a2166ab23e Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 10:56:33 +0200 Subject: [PATCH 12/28] implemented update customer endpoint and added validator service to customerputdto --- .../DTOs/CustomerDTOs/CustomerPutDto.cs | 13 +++++++ .../Endpoints/CustomerEndpoints.cs | 39 +++++++++++++++++++ .../api-cinema-challenge/Program.cs | 4 +- .../Repository/IRepository.cs | 2 +- .../Repository/Repository.cs | 7 +++- .../Validators/CustomerPutValidator.cs | 24 ++++++++++++ 6 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerPutDto.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/Validators/CustomerPutValidator.cs diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerPutDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerPutDto.cs new file mode 100644 index 00000000..2c2c483a --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerPutDto.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace api_cinema_challenge.DTOs.CustomerDTOs +{ + public class CustomerPutDto + { + public required string Name { get; set; } + [EmailAddress] + public required string Email { get; set; } + [Phone] + public required string Phone { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs index ce75a627..500859e6 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs @@ -17,6 +17,7 @@ public static void ConfigureCustomerEndpoint(this WebApplication app) customers.MapGet("/", GetCustomers); customers.MapPost("/", AddCustomer); customers.MapDelete("/{id}", DeleteCustomer); + customers.MapPut("/{id}", UpdateCustomer); } @@ -99,5 +100,43 @@ public static async Task DeleteCustomer(int id, IRepository r return TypedResults.Ok(customerDto); } + + + public static async Task UpdateCustomer(int id, IRepository repository, [FromBody] CustomerPutDto model, IValidator validator, HttpRequest request) + { + // check if the customer we want to update exists + var existingCustomer = await repository.GetById(id); + if (existingCustomer == null) { return TypedResults.NotFound($"The customer you want to update with ID {id} does not exist"); } + + if (model == null) { return TypedResults.BadRequest("Invalid customer data"); } + + var validationResult = await validator.ValidateAsync(model); + if (!validationResult.IsValid) + { + var errors = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)); + return TypedResults.BadRequest(errors); + } + + // check if the new name already exists for another customer + var allCustomers = await repository.GetAll(); + var duplicateNameCustomer = allCustomers.FirstOrDefault( + c => c.Name == model.Name && c.Id != id); + if (duplicateNameCustomer != null) { return TypedResults.BadRequest($"A customer with the name '{model.Name}' already exists."); } + + // update the customer + existingCustomer.Name = model.Name; + existingCustomer.Email = model.Email; + existingCustomer.Phone = model.Phone; + + var updatedCustomer = await repository.Update(id, existingCustomer); + + // generate respone dto + var customerDto = new CustomerDto { Id = updatedCustomer.Id, Name = updatedCustomer.Name, Email = updatedCustomer.Email, Phone = updatedCustomer.Phone }; + + var baseUrl = $"{request.Scheme}://{request.Host}{request.PathBase}"; + var location = $"{baseUrl}/customers/{updatedCustomer.Id}"; + return TypedResults.Created(location, customerDto); + + } } } diff --git a/api-cinema-challenge/api-cinema-challenge/Program.cs b/api-cinema-challenge/api-cinema-challenge/Program.cs index 584393a4..873a404a 100644 --- a/api-cinema-challenge/api-cinema-challenge/Program.cs +++ b/api-cinema-challenge/api-cinema-challenge/Program.cs @@ -21,7 +21,9 @@ }); builder.Services.AddScoped, Repository>(); -builder.Services.AddValidatorsFromAssemblyContaining(typeof(CustomerPostValidator)); +// Register validators as services +builder.Services.AddScoped, CustomerPostValidator>(); +builder.Services.AddScoped, CustomerPutValidator>(); var app = builder.Build(); diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs index 88fba549..49643b31 100644 --- a/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs +++ b/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs @@ -8,7 +8,7 @@ public interface IRepository Task GetById(int id); Task Delete(object id); Task Add(T entity); - Task Update(T entity); + Task Update(int id, T entity); Task> GetWithIncludes(params Expression>[] includes); diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs index 952f63e0..7c4eb836 100644 --- a/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs +++ b/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs @@ -46,9 +46,12 @@ public async Task> GetWithIncludes(params Expression Update(T entity) + public async Task Update(int id, T entity) { - throw new NotImplementedException(); + _table.Attach(entity); + _db.Entry(entity).State = EntityState.Modified; + _db.SaveChanges(); + return await _table.FindAsync(id); } } } diff --git a/api-cinema-challenge/api-cinema-challenge/Validators/CustomerPutValidator.cs b/api-cinema-challenge/api-cinema-challenge/Validators/CustomerPutValidator.cs new file mode 100644 index 00000000..126175b6 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Validators/CustomerPutValidator.cs @@ -0,0 +1,24 @@ +using api_cinema_challenge.DTOs.CustomerDTOs; +using FluentValidation; + +namespace api_cinema_challenge.Validators +{ + public class CustomerPutValidator : AbstractValidator + { + public CustomerPutValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Name is required.") + .Must(name => !string.IsNullOrWhiteSpace(name)).WithMessage("Name cannot be whitespace."); + + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required.") + .EmailAddress().WithMessage("Email must be a valid email address."); + + RuleFor(x => x.Phone) + .NotEmpty().WithMessage("Phone is required.") + .Matches(@"^\+?\d{7,15}$").WithMessage("Phone must be a valid phone number."); + + } + } +} From 104d00f38a55dc11999d2b5103ed6a5edac06f12 Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:11:36 +0200 Subject: [PATCH 13/28] created movie model --- .../DTOs/CustomerDTOs/CustomerDto.cs | 1 + .../DTOs/MovieDTOs/MovieDto.cs | 14 +++++++++ .../api-cinema-challenge/Models/Movie.cs | 29 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MovieDto.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/Models/Movie.cs diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerDto.cs index d7277671..8b54b14e 100644 --- a/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerDto.cs +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerDto.cs @@ -3,6 +3,7 @@ public class CustomerDto { public int Id { get; set; } + public required string Name { get; set; } public required string Email { get; set; } public required string Phone { get; set; } diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MovieDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MovieDto.cs new file mode 100644 index 00000000..35a60d26 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MovieDto.cs @@ -0,0 +1,14 @@ +namespace api_cinema_challenge.DTOs.MovieDTOs +{ + public class MovieDto + { + public int Id { get; set; } + public required string Title { get; set; } + public required string Rating { get; set; } + + public required string Description { get; set; } + public required string RuntimeMins { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs b/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs new file mode 100644 index 00000000..0e92dd8e --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace api_cinema_challenge.Models +{ + [Table("Movies")] + public class Movie + { + [Key] + [Column("MovieId")] + public int Id { get; set; } + + [Required] + [Column("MovieTitle")] + public required string Title { get; set; } + + [Required] + [Column("Rating")] + public required string Rating { get; set; } + + [Required] + [Column("MovieDescription")] + public required string Description { get; set; } + + [Required] + [Column("RuntimeMinutes")] + public int RuntimeMins { get; set; } + } +} From b1b7c156e4d6ae41704218e39045e571e72cf49f Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:49:32 +0200 Subject: [PATCH 14/28] seeded 3 movies, migrated and updated db --- .../Data/CinemaContext.cs | 6 + .../Endpoints/MovieEndpoints.cs | 13 ++ ...250822094812_SeededThreeMovies.Designer.cs | 140 ++++++++++++++++++ .../20250822094812_SeededThreeMovies.cs | 50 +++++++ .../Migrations/CinemaContextModelSnapshot.cs | 59 ++++++++ .../api-cinema-challenge/Program.cs | 2 + 6 files changed, 270 insertions(+) create mode 100644 api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/Migrations/20250822094812_SeededThreeMovies.Designer.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/Migrations/20250822094812_SeededThreeMovies.cs diff --git a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs index d03fee2f..afc8479e 100644 --- a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs +++ b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs @@ -18,6 +18,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) new Customer { Id = 3, Name = "Wayne Rooney", Email = "rooney@rooney.rooney", Phone = "90121415" } ); + modelBuilder.Entity().HasData( + new Movie { Id = 1, Title = "Inception", Rating = "PG-13", Description = "A thief who steals corporate secrets through dream-sharing technology.", RuntimeMins = 148 }, + new Movie { Id = 2, Title = "The Matrix", Rating = "R", Description = "A computer hacker learns about the true nature of his reality.", RuntimeMins = 136 }, + new Movie { Id = 3, Title = "Interstellar", Rating = "PG-13", Description = "A team of explorers travel through a wormhole in space.", RuntimeMins = 169 } + ); + } public DbSet Customers { get; set; } diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs new file mode 100644 index 00000000..3da4fd91 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs @@ -0,0 +1,13 @@ +namespace api_cinema_challenge.Endpoints +{ + public static class MovieEndpoints + { + public static void ConfigureMovieEndpoint(this WebApplication app) + { + var movies = app.MapGroup("movies"); + + //movies.MapGet("/{id}", GetMovieById); + + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822094812_SeededThreeMovies.Designer.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822094812_SeededThreeMovies.Designer.cs new file mode 100644 index 00000000..e79143d4 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822094812_SeededThreeMovies.Designer.cs @@ -0,0 +1,140 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using api_cinema_challenge.Data; + +#nullable disable + +namespace api_cinema_challenge.Migrations +{ + [DbContext(typeof(CinemaContext))] + [Migration("20250822094812_SeededThreeMovies")] + partial class SeededThreeMovies + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("api_cinema_challenge.Models.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("CustomerId"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CustomerEmail"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CustomerName"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CustomerPhone"); + + b.HasKey("Id"); + + b.ToTable("Customers"); + + b.HasData( + new + { + Id = 1, + Email = "messi@messi.messi", + Name = "Lionel Messi", + Phone = "90121413" + }, + new + { + Id = 2, + Email = "ronaldo@ronaldo.ronaldo", + Name = "Cristiano Ronaldo", + Phone = "90121414" + }, + new + { + Id = 3, + Email = "rooney@rooney.rooney", + Name = "Wayne Rooney", + Phone = "90121415" + }); + }); + + modelBuilder.Entity("api_cinema_challenge.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("MovieId"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("MovieDescription"); + + b.Property("Rating") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Rating"); + + b.Property("RuntimeMins") + .HasColumnType("integer") + .HasColumnName("RuntimeMinutes"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("MovieTitle"); + + b.HasKey("Id"); + + b.ToTable("Movies"); + + b.HasData( + new + { + Id = 1, + Description = "A thief who steals corporate secrets through dream-sharing technology.", + Rating = "PG-13", + RuntimeMins = 148, + Title = "Inception" + }, + new + { + Id = 2, + Description = "A computer hacker learns about the true nature of his reality.", + Rating = "R", + RuntimeMins = 136, + Title = "The Matrix" + }, + new + { + Id = 3, + Description = "A team of explorers travel through a wormhole in space.", + Rating = "PG-13", + RuntimeMins = 169, + Title = "Interstellar" + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822094812_SeededThreeMovies.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822094812_SeededThreeMovies.cs new file mode 100644 index 00000000..a3756726 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822094812_SeededThreeMovies.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace api_cinema_challenge.Migrations +{ + /// + public partial class SeededThreeMovies : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Movies", + columns: table => new + { + MovieId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MovieTitle = table.Column(type: "text", nullable: false), + Rating = table.Column(type: "text", nullable: false), + MovieDescription = table.Column(type: "text", nullable: false), + RuntimeMinutes = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Movies", x => x.MovieId); + }); + + migrationBuilder.InsertData( + table: "Movies", + columns: new[] { "MovieId", "MovieDescription", "Rating", "RuntimeMinutes", "MovieTitle" }, + values: new object[,] + { + { 1, "A thief who steals corporate secrets through dream-sharing technology.", "PG-13", 148, "Inception" }, + { 2, "A computer hacker learns about the true nature of his reality.", "R", 136, "The Matrix" }, + { 3, "A team of explorers travel through a wormhole in space.", "PG-13", 169, "Interstellar" } + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Movies"); + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs index f12b061a..5278097e 100644 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs @@ -72,6 +72,65 @@ protected override void BuildModel(ModelBuilder modelBuilder) Phone = "90121415" }); }); + + modelBuilder.Entity("api_cinema_challenge.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("MovieId"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("MovieDescription"); + + b.Property("Rating") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Rating"); + + b.Property("RuntimeMins") + .HasColumnType("integer") + .HasColumnName("RuntimeMinutes"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("MovieTitle"); + + b.HasKey("Id"); + + b.ToTable("Movies"); + + b.HasData( + new + { + Id = 1, + Description = "A thief who steals corporate secrets through dream-sharing technology.", + Rating = "PG-13", + RuntimeMins = 148, + Title = "Inception" + }, + new + { + Id = 2, + Description = "A computer hacker learns about the true nature of his reality.", + Rating = "R", + RuntimeMins = 136, + Title = "The Matrix" + }, + new + { + Id = 3, + Description = "A team of explorers travel through a wormhole in space.", + Rating = "PG-13", + RuntimeMins = 169, + Title = "Interstellar" + }); + }); #pragma warning restore 612, 618 } } diff --git a/api-cinema-challenge/api-cinema-challenge/Program.cs b/api-cinema-challenge/api-cinema-challenge/Program.cs index 873a404a..02589fc7 100644 --- a/api-cinema-challenge/api-cinema-challenge/Program.cs +++ b/api-cinema-challenge/api-cinema-challenge/Program.cs @@ -21,6 +21,7 @@ }); builder.Services.AddScoped, Repository>(); +builder.Services.AddScoped, Repository>(); // Register validators as services builder.Services.AddScoped, CustomerPostValidator>(); builder.Services.AddScoped, CustomerPutValidator>(); @@ -40,4 +41,5 @@ app.UseHttpsRedirection(); app.ConfigureCustomerEndpoint(); +app.ConfigureMovieEndpoint(); app.Run(); From bb19d98cfeca3b513b2deb9eb36aa59523bb6b35 Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 12:00:15 +0200 Subject: [PATCH 15/28] added endpoint getmoviebyid --- .../DTOs/MovieDTOs/MovieDto.cs | 2 +- .../Endpoints/CustomerEndpoints.cs | 3 +-- .../Endpoints/MovieEndpoints.cs | 25 +++++++++++++++++-- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MovieDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MovieDto.cs index 35a60d26..09906ff7 100644 --- a/api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MovieDto.cs +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MovieDto.cs @@ -7,7 +7,7 @@ public class MovieDto public required string Rating { get; set; } public required string Description { get; set; } - public required string RuntimeMins { get; set; } + public required int RuntimeMins { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs index 500859e6..b5eef4aa 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs @@ -25,8 +25,7 @@ public static void ConfigureCustomerEndpoint(this WebApplication app) [ProducesResponseType(StatusCodes.Status404NotFound)] public static async Task GetCustomerById(int id, IRepository repository) { - var customers = await repository.GetAll(); - var targetCustomer = customers.FirstOrDefault(c => c.Id == id); + var targetCustomer = await repository.GetById(id); if (targetCustomer == null) { return TypedResults.NotFound($"Customer with id {id} not found."); } var customerDto = new CustomerDto diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs index 3da4fd91..ccb049d5 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs @@ -1,4 +1,8 @@ -namespace api_cinema_challenge.Endpoints +using api_cinema_challenge.DTOs.MovieDTOs; +using api_cinema_challenge.Models; +using api_cinema_challenge.Repository; + +namespace api_cinema_challenge.Endpoints { public static class MovieEndpoints { @@ -6,8 +10,25 @@ public static void ConfigureMovieEndpoint(this WebApplication app) { var movies = app.MapGroup("movies"); - //movies.MapGet("/{id}", GetMovieById); + movies.MapGet("/{id}", GetMovieById); } + + public static async Task GetMovieById(int id, IRepository repository) + { + var targetMovie = await repository.GetById(id); + if (targetMovie == null) { return TypedResults.NotFound($"Movie with id {id} not found."); } + + var movieDto = new MovieDto + { + Id = targetMovie.Id, + Title = targetMovie.Title, + Rating = targetMovie.Rating, + Description = targetMovie.Description, + RuntimeMins = targetMovie.RuntimeMins + }; + + return TypedResults.Ok(movieDto); + } } } From f0db4f60a18d3a598ea675072d2c079de483403d Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 12:55:13 +0200 Subject: [PATCH 16/28] implemented endpoint for deleting a movie by id --- .../DTOs/MovieDTOs/MoviePostDto.cs | 10 +++ .../Endpoints/CustomerEndpoints.cs | 6 +- .../Endpoints/MovieEndpoints.cs | 75 ++++++++++++++++++- .../api-cinema-challenge/Program.cs | 6 +- .../CustomerPostValidator.cs | 2 +- .../CustomerPutValidator.cs | 2 +- .../MovieValidators/MoviePostValidator.cs | 25 +++++++ 7 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MoviePostDto.cs rename api-cinema-challenge/api-cinema-challenge/Validators/{ => CustomerValidators}/CustomerPostValidator.cs (92%) rename api-cinema-challenge/api-cinema-challenge/Validators/{ => CustomerValidators}/CustomerPutValidator.cs (92%) create mode 100644 api-cinema-challenge/api-cinema-challenge/Validators/MovieValidators/MoviePostValidator.cs diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MoviePostDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MoviePostDto.cs new file mode 100644 index 00000000..21ce3dec --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MoviePostDto.cs @@ -0,0 +1,10 @@ +namespace api_cinema_challenge.DTOs.MovieDTOs +{ + public class MoviePostDto + { + public required string Title { get; set; } + public required string Rating { get; set; } + public required string Description { get; set; } + public required int RuntimeMins { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs index b5eef4aa..b324c33d 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs @@ -82,6 +82,8 @@ public static async Task AddCustomer(IRepository repository, } + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public static async Task DeleteCustomer(int id, IRepository repository) { var targetCustomer = await repository.GetById(id); @@ -100,7 +102,9 @@ public static async Task DeleteCustomer(int id, IRepository r return TypedResults.Ok(customerDto); } - + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public static async Task UpdateCustomer(int id, IRepository repository, [FromBody] CustomerPutDto model, IValidator validator, HttpRequest request) { // check if the customer we want to update exists diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs index ccb049d5..92f29c75 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs @@ -1,6 +1,9 @@ -using api_cinema_challenge.DTOs.MovieDTOs; +using api_cinema_challenge.DTOs.CustomerDTOs; +using api_cinema_challenge.DTOs.MovieDTOs; using api_cinema_challenge.Models; using api_cinema_challenge.Repository; +using FluentValidation; +using Microsoft.AspNetCore.Mvc; namespace api_cinema_challenge.Endpoints { @@ -11,9 +14,14 @@ public static void ConfigureMovieEndpoint(this WebApplication app) var movies = app.MapGroup("movies"); movies.MapGet("/{id}", GetMovieById); - + movies.MapGet("/", GetMovies); + movies.MapPost("/", AddMovie); + movies.MapDelete("/{id}", DeleteMovie); + } + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] public static async Task GetMovieById(int id, IRepository repository) { var targetMovie = await repository.GetById(id); @@ -30,5 +38,68 @@ public static async Task GetMovieById(int id, IRepository reposi return TypedResults.Ok(movieDto); } + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public static async Task GetMovies(IRepository repository) + { + var movies = await repository.GetAll(); + if (movies == null || !movies.Any()) { return Results.NotFound("No movies found."); } + var movieDtos = movies.Select(m => new MovieDto + { + Id = m.Id, + Title = m.Title, + Rating = m.Rating, + Description = m.Description, + RuntimeMins = m.RuntimeMins + }).ToList(); + + return TypedResults.Ok(movieDtos); + } + + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public static async Task AddMovie(IRepository repository, [FromBody] MoviePostDto model, HttpRequest request, IValidator validator) + { + if (model == null) { return TypedResults.BadRequest("Invalid movie data"); } + + var validationResult = await validator.ValidateAsync(model); + if (!validationResult.IsValid) + { + var errors = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)); + return TypedResults.BadRequest(errors); + } + + var newMovie = new Movie { Title = model.Title, Rating = model.Rating, Description = model.Description, RuntimeMins = model.RuntimeMins }; + var addedMovie = await repository.Add(newMovie); + + var movieDto = new MovieDto { Id = addedMovie.Id, Title = addedMovie.Title, Rating = addedMovie.Rating, Description = addedMovie.Description, RuntimeMins = addedMovie.RuntimeMins }; + + var baseUrl = $"{request.Scheme}://{request.Host}{request.PathBase}"; + var location = $"{baseUrl}/movies/{addedMovie.Id}"; + return TypedResults.Created(location, movieDto); + + } + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public static async Task DeleteMovie(int id, IRepository repository) + { + var targetMovie = await repository.GetById(id); + if (targetMovie == null) { return TypedResults.NotFound($"Movie with id {id} not found."); } + + var deletedMovie = await repository.Delete(id); + + var movieDto = new MovieDto + { + Id = deletedMovie.Id, + Title = deletedMovie.Title, + Rating = deletedMovie.Rating, + Description = deletedMovie.Description, + RuntimeMins = deletedMovie.RuntimeMins, + }; + + return TypedResults.Ok(movieDto); + } } } diff --git a/api-cinema-challenge/api-cinema-challenge/Program.cs b/api-cinema-challenge/api-cinema-challenge/Program.cs index 02589fc7..b1f02431 100644 --- a/api-cinema-challenge/api-cinema-challenge/Program.cs +++ b/api-cinema-challenge/api-cinema-challenge/Program.cs @@ -1,9 +1,11 @@ using api_cinema_challenge.Data; using api_cinema_challenge.DTOs.CustomerDTOs; +using api_cinema_challenge.DTOs.MovieDTOs; using api_cinema_challenge.Endpoints; using api_cinema_challenge.Models; using api_cinema_challenge.Repository; -using api_cinema_challenge.Validators; +using api_cinema_challenge.Validators.CustomerValidators; +using api_cinema_challenge.Validators.MovieValidators; using FluentValidation; using Microsoft.EntityFrameworkCore; using Scalar.AspNetCore; @@ -25,6 +27,8 @@ // Register validators as services builder.Services.AddScoped, CustomerPostValidator>(); builder.Services.AddScoped, CustomerPutValidator>(); +builder.Services.AddScoped, MoviePostValidator>(); + var app = builder.Build(); diff --git a/api-cinema-challenge/api-cinema-challenge/Validators/CustomerPostValidator.cs b/api-cinema-challenge/api-cinema-challenge/Validators/CustomerValidators/CustomerPostValidator.cs similarity index 92% rename from api-cinema-challenge/api-cinema-challenge/Validators/CustomerPostValidator.cs rename to api-cinema-challenge/api-cinema-challenge/Validators/CustomerValidators/CustomerPostValidator.cs index b4096913..eea8f5f6 100644 --- a/api-cinema-challenge/api-cinema-challenge/Validators/CustomerPostValidator.cs +++ b/api-cinema-challenge/api-cinema-challenge/Validators/CustomerValidators/CustomerPostValidator.cs @@ -1,7 +1,7 @@ using api_cinema_challenge.DTOs.CustomerDTOs; using FluentValidation; -namespace api_cinema_challenge.Validators +namespace api_cinema_challenge.Validators.CustomerValidators { public class CustomerPostValidator : AbstractValidator { diff --git a/api-cinema-challenge/api-cinema-challenge/Validators/CustomerPutValidator.cs b/api-cinema-challenge/api-cinema-challenge/Validators/CustomerValidators/CustomerPutValidator.cs similarity index 92% rename from api-cinema-challenge/api-cinema-challenge/Validators/CustomerPutValidator.cs rename to api-cinema-challenge/api-cinema-challenge/Validators/CustomerValidators/CustomerPutValidator.cs index 126175b6..78c9757e 100644 --- a/api-cinema-challenge/api-cinema-challenge/Validators/CustomerPutValidator.cs +++ b/api-cinema-challenge/api-cinema-challenge/Validators/CustomerValidators/CustomerPutValidator.cs @@ -1,7 +1,7 @@ using api_cinema_challenge.DTOs.CustomerDTOs; using FluentValidation; -namespace api_cinema_challenge.Validators +namespace api_cinema_challenge.Validators.CustomerValidators { public class CustomerPutValidator : AbstractValidator { diff --git a/api-cinema-challenge/api-cinema-challenge/Validators/MovieValidators/MoviePostValidator.cs b/api-cinema-challenge/api-cinema-challenge/Validators/MovieValidators/MoviePostValidator.cs new file mode 100644 index 00000000..64f02dcb --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Validators/MovieValidators/MoviePostValidator.cs @@ -0,0 +1,25 @@ +using api_cinema_challenge.DTOs.MovieDTOs; + +using FluentValidation; + +namespace api_cinema_challenge.Validators.MovieValidators +{ + public class MoviePostValidator : AbstractValidator + { + public MoviePostValidator() + { + RuleFor(m => m.Title) + .NotEmpty().WithMessage("Title is required."); + + RuleFor(m => m.Rating) + .NotEmpty().WithMessage("Rating is required."); + + RuleFor(m => m.Description) + .NotEmpty().WithMessage("Description is required.") + .MaximumLength(500).WithMessage("Description must be at most 500 characters."); + + RuleFor(m => m.RuntimeMins) + .GreaterThan(0).WithMessage("Runtime must be greater than 0 minutes."); + } + } +} From 636f4244154ec1c96b3fd9ff79e10538d39975eb Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 13:09:24 +0200 Subject: [PATCH 17/28] implemented endpoint for updating a movie and made it optional which properties one wants to update on customer and movie --- .../DTOs/CustomerDTOs/CustomerPutDto.cs | 6 +-- .../DTOs/MovieDTOs/MoviePutDto.cs | 10 +++++ .../Endpoints/CustomerEndpoints.cs | 6 +-- .../Endpoints/MovieEndpoints.cs | 45 ++++++++++++++++++- .../api-cinema-challenge/Program.cs | 2 +- .../MovieValidators/MoviePutValidator.cs | 24 ++++++++++ 6 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MoviePutDto.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/Validators/MovieValidators/MoviePutValidator.cs diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerPutDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerPutDto.cs index 2c2c483a..bc7f3a41 100644 --- a/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerPutDto.cs +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerDTOs/CustomerPutDto.cs @@ -4,10 +4,10 @@ namespace api_cinema_challenge.DTOs.CustomerDTOs { public class CustomerPutDto { - public required string Name { get; set; } + public string? Name { get; set; } [EmailAddress] - public required string Email { get; set; } + public string? Email { get; set; } [Phone] - public required string Phone { get; set; } + public string? Phone { get; set; } } } diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MoviePutDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MoviePutDto.cs new file mode 100644 index 00000000..7fb754b4 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MoviePutDto.cs @@ -0,0 +1,10 @@ +namespace api_cinema_challenge.DTOs.MovieDTOs +{ + public class MoviePutDto + { + public string? Title { get; set; } + public string? Rating { get; set; } + public string? Description { get; set; } + public int? RuntimeMins { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs index b324c33d..eb41f461 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs @@ -127,9 +127,9 @@ public static async Task UpdateCustomer(int id, IRepository r if (duplicateNameCustomer != null) { return TypedResults.BadRequest($"A customer with the name '{model.Name}' already exists."); } // update the customer - existingCustomer.Name = model.Name; - existingCustomer.Email = model.Email; - existingCustomer.Phone = model.Phone; + if (model.Name is not null) existingCustomer.Name = model.Name; + if (model.Email is not null) existingCustomer.Email = model.Email; + if (model.Phone is not null) existingCustomer.Phone = model.Phone; var updatedCustomer = await repository.Update(id, existingCustomer); diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs index 92f29c75..f1352a79 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs @@ -1,5 +1,4 @@ -using api_cinema_challenge.DTOs.CustomerDTOs; -using api_cinema_challenge.DTOs.MovieDTOs; +using api_cinema_challenge.DTOs.MovieDTOs; using api_cinema_challenge.Models; using api_cinema_challenge.Repository; using FluentValidation; @@ -17,6 +16,7 @@ public static void ConfigureMovieEndpoint(this WebApplication app) movies.MapGet("/", GetMovies); movies.MapPost("/", AddMovie); movies.MapDelete("/{id}", DeleteMovie); + movies.MapPut("/{id}", UpdateMovie); } @@ -101,5 +101,46 @@ public static async Task DeleteMovie(int id, IRepository reposit return TypedResults.Ok(movieDto); } + + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public static async Task UpdateMovie(int id, IRepository repository, [FromBody] MoviePutDto model, IValidator validator, HttpRequest request) + { + // check if the movie we want to update exists + var existingMovie = await repository.GetById(id); + if (existingMovie == null) { return TypedResults.NotFound($"The movie you want to update with ID {id} does not exist"); } + + if (model == null) { return TypedResults.BadRequest("Invalid movie data"); } + + var validationResult = await validator.ValidateAsync(model); + if (!validationResult.IsValid) + { + var errors = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)); + return TypedResults.BadRequest(errors); + } + + // check if the new title already exists for another movie + var allMovies = await repository.GetAll(); + var duplicateTitleMovie = allMovies.FirstOrDefault( + m => m.Title == model.Title && m.Id != id); + if (duplicateTitleMovie != null) { return TypedResults.BadRequest($"A movie with the title '{model.Title}' already exists."); } + + // update the movie + if (model.Title is not null) existingMovie.Title = model.Title; + if (model.Rating is not null) existingMovie.Rating = model.Rating; + if (model.Description is not null) existingMovie.Description = model.Description; + if (model.RuntimeMins is not null) existingMovie.RuntimeMins = model.RuntimeMins.Value; + + var updatedMovie = await repository.Update(id, existingMovie); + + // generate respone dto + var movieDto = new MovieDto { Id = updatedMovie.Id, Title = updatedMovie.Title, Rating = updatedMovie.Rating, Description = updatedMovie.Description, RuntimeMins = updatedMovie.RuntimeMins }; + + var baseUrl = $"{request.Scheme}://{request.Host}{request.PathBase}"; + var location = $"{baseUrl}/movies/{updatedMovie.Id}"; + return TypedResults.Created(location, movieDto); + + } } } diff --git a/api-cinema-challenge/api-cinema-challenge/Program.cs b/api-cinema-challenge/api-cinema-challenge/Program.cs index b1f02431..7399d073 100644 --- a/api-cinema-challenge/api-cinema-challenge/Program.cs +++ b/api-cinema-challenge/api-cinema-challenge/Program.cs @@ -28,7 +28,7 @@ builder.Services.AddScoped, CustomerPostValidator>(); builder.Services.AddScoped, CustomerPutValidator>(); builder.Services.AddScoped, MoviePostValidator>(); - +builder.Services.AddScoped, MoviePutValidator>(); var app = builder.Build(); diff --git a/api-cinema-challenge/api-cinema-challenge/Validators/MovieValidators/MoviePutValidator.cs b/api-cinema-challenge/api-cinema-challenge/Validators/MovieValidators/MoviePutValidator.cs new file mode 100644 index 00000000..766ae912 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Validators/MovieValidators/MoviePutValidator.cs @@ -0,0 +1,24 @@ +using api_cinema_challenge.DTOs.MovieDTOs; +using FluentValidation; + +namespace api_cinema_challenge.Validators.MovieValidators +{ + public class MoviePutValidator : AbstractValidator + { + public MoviePutValidator() + { + RuleFor(m => m.Title) + .NotEmpty().WithMessage("Title is required."); + + RuleFor(m => m.Rating) + .NotEmpty().WithMessage("Rating is required."); + + RuleFor(m => m.Description) + .NotEmpty().WithMessage("Description is required.") + .MaximumLength(500).WithMessage("Description must be at most 500 characters."); + + RuleFor(m => m.RuntimeMins) + .GreaterThan(0).WithMessage("Runtime must be greater than 0 minutes."); + } + } +} From e6e2aeae5634084a2fa9359864f13a962adec6e1 Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 14:04:53 +0200 Subject: [PATCH 18/28] refactored customer and movie endpoints, migrated, update db, updated prop updatedAT to datetime utc now --- .../Data/CinemaContext.cs | 67 +++++++++++++-- .../Endpoints/CustomerEndpoints.cs | 23 +++-- .../Endpoints/MovieEndpoints.cs | 23 +++-- ...erModelAndSeededThreeCustomers.Designer.cs | 81 ------------------ ...AddCustomerModelAndSeededThreeCustomers.cs | 49 ----------- .../20250822094812_SeededThreeMovies.cs | 50 ----------- ...rSeedingAndModelsWithDateTime.Designer.cs} | 45 ++++++++-- ...39_RefactorSeedingAndModelsWithDateTime.cs | 83 +++++++++++++++++++ .../Migrations/CinemaContextModelSnapshot.cs | 41 +++++++-- .../api-cinema-challenge/Models/Customer.cs | 6 ++ .../api-cinema-challenge/Models/Movie.cs | 6 ++ .../Repository/Repository.cs | 4 +- .../CustomerPutValidator.cs | 12 ++- .../MovieValidators/MoviePutValidator.cs | 20 +++-- 14 files changed, 280 insertions(+), 230 deletions(-) delete mode 100644 api-cinema-challenge/api-cinema-challenge/Migrations/20250822070238_AddCustomerModelAndSeededThreeCustomers.Designer.cs delete mode 100644 api-cinema-challenge/api-cinema-challenge/Migrations/20250822070238_AddCustomerModelAndSeededThreeCustomers.cs delete mode 100644 api-cinema-challenge/api-cinema-challenge/Migrations/20250822094812_SeededThreeMovies.cs rename api-cinema-challenge/api-cinema-challenge/Migrations/{20250822094812_SeededThreeMovies.Designer.cs => 20250822113239_RefactorSeedingAndModelsWithDateTime.Designer.cs} (67%) create mode 100644 api-cinema-challenge/api-cinema-challenge/Migrations/20250822113239_RefactorSeedingAndModelsWithDateTime.cs diff --git a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs index afc8479e..90aac7f9 100644 --- a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs +++ b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs @@ -13,15 +13,66 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // Seed data for the database modelBuilder.Entity().HasData( - new Customer { Id = 1, Name = "Lionel Messi", Email = "messi@messi.messi", Phone = "90121413" }, - new Customer { Id = 2, Name = "Cristiano Ronaldo", Email = "ronaldo@ronaldo.ronaldo", Phone = "90121414" }, - new Customer { Id = 3, Name = "Wayne Rooney", Email = "rooney@rooney.rooney", Phone = "90121415" } - ); + new Customer + { + Id = 1, + Name = "Lionel Messi", + Email = "messi@messi.messi", + Phone = "90121413", + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) + }, + new Customer + { + Id = 2, + Name = "Cristiano Ronaldo", + Email = "ronaldo@ronaldo.ronaldo", + Phone = "90121414", + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) + }, + new Customer + { + Id = 3, + Name = "Wayne Rooney", + Email = "rooney@rooney.rooney", + Phone = "90121415", + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) + } + ); - modelBuilder.Entity().HasData( - new Movie { Id = 1, Title = "Inception", Rating = "PG-13", Description = "A thief who steals corporate secrets through dream-sharing technology.", RuntimeMins = 148 }, - new Movie { Id = 2, Title = "The Matrix", Rating = "R", Description = "A computer hacker learns about the true nature of his reality.", RuntimeMins = 136 }, - new Movie { Id = 3, Title = "Interstellar", Rating = "PG-13", Description = "A team of explorers travel through a wormhole in space.", RuntimeMins = 169 } + modelBuilder.Entity().HasData( + new Movie + { + Id = 1, + Title = "Inception", + Rating = "PG-13", + Description = "A thief who steals corporate secrets through dream-sharing technology.", + RuntimeMins = 148, + CreatedAt = new DateTime(2010, 7, 16, 11, 1, 56, 633, DateTimeKind.Utc), + UpdatedAt = new DateTime(2010, 7, 16, 11, 1, 56, 633, DateTimeKind.Utc) + }, + new Movie + { + Id = 2, + Title = "The Matrix", + Rating = "R", + Description = "A computer hacker learns about the true nature of his reality.", + RuntimeMins = 136, + CreatedAt = new DateTime(1999, 3, 31, 11, 1, 56, 633, DateTimeKind.Utc), + UpdatedAt = new DateTime(1999, 3, 31, 11, 1, 56, 633, DateTimeKind.Utc) + }, + new Movie + { + Id = 3, + Title = "Interstellar", + Rating = "PG-13", + Description = "A team of explorers travel through a wormhole in space.", + RuntimeMins = 169, + CreatedAt = new DateTime(2014, 11, 7, 11, 1, 56, 633, DateTimeKind.Utc), + UpdatedAt = new DateTime(2014, 11, 7, 11, 1, 56, 633, DateTimeKind.Utc) + } ); } diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs index eb41f461..d36f3ee2 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs @@ -33,7 +33,9 @@ public static async Task GetCustomerById(int id, IRepository Id = targetCustomer.Id, Name = targetCustomer.Name, Email = targetCustomer.Email, - Phone = targetCustomer.Phone + Phone = targetCustomer.Phone, + CreatedAt = targetCustomer.CreatedAt, + UpdatedAt = targetCustomer.UpdatedAt }; return TypedResults.Ok(customerDto); @@ -51,7 +53,9 @@ public static async Task GetCustomers(IRepository repository) Id = c.Id, Name = c.Name, Email = c.Email, - Phone = c.Phone + Phone = c.Phone, + CreatedAt = c.CreatedAt, + UpdatedAt = c.UpdatedAt }).ToList(); return TypedResults.Ok(customerDto); @@ -74,7 +78,7 @@ public static async Task AddCustomer(IRepository repository, var newCustomer = new Customer { Name = model.Name, Email = model.Email, Phone = model.Phone }; var addedCustomer = await repository.Add(newCustomer); - var customerDto = new CustomerDto { Id = addedCustomer.Id, Name = addedCustomer.Name, Email=addedCustomer.Email, Phone = addedCustomer.Phone }; + var customerDto = new CustomerDto { Id = addedCustomer.Id, Name = addedCustomer.Name, Email=addedCustomer.Email, Phone = addedCustomer.Phone, CreatedAt = addedCustomer.CreatedAt, UpdatedAt = addedCustomer.UpdatedAt }; var baseUrl = $"{request.Scheme}://{request.Host}{request.PathBase}"; var location = $"{baseUrl}/customers/{addedCustomer.Id}"; @@ -96,7 +100,9 @@ public static async Task DeleteCustomer(int id, IRepository r Id = deletedCustomer.Id, Name = deletedCustomer.Name, Email = deletedCustomer.Email, - Phone = deletedCustomer.Phone + Phone = deletedCustomer.Phone, + CreatedAt = deletedCustomer.CreatedAt, + UpdatedAt = deletedCustomer.UpdatedAt }; return TypedResults.Ok(customerDto); @@ -127,9 +133,12 @@ public static async Task UpdateCustomer(int id, IRepository r if (duplicateNameCustomer != null) { return TypedResults.BadRequest($"A customer with the name '{model.Name}' already exists."); } // update the customer - if (model.Name is not null) existingCustomer.Name = model.Name; - if (model.Email is not null) existingCustomer.Email = model.Email; - if (model.Phone is not null) existingCustomer.Phone = model.Phone; + if (!string.IsNullOrWhiteSpace(model.Name)) existingCustomer.Name = model.Name; + if (!string.IsNullOrWhiteSpace(model.Email)) existingCustomer.Email = model.Email; + if (!string.IsNullOrWhiteSpace(model.Phone)) existingCustomer.Phone = model.Phone; + + // set UpdatedAt to now + existingCustomer.UpdatedAt = DateTime.UtcNow; var updatedCustomer = await repository.Update(id, existingCustomer); diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs index f1352a79..97b3eb2d 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs @@ -33,7 +33,9 @@ public static async Task GetMovieById(int id, IRepository reposi Title = targetMovie.Title, Rating = targetMovie.Rating, Description = targetMovie.Description, - RuntimeMins = targetMovie.RuntimeMins + RuntimeMins = targetMovie.RuntimeMins, + CreatedAt = targetMovie.CreatedAt, + UpdatedAt = targetMovie.UpdatedAt }; return TypedResults.Ok(movieDto); @@ -51,7 +53,9 @@ public static async Task GetMovies(IRepository repository) Title = m.Title, Rating = m.Rating, Description = m.Description, - RuntimeMins = m.RuntimeMins + RuntimeMins = m.RuntimeMins, + CreatedAt = m.CreatedAt, + UpdatedAt = m.UpdatedAt }).ToList(); return TypedResults.Ok(movieDtos); @@ -73,7 +77,7 @@ public static async Task AddMovie(IRepository repository, [FromB var newMovie = new Movie { Title = model.Title, Rating = model.Rating, Description = model.Description, RuntimeMins = model.RuntimeMins }; var addedMovie = await repository.Add(newMovie); - var movieDto = new MovieDto { Id = addedMovie.Id, Title = addedMovie.Title, Rating = addedMovie.Rating, Description = addedMovie.Description, RuntimeMins = addedMovie.RuntimeMins }; + var movieDto = new MovieDto { Id = addedMovie.Id, Title = addedMovie.Title, Rating = addedMovie.Rating, Description = addedMovie.Description, RuntimeMins = addedMovie.RuntimeMins, CreatedAt = addedMovie.CreatedAt, UpdatedAt = addedMovie.UpdatedAt }; var baseUrl = $"{request.Scheme}://{request.Host}{request.PathBase}"; var location = $"{baseUrl}/movies/{addedMovie.Id}"; @@ -97,6 +101,8 @@ public static async Task DeleteMovie(int id, IRepository reposit Rating = deletedMovie.Rating, Description = deletedMovie.Description, RuntimeMins = deletedMovie.RuntimeMins, + CreatedAt = deletedMovie.CreatedAt, + UpdatedAt = deletedMovie.UpdatedAt }; return TypedResults.Ok(movieDto); @@ -127,15 +133,18 @@ public static async Task UpdateMovie(int id, IRepository reposit if (duplicateTitleMovie != null) { return TypedResults.BadRequest($"A movie with the title '{model.Title}' already exists."); } // update the movie - if (model.Title is not null) existingMovie.Title = model.Title; - if (model.Rating is not null) existingMovie.Rating = model.Rating; - if (model.Description is not null) existingMovie.Description = model.Description; + if (!string.IsNullOrWhiteSpace(model.Title)) existingMovie.Title = model.Title; + if (!string.IsNullOrWhiteSpace(model.Rating)) existingMovie.Rating = model.Rating; + if (!string.IsNullOrWhiteSpace(model.Description)) existingMovie.Description = model.Description; if (model.RuntimeMins is not null) existingMovie.RuntimeMins = model.RuntimeMins.Value; + // set UpdatedAt to now + existingMovie.UpdatedAt = DateTime.UtcNow; + var updatedMovie = await repository.Update(id, existingMovie); // generate respone dto - var movieDto = new MovieDto { Id = updatedMovie.Id, Title = updatedMovie.Title, Rating = updatedMovie.Rating, Description = updatedMovie.Description, RuntimeMins = updatedMovie.RuntimeMins }; + var movieDto = new MovieDto { Id = updatedMovie.Id, Title = updatedMovie.Title, Rating = updatedMovie.Rating, Description = updatedMovie.Description, RuntimeMins = updatedMovie.RuntimeMins, CreatedAt = updatedMovie.CreatedAt, UpdatedAt = updatedMovie.UpdatedAt }; var baseUrl = $"{request.Scheme}://{request.Host}{request.PathBase}"; var location = $"{baseUrl}/movies/{updatedMovie.Id}"; diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822070238_AddCustomerModelAndSeededThreeCustomers.Designer.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822070238_AddCustomerModelAndSeededThreeCustomers.Designer.cs deleted file mode 100644 index 1041da16..00000000 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822070238_AddCustomerModelAndSeededThreeCustomers.Designer.cs +++ /dev/null @@ -1,81 +0,0 @@ -// -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using api_cinema_challenge.Data; - -#nullable disable - -namespace api_cinema_challenge.Migrations -{ - [DbContext(typeof(CinemaContext))] - [Migration("20250822070238_AddCustomerModelAndSeededThreeCustomers")] - partial class AddCustomerModelAndSeededThreeCustomers - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.8") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("api_cinema_challenge.Models.Customer", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("CustomerId"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Email") - .IsRequired() - .HasColumnType("text") - .HasColumnName("CustomerEmail"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text") - .HasColumnName("CustomerName"); - - b.Property("Phone") - .IsRequired() - .HasColumnType("text") - .HasColumnName("CustomerPhone"); - - b.HasKey("Id"); - - b.ToTable("Customers"); - - b.HasData( - new - { - Id = 1, - Email = "messi@messi.messi", - Name = "Lionel Messi", - Phone = "90121413" - }, - new - { - Id = 2, - Email = "ronaldo@ronaldo.ronaldo", - Name = "Cristiano Ronaldo", - Phone = "90121414" - }, - new - { - Id = 3, - Email = "rooney@rooney.rooney", - Name = "Wayne Rooney", - Phone = "90121415" - }); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822070238_AddCustomerModelAndSeededThreeCustomers.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822070238_AddCustomerModelAndSeededThreeCustomers.cs deleted file mode 100644 index c9a84711..00000000 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822070238_AddCustomerModelAndSeededThreeCustomers.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional - -namespace api_cinema_challenge.Migrations -{ - /// - public partial class AddCustomerModelAndSeededThreeCustomers : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Customers", - columns: table => new - { - CustomerId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - CustomerName = table.Column(type: "text", nullable: false), - CustomerEmail = table.Column(type: "text", nullable: false), - CustomerPhone = table.Column(type: "text", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Customers", x => x.CustomerId); - }); - - migrationBuilder.InsertData( - table: "Customers", - columns: new[] { "CustomerId", "CustomerEmail", "CustomerName", "CustomerPhone" }, - values: new object[,] - { - { 1, "messi@messi.messi", "Lionel Messi", "90121413" }, - { 2, "ronaldo@ronaldo.ronaldo", "Cristiano Ronaldo", "90121414" }, - { 3, "rooney@rooney.rooney", "Wayne Rooney", "90121415" } - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Customers"); - } - } -} diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822094812_SeededThreeMovies.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822094812_SeededThreeMovies.cs deleted file mode 100644 index a3756726..00000000 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822094812_SeededThreeMovies.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional - -namespace api_cinema_challenge.Migrations -{ - /// - public partial class SeededThreeMovies : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Movies", - columns: table => new - { - MovieId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - MovieTitle = table.Column(type: "text", nullable: false), - Rating = table.Column(type: "text", nullable: false), - MovieDescription = table.Column(type: "text", nullable: false), - RuntimeMinutes = table.Column(type: "integer", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Movies", x => x.MovieId); - }); - - migrationBuilder.InsertData( - table: "Movies", - columns: new[] { "MovieId", "MovieDescription", "Rating", "RuntimeMinutes", "MovieTitle" }, - values: new object[,] - { - { 1, "A thief who steals corporate secrets through dream-sharing technology.", "PG-13", 148, "Inception" }, - { 2, "A computer hacker learns about the true nature of his reality.", "R", 136, "The Matrix" }, - { 3, "A team of explorers travel through a wormhole in space.", "PG-13", 169, "Interstellar" } - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Movies"); - } - } -} diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822094812_SeededThreeMovies.Designer.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822113239_RefactorSeedingAndModelsWithDateTime.Designer.cs similarity index 67% rename from api-cinema-challenge/api-cinema-challenge/Migrations/20250822094812_SeededThreeMovies.Designer.cs rename to api-cinema-challenge/api-cinema-challenge/Migrations/20250822113239_RefactorSeedingAndModelsWithDateTime.Designer.cs index e79143d4..0deabee8 100644 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822094812_SeededThreeMovies.Designer.cs +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822113239_RefactorSeedingAndModelsWithDateTime.Designer.cs @@ -1,4 +1,5 @@ // +using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; @@ -11,8 +12,8 @@ namespace api_cinema_challenge.Migrations { [DbContext(typeof(CinemaContext))] - [Migration("20250822094812_SeededThreeMovies")] - partial class SeededThreeMovies + [Migration("20250822113239_RefactorSeedingAndModelsWithDateTime")] + partial class RefactorSeedingAndModelsWithDateTime { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -33,6 +34,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("CreatedAt"); + b.Property("Email") .IsRequired() .HasColumnType("text") @@ -48,6 +53,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("CustomerPhone"); + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("UpdatedAt"); + b.HasKey("Id"); b.ToTable("Customers"); @@ -56,23 +65,29 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) new { Id = 1, + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), Email = "messi@messi.messi", Name = "Lionel Messi", - Phone = "90121413" + Phone = "90121413", + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) }, new { Id = 2, + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), Email = "ronaldo@ronaldo.ronaldo", Name = "Cristiano Ronaldo", - Phone = "90121414" + Phone = "90121414", + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) }, new { Id = 3, + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), Email = "rooney@rooney.rooney", Name = "Wayne Rooney", - Phone = "90121415" + Phone = "90121415", + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) }); }); @@ -85,6 +100,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("CreatedAt"); + b.Property("Description") .IsRequired() .HasColumnType("text") @@ -104,6 +123,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("MovieTitle"); + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("UpdatedAt"); + b.HasKey("Id"); b.ToTable("Movies"); @@ -112,26 +135,32 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) new { Id = 1, + CreatedAt = new DateTime(2010, 7, 16, 11, 1, 56, 633, DateTimeKind.Utc), Description = "A thief who steals corporate secrets through dream-sharing technology.", Rating = "PG-13", RuntimeMins = 148, - Title = "Inception" + Title = "Inception", + UpdatedAt = new DateTime(2010, 7, 16, 11, 1, 56, 633, DateTimeKind.Utc) }, new { Id = 2, + CreatedAt = new DateTime(1999, 3, 31, 11, 1, 56, 633, DateTimeKind.Utc), Description = "A computer hacker learns about the true nature of his reality.", Rating = "R", RuntimeMins = 136, - Title = "The Matrix" + Title = "The Matrix", + UpdatedAt = new DateTime(1999, 3, 31, 11, 1, 56, 633, DateTimeKind.Utc) }, new { Id = 3, + CreatedAt = new DateTime(2014, 11, 7, 11, 1, 56, 633, DateTimeKind.Utc), Description = "A team of explorers travel through a wormhole in space.", Rating = "PG-13", RuntimeMins = 169, - Title = "Interstellar" + Title = "Interstellar", + UpdatedAt = new DateTime(2014, 11, 7, 11, 1, 56, 633, DateTimeKind.Utc) }); }); #pragma warning restore 612, 618 diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822113239_RefactorSeedingAndModelsWithDateTime.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822113239_RefactorSeedingAndModelsWithDateTime.cs new file mode 100644 index 00000000..3ea90495 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822113239_RefactorSeedingAndModelsWithDateTime.cs @@ -0,0 +1,83 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace api_cinema_challenge.Migrations +{ + /// + public partial class RefactorSeedingAndModelsWithDateTime : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Customers", + columns: table => new + { + CustomerId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CustomerName = table.Column(type: "text", nullable: false), + CustomerEmail = table.Column(type: "text", nullable: false), + CustomerPhone = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Customers", x => x.CustomerId); + }); + + migrationBuilder.CreateTable( + name: "Movies", + columns: table => new + { + MovieId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MovieTitle = table.Column(type: "text", nullable: false), + Rating = table.Column(type: "text", nullable: false), + MovieDescription = table.Column(type: "text", nullable: false), + RuntimeMinutes = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Movies", x => x.MovieId); + }); + + migrationBuilder.InsertData( + table: "Customers", + columns: new[] { "CustomerId", "CreatedAt", "CustomerEmail", "CustomerName", "CustomerPhone", "UpdatedAt" }, + values: new object[,] + { + { 1, new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), "messi@messi.messi", "Lionel Messi", "90121413", new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) }, + { 2, new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), "ronaldo@ronaldo.ronaldo", "Cristiano Ronaldo", "90121414", new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) }, + { 3, new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), "rooney@rooney.rooney", "Wayne Rooney", "90121415", new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) } + }); + + migrationBuilder.InsertData( + table: "Movies", + columns: new[] { "MovieId", "CreatedAt", "MovieDescription", "Rating", "RuntimeMinutes", "MovieTitle", "UpdatedAt" }, + values: new object[,] + { + { 1, new DateTime(2010, 7, 16, 11, 1, 56, 633, DateTimeKind.Utc), "A thief who steals corporate secrets through dream-sharing technology.", "PG-13", 148, "Inception", new DateTime(2010, 7, 16, 11, 1, 56, 633, DateTimeKind.Utc) }, + { 2, new DateTime(1999, 3, 31, 11, 1, 56, 633, DateTimeKind.Utc), "A computer hacker learns about the true nature of his reality.", "R", 136, "The Matrix", new DateTime(1999, 3, 31, 11, 1, 56, 633, DateTimeKind.Utc) }, + { 3, new DateTime(2014, 11, 7, 11, 1, 56, 633, DateTimeKind.Utc), "A team of explorers travel through a wormhole in space.", "PG-13", 169, "Interstellar", new DateTime(2014, 11, 7, 11, 1, 56, 633, DateTimeKind.Utc) } + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Customers"); + + migrationBuilder.DropTable( + name: "Movies"); + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs index 5278097e..ad461a86 100644 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs @@ -1,4 +1,5 @@ // +using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -30,6 +31,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("CreatedAt"); + b.Property("Email") .IsRequired() .HasColumnType("text") @@ -45,6 +50,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("CustomerPhone"); + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("UpdatedAt"); + b.HasKey("Id"); b.ToTable("Customers"); @@ -53,23 +62,29 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = 1, + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), Email = "messi@messi.messi", Name = "Lionel Messi", - Phone = "90121413" + Phone = "90121413", + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) }, new { Id = 2, + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), Email = "ronaldo@ronaldo.ronaldo", Name = "Cristiano Ronaldo", - Phone = "90121414" + Phone = "90121414", + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) }, new { Id = 3, + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), Email = "rooney@rooney.rooney", Name = "Wayne Rooney", - Phone = "90121415" + Phone = "90121415", + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) }); }); @@ -82,6 +97,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("CreatedAt"); + b.Property("Description") .IsRequired() .HasColumnType("text") @@ -101,6 +120,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("MovieTitle"); + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("UpdatedAt"); + b.HasKey("Id"); b.ToTable("Movies"); @@ -109,26 +132,32 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = 1, + CreatedAt = new DateTime(2010, 7, 16, 11, 1, 56, 633, DateTimeKind.Utc), Description = "A thief who steals corporate secrets through dream-sharing technology.", Rating = "PG-13", RuntimeMins = 148, - Title = "Inception" + Title = "Inception", + UpdatedAt = new DateTime(2010, 7, 16, 11, 1, 56, 633, DateTimeKind.Utc) }, new { Id = 2, + CreatedAt = new DateTime(1999, 3, 31, 11, 1, 56, 633, DateTimeKind.Utc), Description = "A computer hacker learns about the true nature of his reality.", Rating = "R", RuntimeMins = 136, - Title = "The Matrix" + Title = "The Matrix", + UpdatedAt = new DateTime(1999, 3, 31, 11, 1, 56, 633, DateTimeKind.Utc) }, new { Id = 3, + CreatedAt = new DateTime(2014, 11, 7, 11, 1, 56, 633, DateTimeKind.Utc), Description = "A team of explorers travel through a wormhole in space.", Rating = "PG-13", RuntimeMins = 169, - Title = "Interstellar" + Title = "Interstellar", + UpdatedAt = new DateTime(2014, 11, 7, 11, 1, 56, 633, DateTimeKind.Utc) }); }); #pragma warning restore 612, 618 diff --git a/api-cinema-challenge/api-cinema-challenge/Models/Customer.cs b/api-cinema-challenge/api-cinema-challenge/Models/Customer.cs index d0321092..d99c842b 100644 --- a/api-cinema-challenge/api-cinema-challenge/Models/Customer.cs +++ b/api-cinema-challenge/api-cinema-challenge/Models/Customer.cs @@ -23,5 +23,11 @@ public class Customer [Column("CustomerPhone")] [Phone] public required string Phone { get; set; } + + [Column("CreatedAt")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [Column("UpdatedAt")] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } } diff --git a/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs b/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs index 0e92dd8e..5cba1e14 100644 --- a/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs +++ b/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs @@ -25,5 +25,11 @@ public class Movie [Required] [Column("RuntimeMinutes")] public int RuntimeMins { get; set; } + + [Column("CreatedAt")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [Column("UpdatedAt")] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } } diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs index 7c4eb836..6ad179b8 100644 --- a/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs +++ b/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs @@ -15,7 +15,6 @@ public Repository(CinemaContext db) _table = db.Set(); } - public async Task Add(T entity) { await _table.AddAsync(entity); @@ -26,6 +25,7 @@ public async Task Add(T entity) public async Task Delete(object id) { T entity = await _table.FindAsync(id); + if (entity == null) return null; _table.Remove(entity); await _db.SaveChangesAsync(); return entity; @@ -50,7 +50,7 @@ public async Task Update(int id, T entity) { _table.Attach(entity); _db.Entry(entity).State = EntityState.Modified; - _db.SaveChanges(); + await _db.SaveChangesAsync(); return await _table.FindAsync(id); } } diff --git a/api-cinema-challenge/api-cinema-challenge/Validators/CustomerValidators/CustomerPutValidator.cs b/api-cinema-challenge/api-cinema-challenge/Validators/CustomerValidators/CustomerPutValidator.cs index 78c9757e..7f33108e 100644 --- a/api-cinema-challenge/api-cinema-challenge/Validators/CustomerValidators/CustomerPutValidator.cs +++ b/api-cinema-challenge/api-cinema-challenge/Validators/CustomerValidators/CustomerPutValidator.cs @@ -8,17 +8,15 @@ public class CustomerPutValidator : AbstractValidator public CustomerPutValidator() { RuleFor(x => x.Name) - .NotEmpty().WithMessage("Name is required.") - .Must(name => !string.IsNullOrWhiteSpace(name)).WithMessage("Name cannot be whitespace."); + .NotEmpty().When(x => x.Name != null).WithMessage("Name cannot be empty or whitespace."); RuleFor(x => x.Email) - .NotEmpty().WithMessage("Email is required.") - .EmailAddress().WithMessage("Email must be a valid email address."); + .NotEmpty().When(x => x.Email != null).WithMessage("Email cannot be empty.") + .EmailAddress().When(x => x.Email != null).WithMessage("Email must be valid."); RuleFor(x => x.Phone) - .NotEmpty().WithMessage("Phone is required.") - .Matches(@"^\+?\d{7,15}$").WithMessage("Phone must be a valid phone number."); - + .NotEmpty().When(x => x.Phone != null).WithMessage("Phone cannot be empty.") + .Matches(@"^\+?\d{7,15}$").When(x => x.Phone != null).WithMessage("Phone must be valid."); } } } diff --git a/api-cinema-challenge/api-cinema-challenge/Validators/MovieValidators/MoviePutValidator.cs b/api-cinema-challenge/api-cinema-challenge/Validators/MovieValidators/MoviePutValidator.cs index 766ae912..48350adc 100644 --- a/api-cinema-challenge/api-cinema-challenge/Validators/MovieValidators/MoviePutValidator.cs +++ b/api-cinema-challenge/api-cinema-challenge/Validators/MovieValidators/MoviePutValidator.cs @@ -8,17 +8,27 @@ public class MoviePutValidator : AbstractValidator public MoviePutValidator() { RuleFor(m => m.Title) - .NotEmpty().WithMessage("Title is required."); + .NotEmpty() + .When(m => m.Title != null) + .WithMessage("Title cannot be empty or whitespace."); RuleFor(m => m.Rating) - .NotEmpty().WithMessage("Rating is required."); + .NotEmpty() + .When(m => m.Rating != null) + .WithMessage("Rating cannot be empty or whitespace."); RuleFor(m => m.Description) - .NotEmpty().WithMessage("Description is required.") - .MaximumLength(500).WithMessage("Description must be at most 500 characters."); + .NotEmpty() + .When(m => m.Description != null) + .WithMessage("Description cannot be empty.") + .MaximumLength(500) + .When(m => m.Description != null) + .WithMessage("Description must be at most 500 characters."); RuleFor(m => m.RuntimeMins) - .GreaterThan(0).WithMessage("Runtime must be greater than 0 minutes."); + .GreaterThan(0) + .When(m => m.RuntimeMins != null) + .WithMessage("Runtime must be greater than 0 minutes."); } } } From 655582c4b3ea3c23968b779e9ce0fb6be304608a Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 15:13:55 +0200 Subject: [PATCH 19/28] finished implemetning screening endpoints for movies --- .../DTOs/ScreeningDTOs/ScreeningDto.cs | 12 ++ .../DTOs/ScreeningDTOs/ScreeningPostDto.cs | 9 ++ .../Data/CinemaContext.cs | 69 +---------- .../api-cinema-challenge/Data/ModelSeeder.cs | 107 ++++++++++++++++++ .../Endpoints/MovieEndpoints.cs | 89 +++++++++++++++ ...InitialScreeningAndTimestamps.Designer.cs} | 86 +++++++++++++- ...22130044_InitialScreeningAndTimestamps.cs} | 44 ++++++- .../Migrations/CinemaContextModelSnapshot.cs | 82 ++++++++++++++ .../api-cinema-challenge/Models/Screening.cs | 36 ++++++ .../api-cinema-challenge/Program.cs | 5 + .../ScreeningPostValidator.cs | 21 ++++ 11 files changed, 492 insertions(+), 68 deletions(-) create mode 100644 api-cinema-challenge/api-cinema-challenge/DTOs/ScreeningDTOs/ScreeningDto.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/DTOs/ScreeningDTOs/ScreeningPostDto.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/Data/ModelSeeder.cs rename api-cinema-challenge/api-cinema-challenge/Migrations/{20250822113239_RefactorSeedingAndModelsWithDateTime.Designer.cs => 20250822130044_InitialScreeningAndTimestamps.Designer.cs} (65%) rename api-cinema-challenge/api-cinema-challenge/Migrations/{20250822113239_RefactorSeedingAndModelsWithDateTime.cs => 20250822130044_InitialScreeningAndTimestamps.cs} (63%) create mode 100644 api-cinema-challenge/api-cinema-challenge/Models/Screening.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/Validators/ScreeningValidators/ScreeningPostValidator.cs diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/ScreeningDTOs/ScreeningDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/ScreeningDTOs/ScreeningDto.cs new file mode 100644 index 00000000..57ed20f8 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/ScreeningDTOs/ScreeningDto.cs @@ -0,0 +1,12 @@ +namespace api_cinema_challenge.DTOs.ScreeningDTOs +{ + public class ScreeningDto + { + public int Id { get; set; } + public int ScreenNumber { get; set; } + public int Capacity { get; set; } + public DateTime StartsAt { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/ScreeningDTOs/ScreeningPostDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/ScreeningDTOs/ScreeningPostDto.cs new file mode 100644 index 00000000..30d505ea --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/ScreeningDTOs/ScreeningPostDto.cs @@ -0,0 +1,9 @@ +namespace api_cinema_challenge.DTOs.ScreeningDTOs +{ + public class ScreeningPostDto + { + public int ScreenNumber { get; set; } + public int Capacity { get; set; } + public DateTime StartsAt { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs index 90aac7f9..c4f13575 100644 --- a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs +++ b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs @@ -10,73 +10,12 @@ public CinemaContext(DbContextOptions options) : base(options) { protected override void OnModelCreating(ModelBuilder modelBuilder) { - - // Seed data for the database - modelBuilder.Entity().HasData( - new Customer - { - Id = 1, - Name = "Lionel Messi", - Email = "messi@messi.messi", - Phone = "90121413", - CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), - UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) - }, - new Customer - { - Id = 2, - Name = "Cristiano Ronaldo", - Email = "ronaldo@ronaldo.ronaldo", - Phone = "90121414", - CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), - UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) - }, - new Customer - { - Id = 3, - Name = "Wayne Rooney", - Email = "rooney@rooney.rooney", - Phone = "90121415", - CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), - UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) - } - ); - - modelBuilder.Entity().HasData( - new Movie - { - Id = 1, - Title = "Inception", - Rating = "PG-13", - Description = "A thief who steals corporate secrets through dream-sharing technology.", - RuntimeMins = 148, - CreatedAt = new DateTime(2010, 7, 16, 11, 1, 56, 633, DateTimeKind.Utc), - UpdatedAt = new DateTime(2010, 7, 16, 11, 1, 56, 633, DateTimeKind.Utc) - }, - new Movie - { - Id = 2, - Title = "The Matrix", - Rating = "R", - Description = "A computer hacker learns about the true nature of his reality.", - RuntimeMins = 136, - CreatedAt = new DateTime(1999, 3, 31, 11, 1, 56, 633, DateTimeKind.Utc), - UpdatedAt = new DateTime(1999, 3, 31, 11, 1, 56, 633, DateTimeKind.Utc) - }, - new Movie - { - Id = 3, - Title = "Interstellar", - Rating = "PG-13", - Description = "A team of explorers travel through a wormhole in space.", - RuntimeMins = 169, - CreatedAt = new DateTime(2014, 11, 7, 11, 1, 56, 633, DateTimeKind.Utc), - UpdatedAt = new DateTime(2014, 11, 7, 11, 1, 56, 633, DateTimeKind.Utc) - } - ); - + ModelSeeder.Seed(modelBuilder); // seed initial data } public DbSet Customers { get; set; } + public DbSet Movies { get; set; } + public DbSet Screenings { get; set; } + } } diff --git a/api-cinema-challenge/api-cinema-challenge/Data/ModelSeeder.cs b/api-cinema-challenge/api-cinema-challenge/Data/ModelSeeder.cs new file mode 100644 index 00000000..5ff16838 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Data/ModelSeeder.cs @@ -0,0 +1,107 @@ +using api_cinema_challenge.Models; +using Microsoft.EntityFrameworkCore; + +namespace api_cinema_challenge.Data +{ + public static class ModelSeeder + { + public static void Seed(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasData( + new Customer + { + Id = 1, + Name = "Lionel Messi", + Email = "messi@messi.messi", + Phone = "90121413", + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) + }, + new Customer + { + Id = 2, + Name = "Cristiano Ronaldo", + Email = "ronaldo@ronaldo.ronaldo", + Phone = "90121414", + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) + }, + new Customer + { + Id = 3, + Name = "Wayne Rooney", + Email = "rooney@rooney.rooney", + Phone = "90121415", + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) + } + ); + + modelBuilder.Entity().HasData( + new Movie + { + Id = 1, + Title = "Inception", + Rating = "PG-13", + Description = "A thief who steals corporate secrets through dream-sharing technology.", + RuntimeMins = 148, + CreatedAt = new DateTime(2010, 7, 16, 11, 1, 56, 633, DateTimeKind.Utc), + UpdatedAt = new DateTime(2010, 7, 16, 11, 1, 56, 633, DateTimeKind.Utc) + }, + new Movie + { + Id = 2, + Title = "The Matrix", + Rating = "R", + Description = "A computer hacker learns about the true nature of his reality.", + RuntimeMins = 136, + CreatedAt = new DateTime(1999, 3, 31, 11, 1, 56, 633, DateTimeKind.Utc), + UpdatedAt = new DateTime(1999, 3, 31, 11, 1, 56, 633, DateTimeKind.Utc) + }, + new Movie + { + Id = 3, + Title = "Interstellar", + Rating = "PG-13", + Description = "A team of explorers travel through a wormhole in space.", + RuntimeMins = 169, + CreatedAt = new DateTime(2014, 11, 7, 11, 1, 56, 633, DateTimeKind.Utc), + UpdatedAt = new DateTime(2014, 11, 7, 11, 1, 56, 633, DateTimeKind.Utc) + } + ); + + modelBuilder.Entity().HasData( + new Screening + { + Id = 1, + ScreenNumber = 5, + Capacity = 40, + StartsAt = new DateTime(2023, 3, 19, 11, 30, 0, DateTimeKind.Utc), + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + MovieId = 1 + }, + new Screening + { + Id = 2, + ScreenNumber = 3, + Capacity = 60, + StartsAt = new DateTime(2023, 3, 20, 15, 0, 0, DateTimeKind.Utc), + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + MovieId = 2 + }, + new Screening + { + Id = 3, + ScreenNumber = 7, + Capacity = 30, + StartsAt = new DateTime(2023, 3, 21, 18, 45, 0, DateTimeKind.Utc), + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + MovieId = 3 + } + ); + } + } +} \ No newline at end of file diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs index 97b3eb2d..5d30bcd6 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs @@ -1,4 +1,5 @@ using api_cinema_challenge.DTOs.MovieDTOs; +using api_cinema_challenge.DTOs.ScreeningDTOs; using api_cinema_challenge.Models; using api_cinema_challenge.Repository; using FluentValidation; @@ -18,6 +19,11 @@ public static void ConfigureMovieEndpoint(this WebApplication app) movies.MapDelete("/{id}", DeleteMovie); movies.MapPut("/{id}", UpdateMovie); + // screening endpoints + movies.MapGet("/{id}/screenings", GetScreeningsForMovie); + movies.MapPost("/{id}/screenings", AddScreeningForMovie); + movies.MapGet("/{movieId}/screenings/{screeningId}", GetScreeningForMovie); + } [ProducesResponseType(StatusCodes.Status200OK)] @@ -151,5 +157,88 @@ public static async Task UpdateMovie(int id, IRepository reposit return TypedResults.Created(location, movieDto); } + + // screening endpoints + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public static async Task GetScreeningsForMovie(int id, IRepository screeningRepository) + { + var screenings = await screeningRepository.GetAll(); + var filteredScreenings = screenings.Where(s => s.MovieId == id).ToList(); + + if (!filteredScreenings.Any()) + return TypedResults.NotFound($"No screenings found for movie with id {id}."); + + var screeningDtos = filteredScreenings.Select(s => new ScreeningDto + { + Id = s.Id, + ScreenNumber = s.ScreenNumber, + Capacity = s.Capacity, + StartsAt = s.StartsAt, + CreatedAt = s.CreatedAt, + UpdatedAt = s.UpdatedAt + }).ToList(); + + return TypedResults.Ok(screeningDtos); + } + + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public static async Task AddScreeningForMovie(int id, IRepository screeningRepository, [FromBody] ScreeningPostDto model, IValidator validator, HttpRequest request) + { + if (model == null) { return TypedResults.BadRequest("Invalid screening data"); } + var validationResult = await validator.ValidateAsync(model); + + if (!validationResult.IsValid) + { + var errors = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)); + return TypedResults.BadRequest(errors); + } + + var newScreening = new Screening + { + ScreenNumber = model.ScreenNumber, + Capacity = model.Capacity, + StartsAt = model.StartsAt, + MovieId = id + }; + + var addedScreening = await screeningRepository.Add(newScreening); + + var screeningDto = new ScreeningDto + { + Id = addedScreening.Id, + ScreenNumber = addedScreening.ScreenNumber, + Capacity = addedScreening.Capacity, + StartsAt = addedScreening.StartsAt, + CreatedAt = addedScreening.CreatedAt, + UpdatedAt = addedScreening.UpdatedAt + }; + + var baseUrl = $"{request.Scheme}://{request.Host}{request.PathBase}"; + var location = $"{baseUrl}/movies/{id}/screenings/{addedScreening.Id}"; + return TypedResults.Created(location, screeningDto); + } + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public static async Task GetScreeningForMovie(int movieId, int screeningId, IRepository screeningRepository) + { + var screening = await screeningRepository.GetById(screeningId); + if (screening == null || screening.MovieId != movieId) + return TypedResults.NotFound($"Screening with id {screeningId} for movie {movieId} not found."); + + var screeningDto = new ScreeningDto + { + Id = screening.Id, + ScreenNumber = screening.ScreenNumber, + Capacity = screening.Capacity, + StartsAt = screening.StartsAt, + CreatedAt = screening.CreatedAt, + UpdatedAt = screening.UpdatedAt + }; + + return TypedResults.Ok(screeningDto); + } } } diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822113239_RefactorSeedingAndModelsWithDateTime.Designer.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822130044_InitialScreeningAndTimestamps.Designer.cs similarity index 65% rename from api-cinema-challenge/api-cinema-challenge/Migrations/20250822113239_RefactorSeedingAndModelsWithDateTime.Designer.cs rename to api-cinema-challenge/api-cinema-challenge/Migrations/20250822130044_InitialScreeningAndTimestamps.Designer.cs index 0deabee8..e7db6cc5 100644 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822113239_RefactorSeedingAndModelsWithDateTime.Designer.cs +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822130044_InitialScreeningAndTimestamps.Designer.cs @@ -12,8 +12,8 @@ namespace api_cinema_challenge.Migrations { [DbContext(typeof(CinemaContext))] - [Migration("20250822113239_RefactorSeedingAndModelsWithDateTime")] - partial class RefactorSeedingAndModelsWithDateTime + [Migration("20250822130044_InitialScreeningAndTimestamps")] + partial class InitialScreeningAndTimestamps { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -163,6 +163,88 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) UpdatedAt = new DateTime(2014, 11, 7, 11, 1, 56, 633, DateTimeKind.Utc) }); }); + + modelBuilder.Entity("api_cinema_challenge.Models.Screening", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("ScreeningId"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Capacity") + .HasColumnType("integer") + .HasColumnName("Capacity"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("CreatedAt"); + + b.Property("MovieId") + .HasColumnType("integer"); + + b.Property("ScreenNumber") + .HasColumnType("integer") + .HasColumnName("ScreenNumber"); + + b.Property("StartsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("StartsAt"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("UpdatedAt"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.ToTable("Screenings"); + + b.HasData( + new + { + Id = 1, + Capacity = 40, + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + MovieId = 1, + ScreenNumber = 5, + StartsAt = new DateTime(2023, 3, 19, 11, 30, 0, 0, DateTimeKind.Utc), + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) + }, + new + { + Id = 2, + Capacity = 60, + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + MovieId = 2, + ScreenNumber = 3, + StartsAt = new DateTime(2023, 3, 20, 15, 0, 0, 0, DateTimeKind.Utc), + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) + }, + new + { + Id = 3, + Capacity = 30, + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + MovieId = 3, + ScreenNumber = 7, + StartsAt = new DateTime(2023, 3, 21, 18, 45, 0, 0, DateTimeKind.Utc), + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) + }); + }); + + modelBuilder.Entity("api_cinema_challenge.Models.Screening", b => + { + b.HasOne("api_cinema_challenge.Models.Movie", "Movie") + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Movie"); + }); #pragma warning restore 612, 618 } } diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822113239_RefactorSeedingAndModelsWithDateTime.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822130044_InitialScreeningAndTimestamps.cs similarity index 63% rename from api-cinema-challenge/api-cinema-challenge/Migrations/20250822113239_RefactorSeedingAndModelsWithDateTime.cs rename to api-cinema-challenge/api-cinema-challenge/Migrations/20250822130044_InitialScreeningAndTimestamps.cs index 3ea90495..8f716bd1 100644 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822113239_RefactorSeedingAndModelsWithDateTime.cs +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822130044_InitialScreeningAndTimestamps.cs @@ -9,7 +9,7 @@ namespace api_cinema_challenge.Migrations { /// - public partial class RefactorSeedingAndModelsWithDateTime : Migration + public partial class InitialScreeningAndTimestamps : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -49,6 +49,30 @@ protected override void Up(MigrationBuilder migrationBuilder) table.PrimaryKey("PK_Movies", x => x.MovieId); }); + migrationBuilder.CreateTable( + name: "Screenings", + columns: table => new + { + ScreeningId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ScreenNumber = table.Column(type: "integer", nullable: false), + Capacity = table.Column(type: "integer", nullable: false), + StartsAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + MovieId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Screenings", x => x.ScreeningId); + table.ForeignKey( + name: "FK_Screenings_Movies_MovieId", + column: x => x.MovieId, + principalTable: "Movies", + principalColumn: "MovieId", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.InsertData( table: "Customers", columns: new[] { "CustomerId", "CreatedAt", "CustomerEmail", "CustomerName", "CustomerPhone", "UpdatedAt" }, @@ -68,6 +92,21 @@ protected override void Up(MigrationBuilder migrationBuilder) { 2, new DateTime(1999, 3, 31, 11, 1, 56, 633, DateTimeKind.Utc), "A computer hacker learns about the true nature of his reality.", "R", 136, "The Matrix", new DateTime(1999, 3, 31, 11, 1, 56, 633, DateTimeKind.Utc) }, { 3, new DateTime(2014, 11, 7, 11, 1, 56, 633, DateTimeKind.Utc), "A team of explorers travel through a wormhole in space.", "PG-13", 169, "Interstellar", new DateTime(2014, 11, 7, 11, 1, 56, 633, DateTimeKind.Utc) } }); + + migrationBuilder.InsertData( + table: "Screenings", + columns: new[] { "ScreeningId", "Capacity", "CreatedAt", "MovieId", "ScreenNumber", "StartsAt", "UpdatedAt" }, + values: new object[,] + { + { 1, 40, new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), 1, 5, new DateTime(2023, 3, 19, 11, 30, 0, 0, DateTimeKind.Utc), new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) }, + { 2, 60, new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), 2, 3, new DateTime(2023, 3, 20, 15, 0, 0, 0, DateTimeKind.Utc), new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) }, + { 3, 30, new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), 3, 7, new DateTime(2023, 3, 21, 18, 45, 0, 0, DateTimeKind.Utc), new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) } + }); + + migrationBuilder.CreateIndex( + name: "IX_Screenings_MovieId", + table: "Screenings", + column: "MovieId"); } /// @@ -76,6 +115,9 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropTable( name: "Customers"); + migrationBuilder.DropTable( + name: "Screenings"); + migrationBuilder.DropTable( name: "Movies"); } diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs index ad461a86..453916ad 100644 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs @@ -160,6 +160,88 @@ protected override void BuildModel(ModelBuilder modelBuilder) UpdatedAt = new DateTime(2014, 11, 7, 11, 1, 56, 633, DateTimeKind.Utc) }); }); + + modelBuilder.Entity("api_cinema_challenge.Models.Screening", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("ScreeningId"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Capacity") + .HasColumnType("integer") + .HasColumnName("Capacity"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("CreatedAt"); + + b.Property("MovieId") + .HasColumnType("integer"); + + b.Property("ScreenNumber") + .HasColumnType("integer") + .HasColumnName("ScreenNumber"); + + b.Property("StartsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("StartsAt"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("UpdatedAt"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.ToTable("Screenings"); + + b.HasData( + new + { + Id = 1, + Capacity = 40, + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + MovieId = 1, + ScreenNumber = 5, + StartsAt = new DateTime(2023, 3, 19, 11, 30, 0, 0, DateTimeKind.Utc), + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) + }, + new + { + Id = 2, + Capacity = 60, + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + MovieId = 2, + ScreenNumber = 3, + StartsAt = new DateTime(2023, 3, 20, 15, 0, 0, 0, DateTimeKind.Utc), + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) + }, + new + { + Id = 3, + Capacity = 30, + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + MovieId = 3, + ScreenNumber = 7, + StartsAt = new DateTime(2023, 3, 21, 18, 45, 0, 0, DateTimeKind.Utc), + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) + }); + }); + + modelBuilder.Entity("api_cinema_challenge.Models.Screening", b => + { + b.HasOne("api_cinema_challenge.Models.Movie", "Movie") + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Movie"); + }); #pragma warning restore 612, 618 } } diff --git a/api-cinema-challenge/api-cinema-challenge/Models/Screening.cs b/api-cinema-challenge/api-cinema-challenge/Models/Screening.cs new file mode 100644 index 00000000..244b7257 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Screening.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace api_cinema_challenge.Models +{ + [Table("Screenings")] + public class Screening + { + [Key] + [Column("ScreeningId")] + public int Id { get; set; } + + [Required] + [Column("ScreenNumber")] + public int ScreenNumber { get; set; } + + [Required] + [Column("Capacity")] + public int Capacity { get; set; } + + [Required] + [Column("StartsAt")] + public DateTime StartsAt { get; set; } + + [Column("CreatedAt")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [Column("UpdatedAt")] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + // Foreign key to Movie + [ForeignKey("Movie")] + public int MovieId { get; set; } + public Movie Movie { get; set; } = null!; + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Program.cs b/api-cinema-challenge/api-cinema-challenge/Program.cs index 7399d073..5749c9e4 100644 --- a/api-cinema-challenge/api-cinema-challenge/Program.cs +++ b/api-cinema-challenge/api-cinema-challenge/Program.cs @@ -1,11 +1,13 @@ using api_cinema_challenge.Data; using api_cinema_challenge.DTOs.CustomerDTOs; using api_cinema_challenge.DTOs.MovieDTOs; +using api_cinema_challenge.DTOs.ScreeningDTOs; using api_cinema_challenge.Endpoints; using api_cinema_challenge.Models; using api_cinema_challenge.Repository; using api_cinema_challenge.Validators.CustomerValidators; using api_cinema_challenge.Validators.MovieValidators; +using api_cinema_challenge.Validators.ScreeningValidators; using FluentValidation; using Microsoft.EntityFrameworkCore; using Scalar.AspNetCore; @@ -24,11 +26,14 @@ builder.Services.AddScoped, Repository>(); builder.Services.AddScoped, Repository>(); +builder.Services.AddScoped, Repository>(); // Register validators as services builder.Services.AddScoped, CustomerPostValidator>(); builder.Services.AddScoped, CustomerPutValidator>(); builder.Services.AddScoped, MoviePostValidator>(); builder.Services.AddScoped, MoviePutValidator>(); +builder.Services.AddScoped, ScreeningPostValidator>(); + var app = builder.Build(); diff --git a/api-cinema-challenge/api-cinema-challenge/Validators/ScreeningValidators/ScreeningPostValidator.cs b/api-cinema-challenge/api-cinema-challenge/Validators/ScreeningValidators/ScreeningPostValidator.cs new file mode 100644 index 00000000..752d63dd --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Validators/ScreeningValidators/ScreeningPostValidator.cs @@ -0,0 +1,21 @@ +using api_cinema_challenge.DTOs.MovieDTOs; +using api_cinema_challenge.DTOs.ScreeningDTOs; +using FluentValidation; + +namespace api_cinema_challenge.Validators.ScreeningValidators +{ + public class ScreeningPostValidator : AbstractValidator + { + public ScreeningPostValidator() + { + RuleFor(x => x.ScreenNumber) + .GreaterThan(0).WithMessage("Screen number must be greater than 0."); + + RuleFor(x => x.Capacity) + .GreaterThan(0).WithMessage("Capacity must be greater than 0."); + + RuleFor(x => x.StartsAt) + .NotEmpty().WithMessage("StartsAt is required."); + } + } +} From 622a2cb143be8caad966c235f81aa8e435b13542 Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 15:44:12 +0200 Subject: [PATCH 20/28] implemented extention for customer endpoints: added status message and data section --- .../Endpoints/CustomerEndpoints.cs | 122 +++++++++++++++--- 1 file changed, 103 insertions(+), 19 deletions(-) diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs index d36f3ee2..29d749f3 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs @@ -26,7 +26,15 @@ public static void ConfigureCustomerEndpoint(this WebApplication app) public static async Task GetCustomerById(int id, IRepository repository) { var targetCustomer = await repository.GetById(id); - if (targetCustomer == null) { return TypedResults.NotFound($"Customer with id {id} not found."); } + if (targetCustomer == null) + { + var errorResponse = new + { + status = "error", + message = $"Customer with id {id} not found." + }; + return TypedResults.NotFound(errorResponse); + } var customerDto = new CustomerDto { @@ -38,7 +46,13 @@ public static async Task GetCustomerById(int id, IRepository UpdatedAt = targetCustomer.UpdatedAt }; - return TypedResults.Ok(customerDto); + var response = new + { + status = "success", + data = customerDto + }; + + return TypedResults.Ok(response); } [ProducesResponseType(StatusCodes.Status200OK)] @@ -46,11 +60,17 @@ public static async Task GetCustomerById(int id, IRepository public static async Task GetCustomers(IRepository repository) { var customers = await repository.GetAll(); - if (customers == null || !customers.Any()) { return Results.NotFound("No customers found."); } - - var customerDto = customers.Select(c => new CustomerDto + if (customers == null || !customers.Any()) { - Id = c.Id, + var errorResponse = new + { + status = "error", + message = "No customers found." + }; + return TypedResults.NotFound(errorResponse); + } + + var customerDto = customers.Select(c => new CustomerDto { Id = c.Id, Name = c.Name, Email = c.Email, Phone = c.Phone, @@ -58,7 +78,13 @@ public static async Task GetCustomers(IRepository repository) UpdatedAt = c.UpdatedAt }).ToList(); - return TypedResults.Ok(customerDto); + var response = new + { + status = "success", + data = customerDto + }; + + return TypedResults.Ok(response); } @@ -71,8 +97,12 @@ public static async Task AddCustomer(IRepository repository, var validationResult = await validator.ValidateAsync(model); if (!validationResult.IsValid) { - var errors = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)); - return TypedResults.BadRequest(errors); + var errorResponse = new + { + status = "error", + message = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)) + }; + return TypedResults.BadRequest(errorResponse); } var newCustomer = new Customer { Name = model.Name, Email = model.Email, Phone = model.Phone }; @@ -80,9 +110,15 @@ public static async Task AddCustomer(IRepository repository, var customerDto = new CustomerDto { Id = addedCustomer.Id, Name = addedCustomer.Name, Email=addedCustomer.Email, Phone = addedCustomer.Phone, CreatedAt = addedCustomer.CreatedAt, UpdatedAt = addedCustomer.UpdatedAt }; + var response = new + { + status = "success", + data = customerDto + }; + var baseUrl = $"{request.Scheme}://{request.Host}{request.PathBase}"; var location = $"{baseUrl}/customers/{addedCustomer.Id}"; - return TypedResults.Created(location, customerDto); + return TypedResults.Created(location, response); } @@ -91,7 +127,15 @@ public static async Task AddCustomer(IRepository repository, public static async Task DeleteCustomer(int id, IRepository repository) { var targetCustomer = await repository.GetById(id); - if (targetCustomer == null) { return TypedResults.NotFound($"Customer with id {id} not found."); } + if (targetCustomer == null) + { + var errorResponse = new + { + status = "error", + message = $"Customer with id {id} not found." + }; + return TypedResults.NotFound(errorResponse); + } var deletedCustomer = await repository.Delete(id); @@ -105,7 +149,13 @@ public static async Task DeleteCustomer(int id, IRepository r UpdatedAt = deletedCustomer.UpdatedAt }; - return TypedResults.Ok(customerDto); + var response = new + { + status = "success", + data = customerDto + }; + + return TypedResults.Ok(response); } [ProducesResponseType(StatusCodes.Status201Created)] @@ -115,22 +165,50 @@ public static async Task UpdateCustomer(int id, IRepository r { // check if the customer we want to update exists var existingCustomer = await repository.GetById(id); - if (existingCustomer == null) { return TypedResults.NotFound($"The customer you want to update with ID {id} does not exist"); } - - if (model == null) { return TypedResults.BadRequest("Invalid customer data"); } + if (existingCustomer == null) + { + var errorResponse = new + { + status = "error", + message = $"The customer you want to update with ID {id} does not exist" + }; + return TypedResults.NotFound(errorResponse); + } + + if (model == null) + { + var errorResponse = new + { + status = "error", + message = "Invalid customer data" + }; + return TypedResults.BadRequest(errorResponse); + } var validationResult = await validator.ValidateAsync(model); if (!validationResult.IsValid) { - var errors = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)); - return TypedResults.BadRequest(errors); + var errorResponse = new + { + status = "error", + message = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)) + }; + return TypedResults.BadRequest(errorResponse); } // check if the new name already exists for another customer var allCustomers = await repository.GetAll(); var duplicateNameCustomer = allCustomers.FirstOrDefault( c => c.Name == model.Name && c.Id != id); - if (duplicateNameCustomer != null) { return TypedResults.BadRequest($"A customer with the name '{model.Name}' already exists."); } + if (duplicateNameCustomer != null) + { + var errorResponse = new + { + status = "error", + message = $"A customer with the name '{model.Name}' already exists." + }; + return TypedResults.BadRequest(errorResponse); + } // update the customer if (!string.IsNullOrWhiteSpace(model.Name)) existingCustomer.Name = model.Name; @@ -145,9 +223,15 @@ public static async Task UpdateCustomer(int id, IRepository r // generate respone dto var customerDto = new CustomerDto { Id = updatedCustomer.Id, Name = updatedCustomer.Name, Email = updatedCustomer.Email, Phone = updatedCustomer.Phone }; + var response = new + { + status = "success", + data = customerDto + }; + var baseUrl = $"{request.Scheme}://{request.Host}{request.PathBase}"; var location = $"{baseUrl}/customers/{updatedCustomer.Id}"; - return TypedResults.Created(location, customerDto); + return TypedResults.Created(location, response); } } From 656650ce1d8eb9769f7d3175696a53705ab67ee7 Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 15:59:48 +0200 Subject: [PATCH 21/28] Updated all movie and screening endpoints to return consistent response objects: - Success responses now use { status: "success", data: ... } else suited error message --- .../Endpoints/MovieEndpoints.cs | 167 +++++++++++++++--- 1 file changed, 141 insertions(+), 26 deletions(-) diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs index 5d30bcd6..d329ed17 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs @@ -31,7 +31,15 @@ public static void ConfigureMovieEndpoint(this WebApplication app) public static async Task GetMovieById(int id, IRepository repository) { var targetMovie = await repository.GetById(id); - if (targetMovie == null) { return TypedResults.NotFound($"Movie with id {id} not found."); } + if (targetMovie == null) + { + var errorResponse = new + { + status = "error", + message = $"Movie with id {id} not found." + }; + return TypedResults.NotFound(errorResponse); + } var movieDto = new MovieDto { @@ -44,7 +52,13 @@ public static async Task GetMovieById(int id, IRepository reposi UpdatedAt = targetMovie.UpdatedAt }; - return TypedResults.Ok(movieDto); + var response = new + { + status = "success", + data = movieDto + }; + + return TypedResults.Ok(response); } [ProducesResponseType(StatusCodes.Status200OK)] @@ -52,7 +66,16 @@ public static async Task GetMovieById(int id, IRepository reposi public static async Task GetMovies(IRepository repository) { var movies = await repository.GetAll(); - if (movies == null || !movies.Any()) { return Results.NotFound("No movies found."); } + if (movies == null || !movies.Any()) + { + var errorResponse = new + { + status = "error", + message = "No movies found." + }; + return TypedResults.NotFound(errorResponse); + } + var movieDtos = movies.Select(m => new MovieDto { Id = m.Id, @@ -64,20 +87,35 @@ public static async Task GetMovies(IRepository repository) UpdatedAt = m.UpdatedAt }).ToList(); - return TypedResults.Ok(movieDtos); + var response = new + { + status = "success", + data = movieDtos + }; + + return TypedResults.Ok(response); + } [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public static async Task AddMovie(IRepository repository, [FromBody] MoviePostDto model, HttpRequest request, IValidator validator) { - if (model == null) { return TypedResults.BadRequest("Invalid movie data"); } + if (model == null) + { + var errorResponse = new { status = "error", message = "Invalid movie data" }; + return TypedResults.BadRequest(errorResponse); + } var validationResult = await validator.ValidateAsync(model); if (!validationResult.IsValid) { - var errors = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)); - return TypedResults.BadRequest(errors); + var errorResponse = new + { + status = "error", + message = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)) + }; + return TypedResults.BadRequest(errorResponse); } var newMovie = new Movie { Title = model.Title, Rating = model.Rating, Description = model.Description, RuntimeMins = model.RuntimeMins }; @@ -85,9 +123,15 @@ public static async Task AddMovie(IRepository repository, [FromB var movieDto = new MovieDto { Id = addedMovie.Id, Title = addedMovie.Title, Rating = addedMovie.Rating, Description = addedMovie.Description, RuntimeMins = addedMovie.RuntimeMins, CreatedAt = addedMovie.CreatedAt, UpdatedAt = addedMovie.UpdatedAt }; + var response = new + { + status = "success", + data = movieDto + }; + var baseUrl = $"{request.Scheme}://{request.Host}{request.PathBase}"; var location = $"{baseUrl}/movies/{addedMovie.Id}"; - return TypedResults.Created(location, movieDto); + return TypedResults.Created(location, response); } @@ -96,8 +140,11 @@ public static async Task AddMovie(IRepository repository, [FromB public static async Task DeleteMovie(int id, IRepository repository) { var targetMovie = await repository.GetById(id); - if (targetMovie == null) { return TypedResults.NotFound($"Movie with id {id} not found."); } - + if (targetMovie == null) + { + var errorResponse = new { status = "error", message = $"Movie with id {id} not found." }; + return TypedResults.NotFound(errorResponse); + } var deletedMovie = await repository.Delete(id); var movieDto = new MovieDto @@ -111,7 +158,13 @@ public static async Task DeleteMovie(int id, IRepository reposit UpdatedAt = deletedMovie.UpdatedAt }; - return TypedResults.Ok(movieDto); + var response = new + { + status = "success", + data = movieDto + }; + + return TypedResults.Ok(response); } [ProducesResponseType(StatusCodes.Status201Created)] @@ -121,22 +174,38 @@ public static async Task UpdateMovie(int id, IRepository reposit { // check if the movie we want to update exists var existingMovie = await repository.GetById(id); - if (existingMovie == null) { return TypedResults.NotFound($"The movie you want to update with ID {id} does not exist"); } + if (existingMovie == null) + { + var errorResponse = new { status = "error", message = $"The movie you want to update with ID {id} does not exist" }; + return TypedResults.NotFound(errorResponse); + } - if (model == null) { return TypedResults.BadRequest("Invalid movie data"); } + if (model == null) + { + var errorResponse = new { status = "error", message = "Invalid movie data" }; + return TypedResults.BadRequest(errorResponse); + } var validationResult = await validator.ValidateAsync(model); if (!validationResult.IsValid) { - var errors = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)); - return TypedResults.BadRequest(errors); + var errorResponse = new + { + status = "error", + message = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)) + }; + return TypedResults.BadRequest(errorResponse); } // check if the new title already exists for another movie var allMovies = await repository.GetAll(); var duplicateTitleMovie = allMovies.FirstOrDefault( m => m.Title == model.Title && m.Id != id); - if (duplicateTitleMovie != null) { return TypedResults.BadRequest($"A movie with the title '{model.Title}' already exists."); } + if (duplicateTitleMovie != null) + { + var errorResponse = new { status = "error", message = $"A movie with the title '{model.Title}' already exists." }; + return TypedResults.BadRequest(errorResponse); + } // update the movie if (!string.IsNullOrWhiteSpace(model.Title)) existingMovie.Title = model.Title; @@ -152,9 +221,15 @@ public static async Task UpdateMovie(int id, IRepository reposit // generate respone dto var movieDto = new MovieDto { Id = updatedMovie.Id, Title = updatedMovie.Title, Rating = updatedMovie.Rating, Description = updatedMovie.Description, RuntimeMins = updatedMovie.RuntimeMins, CreatedAt = updatedMovie.CreatedAt, UpdatedAt = updatedMovie.UpdatedAt }; + var response = new + { + status = "success", + data = movieDto + }; + var baseUrl = $"{request.Scheme}://{request.Host}{request.PathBase}"; var location = $"{baseUrl}/movies/{updatedMovie.Id}"; - return TypedResults.Created(location, movieDto); + return TypedResults.Created(location, response); } @@ -167,7 +242,14 @@ public static async Task GetScreeningsForMovie(int id, IRepository s.MovieId == id).ToList(); if (!filteredScreenings.Any()) - return TypedResults.NotFound($"No screenings found for movie with id {id}."); + { + var errorResponse = new + { + status = "error", + message = $"No screenings found for movie with id {id}." + }; + return TypedResults.NotFound(errorResponse); + } var screeningDtos = filteredScreenings.Select(s => new ScreeningDto { @@ -179,20 +261,34 @@ public static async Task GetScreeningsForMovie(int id, IRepository AddScreeningForMovie(int id, IRepository screeningRepository, [FromBody] ScreeningPostDto model, IValidator validator, HttpRequest request) { - if (model == null) { return TypedResults.BadRequest("Invalid screening data"); } - var validationResult = await validator.ValidateAsync(model); + if (model == null) + { + var errorResponse = new { status = "error", message = "Invalid screening data" }; + return TypedResults.BadRequest(errorResponse); + } + var validationResult = await validator.ValidateAsync(model); if (!validationResult.IsValid) { - var errors = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)); - return TypedResults.BadRequest(errors); + var errorResponse = new + { + status = "error", + message = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)) + }; + return TypedResults.BadRequest(errorResponse); } var newScreening = new Screening @@ -215,9 +311,15 @@ public static async Task AddScreeningForMovie(int id, IRepository GetScreeningForMovie(int movieId, int screenin { var screening = await screeningRepository.GetById(screeningId); if (screening == null || screening.MovieId != movieId) - return TypedResults.NotFound($"Screening with id {screeningId} for movie {movieId} not found."); + { + var errorResponse = new + { + status = "error", + message = $"Screening with id {screeningId} for movie {movieId} not found." + }; + return TypedResults.NotFound(errorResponse); + } var screeningDto = new ScreeningDto { @@ -238,7 +347,13 @@ public static async Task GetScreeningForMovie(int movieId, int screenin UpdatedAt = screening.UpdatedAt }; - return TypedResults.Ok(screeningDto); + var response = new + { + status = "success", + data = screeningDto + }; + + return TypedResults.Ok(response); } } } From e247d60745b80830f2273ba63fe38583fff16bab Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 16:24:13 +0200 Subject: [PATCH 22/28] implemented extention: allow movie creation with optional screenings in AddMovie endpoint --- .../DTOs/MovieDTOs/MoviePostDto.cs | 8 +++++++- .../Endpoints/MovieEndpoints.cs | 18 +++++++++++++++++- .../Repository/IRepository.cs | 2 +- .../Repository/Repository.cs | 10 +++++----- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MoviePostDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MoviePostDto.cs index 21ce3dec..9a3ff7dd 100644 --- a/api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MoviePostDto.cs +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/MovieDTOs/MoviePostDto.cs @@ -1,4 +1,6 @@ -namespace api_cinema_challenge.DTOs.MovieDTOs +using api_cinema_challenge.DTOs.ScreeningDTOs; + +namespace api_cinema_challenge.DTOs.MovieDTOs { public class MoviePostDto { @@ -6,5 +8,9 @@ public class MoviePostDto public required string Rating { get; set; } public required string Description { get; set; } public required int RuntimeMins { get; set; } + + // optional screenings for a movie + public List? Screenings { get; set; } + } } diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs index d329ed17..880f8a9b 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs @@ -99,7 +99,7 @@ public static async Task GetMovies(IRepository repository) [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public static async Task AddMovie(IRepository repository, [FromBody] MoviePostDto model, HttpRequest request, IValidator validator) + public static async Task AddMovie(IRepository repository, [FromBody] MoviePostDto model, HttpRequest request, IValidator validator, IRepository screeningRepository) { if (model == null) { @@ -121,6 +121,22 @@ public static async Task AddMovie(IRepository repository, [FromB var newMovie = new Movie { Title = model.Title, Rating = model.Rating, Description = model.Description, RuntimeMins = model.RuntimeMins }; var addedMovie = await repository.Add(newMovie); + // add optional screenings + if (model.Screenings != null && model.Screenings.Any()) + { + foreach (var screeningDto in model.Screenings) + { + var newScreening = new Screening + { + ScreenNumber = screeningDto.ScreenNumber, + Capacity = screeningDto.Capacity, + StartsAt = screeningDto.StartsAt, + MovieId = addedMovie.Id + }; + await screeningRepository.Add(newScreening); + } + } + var movieDto = new MovieDto { Id = addedMovie.Id, Title = addedMovie.Title, Rating = addedMovie.Rating, Description = addedMovie.Description, RuntimeMins = addedMovie.RuntimeMins, CreatedAt = addedMovie.CreatedAt, UpdatedAt = addedMovie.UpdatedAt }; var response = new diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs index 49643b31..09d9adbc 100644 --- a/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs +++ b/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs @@ -10,7 +10,7 @@ public interface IRepository Task Add(T entity); Task Update(int id, T entity); - Task> GetWithIncludes(params Expression>[] includes); + Task> GetWithIncludes(Func, IQueryable> includeQuery); // used for dynamic includes } } diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs index 6ad179b8..2c403e97 100644 --- a/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs +++ b/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs @@ -41,11 +41,6 @@ public async Task GetById(int id) return await _table.FindAsync(id); } - public async Task> GetWithIncludes(params Expression>[] includes) - { - throw new NotImplementedException(); - } - public async Task Update(int id, T entity) { _table.Attach(entity); @@ -53,5 +48,10 @@ public async Task Update(int id, T entity) await _db.SaveChangesAsync(); return await _table.FindAsync(id); } + public async Task> GetWithIncludes(Func, IQueryable> includeQuery) // used for dynamic includes + { + IQueryable query = includeQuery(_table); + return await query.ToListAsync(); + } } } From b4d9523bcc1cd20503cb5536103096be685f8432 Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Fri, 22 Aug 2025 16:33:56 +0200 Subject: [PATCH 23/28] add migration for AddCascadeDeleteToMovieScreenings , update db --- .../api-cinema-challenge/Data/CinemaContext.cs | 8 ++++++++ ...258_AddCascadeDeleteToMovieScreenings.Designer.cs} | 11 ++++++++--- ...250822143258_AddCascadeDeleteToMovieScreenings.cs} | 2 +- .../Migrations/CinemaContextModelSnapshot.cs | 7 ++++++- .../api-cinema-challenge/Models/Movie.cs | 3 +++ 5 files changed, 26 insertions(+), 5 deletions(-) rename api-cinema-challenge/api-cinema-challenge/Migrations/{20250822130044_InitialScreeningAndTimestamps.Designer.cs => 20250822143258_AddCascadeDeleteToMovieScreenings.Designer.cs} (96%) rename api-cinema-challenge/api-cinema-challenge/Migrations/{20250822130044_InitialScreeningAndTimestamps.cs => 20250822143258_AddCascadeDeleteToMovieScreenings.cs} (99%) diff --git a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs index c4f13575..3c110446 100644 --- a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs +++ b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs @@ -11,6 +11,14 @@ public CinemaContext(DbContextOptions options) : base(options) { protected override void OnModelCreating(ModelBuilder modelBuilder) { ModelSeeder.Seed(modelBuilder); // seed initial data + + // when a movie is deleted, delete all screenings associated with it + modelBuilder.Entity() + .HasMany(m => m.Screenings) + .WithOne(s => s.Movie) + .HasForeignKey(s => s.MovieId) + .OnDelete(DeleteBehavior.Cascade); + } public DbSet Customers { get; set; } diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822130044_InitialScreeningAndTimestamps.Designer.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822143258_AddCascadeDeleteToMovieScreenings.Designer.cs similarity index 96% rename from api-cinema-challenge/api-cinema-challenge/Migrations/20250822130044_InitialScreeningAndTimestamps.Designer.cs rename to api-cinema-challenge/api-cinema-challenge/Migrations/20250822143258_AddCascadeDeleteToMovieScreenings.Designer.cs index e7db6cc5..f9c0ab84 100644 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822130044_InitialScreeningAndTimestamps.Designer.cs +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822143258_AddCascadeDeleteToMovieScreenings.Designer.cs @@ -12,8 +12,8 @@ namespace api_cinema_challenge.Migrations { [DbContext(typeof(CinemaContext))] - [Migration("20250822130044_InitialScreeningAndTimestamps")] - partial class InitialScreeningAndTimestamps + [Migration("20250822143258_AddCascadeDeleteToMovieScreenings")] + partial class AddCascadeDeleteToMovieScreenings { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -238,13 +238,18 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("api_cinema_challenge.Models.Screening", b => { b.HasOne("api_cinema_challenge.Models.Movie", "Movie") - .WithMany() + .WithMany("Screenings") .HasForeignKey("MovieId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.Navigation("Movie"); }); + + modelBuilder.Entity("api_cinema_challenge.Models.Movie", b => + { + b.Navigation("Screenings"); + }); #pragma warning restore 612, 618 } } diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822130044_InitialScreeningAndTimestamps.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822143258_AddCascadeDeleteToMovieScreenings.cs similarity index 99% rename from api-cinema-challenge/api-cinema-challenge/Migrations/20250822130044_InitialScreeningAndTimestamps.cs rename to api-cinema-challenge/api-cinema-challenge/Migrations/20250822143258_AddCascadeDeleteToMovieScreenings.cs index 8f716bd1..0615021c 100644 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822130044_InitialScreeningAndTimestamps.cs +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/20250822143258_AddCascadeDeleteToMovieScreenings.cs @@ -9,7 +9,7 @@ namespace api_cinema_challenge.Migrations { /// - public partial class InitialScreeningAndTimestamps : Migration + public partial class AddCascadeDeleteToMovieScreenings : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs index 453916ad..989f1ca5 100644 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs @@ -235,13 +235,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("api_cinema_challenge.Models.Screening", b => { b.HasOne("api_cinema_challenge.Models.Movie", "Movie") - .WithMany() + .WithMany("Screenings") .HasForeignKey("MovieId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.Navigation("Movie"); }); + + modelBuilder.Entity("api_cinema_challenge.Models.Movie", b => + { + b.Navigation("Screenings"); + }); #pragma warning restore 612, 618 } } diff --git a/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs b/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs index 5cba1e14..575e9dad 100644 --- a/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs +++ b/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs @@ -31,5 +31,8 @@ public class Movie [Column("UpdatedAt")] public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + // Navigation property for related screenings + public ICollection Screenings { get; set; } } } From 4e56c7e6136d039c8c2f92c0c3cc190137de2f43 Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Sat, 23 Aug 2025 13:03:29 +0200 Subject: [PATCH 24/28] add [JsonIgnore] attribute to the Movie property in your Screening class. --- api-cinema-challenge/api-cinema-challenge/Models/Screening.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api-cinema-challenge/api-cinema-challenge/Models/Screening.cs b/api-cinema-challenge/api-cinema-challenge/Models/Screening.cs index 244b7257..6940e9fa 100644 --- a/api-cinema-challenge/api-cinema-challenge/Models/Screening.cs +++ b/api-cinema-challenge/api-cinema-challenge/Models/Screening.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; namespace api_cinema_challenge.Models { @@ -31,6 +32,8 @@ public class Screening // Foreign key to Movie [ForeignKey("Movie")] public int MovieId { get; set; } + + [JsonIgnore] public Movie Movie { get; set; } = null!; } } From 5e23c04dd9a2a03619077b94d8f25cfabb5b4c9e Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Sat, 23 Aug 2025 14:11:28 +0200 Subject: [PATCH 25/28] implemented ticket extention, deleted db, run migration, update db, add validationfilter for ticket, seeded ticket on config --- .../DTOs/TicketDTOs/TicketDto.cs | 11 ++ .../DTOs/TicketDTOs/TicketPostDto.cs | 10 ++ .../Data/CinemaContext.cs | 2 + .../api-cinema-challenge/Data/ModelSeeder.cs | 24 ++++ .../Endpoints/CustomerEndpoints.cs | 128 ++++++++++++++++++ ...250823120028_InitialSeedingDb.Designer.cs} | 79 ++++++++++- ....cs => 20250823120028_InitialSeedingDb.cs} | 53 +++++++- .../Migrations/CinemaContextModelSnapshot.cs | 75 ++++++++++ .../api-cinema-challenge/Models/Ticket.cs | 35 +++++ .../api-cinema-challenge/Program.cs | 4 + .../TicketValidators/TicketPostValidator.cs | 15 ++ 11 files changed, 433 insertions(+), 3 deletions(-) create mode 100644 api-cinema-challenge/api-cinema-challenge/DTOs/TicketDTOs/TicketDto.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/DTOs/TicketDTOs/TicketPostDto.cs rename api-cinema-challenge/api-cinema-challenge/Migrations/{20250822143258_AddCascadeDeleteToMovieScreenings.Designer.cs => 20250823120028_InitialSeedingDb.Designer.cs} (77%) rename api-cinema-challenge/api-cinema-challenge/Migrations/{20250822143258_AddCascadeDeleteToMovieScreenings.cs => 20250823120028_InitialSeedingDb.cs} (73%) create mode 100644 api-cinema-challenge/api-cinema-challenge/Models/Ticket.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/Validators/TicketValidators/TicketPostValidator.cs diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/TicketDTOs/TicketDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/TicketDTOs/TicketDto.cs new file mode 100644 index 00000000..15bc4070 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/TicketDTOs/TicketDto.cs @@ -0,0 +1,11 @@ +namespace api_cinema_challenge.DTOs.TicketDTOs +{ + public class TicketDto + { + public int Id { get; set; } + public int NumSeats { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/TicketDTOs/TicketPostDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/TicketDTOs/TicketPostDto.cs new file mode 100644 index 00000000..ba104d6a --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/TicketDTOs/TicketPostDto.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace api_cinema_challenge.DTOs.TicketDTOs +{ + public class TicketPostDto + { + [Required] + public int NumSeats { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs index 3c110446..000523f6 100644 --- a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs +++ b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs @@ -25,5 +25,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet Movies { get; set; } public DbSet Screenings { get; set; } + public DbSet Tickets { get; set; } + } } diff --git a/api-cinema-challenge/api-cinema-challenge/Data/ModelSeeder.cs b/api-cinema-challenge/api-cinema-challenge/Data/ModelSeeder.cs index 5ff16838..a38beb8f 100644 --- a/api-cinema-challenge/api-cinema-challenge/Data/ModelSeeder.cs +++ b/api-cinema-challenge/api-cinema-challenge/Data/ModelSeeder.cs @@ -1,5 +1,7 @@ using api_cinema_challenge.Models; using Microsoft.EntityFrameworkCore; +using System.Net.Sockets; +using static System.Runtime.InteropServices.JavaScript.JSType; namespace api_cinema_challenge.Data { @@ -102,6 +104,28 @@ public static void Seed(ModelBuilder modelBuilder) MovieId = 3 } ); + + // ticket seed data + modelBuilder.Entity().HasData( + new Ticket + { + Id = 1, + NumSeats = 2, + CustomerId = 1, + ScreeningId = 1, + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) + }, + new Ticket + { + Id = 2, + NumSeats = 4, + CustomerId = 2, + ScreeningId = 1, + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) + } + ); } } } \ No newline at end of file diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs index 29d749f3..c90573fe 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs @@ -1,4 +1,5 @@ using api_cinema_challenge.DTOs.CustomerDTOs; +using api_cinema_challenge.DTOs.TicketDTOs; using api_cinema_challenge.Models; using api_cinema_challenge.Repository; using FluentValidation; @@ -19,6 +20,9 @@ public static void ConfigureCustomerEndpoint(this WebApplication app) customers.MapDelete("/{id}", DeleteCustomer); customers.MapPut("/{id}", UpdateCustomer); + // ticket endpoints + customers.MapPost("/{customerId}/screenings/{screeningId}", AddTicket); + customers.MapGet("/{customerId}/screenings/{screeningId}", GetTickets); } [ProducesResponseType(StatusCodes.Status200OK)] @@ -234,5 +238,129 @@ public static async Task UpdateCustomer(int id, IRepository r return TypedResults.Created(location, response); } + + // ticket endpoints + + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public static async Task AddTicket(int customerId, int screeningId, IRepository customerRepository, IRepository screeningRepository, IRepository ticketRepository, [FromBody] TicketPostDto model, IValidator validator, HttpRequest request) + { + // check if the customer exists + var existingCustomer = await customerRepository.GetById(customerId); + if (existingCustomer == null) + { + var errorResponse = new + { + status = "error", + message = $"The customer with ID {customerId} does not exist" + }; + return TypedResults.NotFound(errorResponse); + } + // check if the screening exists + var existingScreening = await screeningRepository.GetById(screeningId); + if (existingScreening == null) + { + var errorResponse = new + { + status = "error", + message = $"The screening with ID {screeningId} does not exist" + }; + return TypedResults.NotFound(errorResponse); + } + if (model == null) + { + var errorResponse = new + { + status = "error", + message = "Invalid ticket data" + }; + return TypedResults.BadRequest(errorResponse); + } + var validationResult = await validator.ValidateAsync(model); + if (!validationResult.IsValid) + { + var errorResponse = new + { + status = "error", + message = string.Join("; ", validationResult.Errors.Select(e => e.ErrorMessage)) + }; + return TypedResults.BadRequest(errorResponse); + } + // create the ticket + var newTicket = new Ticket + { + NumSeats = model.NumSeats, + CustomerId = customerId, + ScreeningId = screeningId + }; + var addedTicket = await ticketRepository.Add(newTicket); + var ticketDto = new TicketDto + { + Id = addedTicket.Id, + NumSeats = addedTicket.NumSeats, + CreatedAt = addedTicket.CreatedAt, + UpdatedAt = addedTicket.UpdatedAt + }; + var response = new + { + status = "success", + data = ticketDto + }; + var baseUrl = $"{request.Scheme}://{request.Host}{request.PathBase}"; + var location = $"{baseUrl}/tickets/{addedTicket.Id}"; + return TypedResults.Created(location, response); + } + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public static async Task GetTickets(int customerId, int screeningId, IRepository ticketRepository, IRepository customerRepository, IRepository screeningRepository) + { + // Check if customer exists + var customer = await customerRepository.GetById(customerId); + if (customer == null) + { + var errorResponse = new + { + status = "error", + message = $"Customer with ID {customerId} does not exist" + }; + return TypedResults.NotFound(errorResponse); + } + + // Check if screening exists + var screening = await screeningRepository.GetById(screeningId); + if (screening == null) + { + var errorResponse = new + { + status = "error", + message = $"Screening with ID {screeningId} does not exist" + }; + return TypedResults.NotFound(errorResponse); + } + + // Get all tickets for this customer and screening + var tickets = await ticketRepository.GetAll(); + var filteredTickets = tickets + .Where(t => t.CustomerId == customerId && t.ScreeningId == screeningId) + .Select(t => new + { + id = t.Id, + numSeats = t.NumSeats, + createdAt = t.CreatedAt.ToString("yyyy-MM-ddTHH:mm:ss"), + updatedAt = t.UpdatedAt.ToString("yyyy-MM-ddTHH:mm:ss") + }) + .ToList(); + + var response = new + { + status = "success", + data = filteredTickets + }; + + return TypedResults.Ok(response); + } + } } diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822143258_AddCascadeDeleteToMovieScreenings.Designer.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250823120028_InitialSeedingDb.Designer.cs similarity index 77% rename from api-cinema-challenge/api-cinema-challenge/Migrations/20250822143258_AddCascadeDeleteToMovieScreenings.Designer.cs rename to api-cinema-challenge/api-cinema-challenge/Migrations/20250823120028_InitialSeedingDb.Designer.cs index f9c0ab84..f8abf146 100644 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822143258_AddCascadeDeleteToMovieScreenings.Designer.cs +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/20250823120028_InitialSeedingDb.Designer.cs @@ -12,8 +12,8 @@ namespace api_cinema_challenge.Migrations { [DbContext(typeof(CinemaContext))] - [Migration("20250822143258_AddCascadeDeleteToMovieScreenings")] - partial class AddCascadeDeleteToMovieScreenings + [Migration("20250823120028_InitialSeedingDb")] + partial class InitialSeedingDb { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -235,6 +235,62 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("api_cinema_challenge.Models.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("TicketId"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("CreatedAt"); + + b.Property("CustomerId") + .HasColumnType("integer"); + + b.Property("NumSeats") + .HasColumnType("integer") + .HasColumnName("NumsSeats"); + + b.Property("ScreeningId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("UpdatedAt"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("ScreeningId"); + + b.ToTable("Tickets"); + + b.HasData( + new + { + Id = 1, + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + CustomerId = 1, + NumSeats = 2, + ScreeningId = 1, + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) + }, + new + { + Id = 2, + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + CustomerId = 2, + NumSeats = 4, + ScreeningId = 1, + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) + }); + }); + modelBuilder.Entity("api_cinema_challenge.Models.Screening", b => { b.HasOne("api_cinema_challenge.Models.Movie", "Movie") @@ -246,6 +302,25 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("Movie"); }); + modelBuilder.Entity("api_cinema_challenge.Models.Ticket", b => + { + b.HasOne("api_cinema_challenge.Models.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("api_cinema_challenge.Models.Screening", "Screening") + .WithMany() + .HasForeignKey("ScreeningId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("Screening"); + }); + modelBuilder.Entity("api_cinema_challenge.Models.Movie", b => { b.Navigation("Screenings"); diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822143258_AddCascadeDeleteToMovieScreenings.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250823120028_InitialSeedingDb.cs similarity index 73% rename from api-cinema-challenge/api-cinema-challenge/Migrations/20250822143258_AddCascadeDeleteToMovieScreenings.cs rename to api-cinema-challenge/api-cinema-challenge/Migrations/20250823120028_InitialSeedingDb.cs index 0615021c..2591a229 100644 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/20250822143258_AddCascadeDeleteToMovieScreenings.cs +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/20250823120028_InitialSeedingDb.cs @@ -9,7 +9,7 @@ namespace api_cinema_challenge.Migrations { /// - public partial class AddCascadeDeleteToMovieScreenings : Migration + public partial class InitialSeedingDb : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -73,6 +73,35 @@ protected override void Up(MigrationBuilder migrationBuilder) onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "Tickets", + columns: table => new + { + TicketId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + NumsSeats = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ScreeningId = table.Column(type: "integer", nullable: false), + CustomerId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tickets", x => x.TicketId); + table.ForeignKey( + name: "FK_Tickets_Customers_CustomerId", + column: x => x.CustomerId, + principalTable: "Customers", + principalColumn: "CustomerId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Tickets_Screenings_ScreeningId", + column: x => x.ScreeningId, + principalTable: "Screenings", + principalColumn: "ScreeningId", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.InsertData( table: "Customers", columns: new[] { "CustomerId", "CreatedAt", "CustomerEmail", "CustomerName", "CustomerPhone", "UpdatedAt" }, @@ -103,15 +132,37 @@ protected override void Up(MigrationBuilder migrationBuilder) { 3, 30, new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), 3, 7, new DateTime(2023, 3, 21, 18, 45, 0, 0, DateTimeKind.Utc), new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) } }); + migrationBuilder.InsertData( + table: "Tickets", + columns: new[] { "TicketId", "CreatedAt", "CustomerId", "NumsSeats", "ScreeningId", "UpdatedAt" }, + values: new object[,] + { + { 1, new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), 1, 2, 1, new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) }, + { 2, new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), 2, 4, 1, new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) } + }); + migrationBuilder.CreateIndex( name: "IX_Screenings_MovieId", table: "Screenings", column: "MovieId"); + + migrationBuilder.CreateIndex( + name: "IX_Tickets_CustomerId", + table: "Tickets", + column: "CustomerId"); + + migrationBuilder.CreateIndex( + name: "IX_Tickets_ScreeningId", + table: "Tickets", + column: "ScreeningId"); } /// protected override void Down(MigrationBuilder migrationBuilder) { + migrationBuilder.DropTable( + name: "Tickets"); + migrationBuilder.DropTable( name: "Customers"); diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs index 989f1ca5..e05dccd9 100644 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs @@ -232,6 +232,62 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("api_cinema_challenge.Models.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("TicketId"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("CreatedAt"); + + b.Property("CustomerId") + .HasColumnType("integer"); + + b.Property("NumSeats") + .HasColumnType("integer") + .HasColumnName("NumsSeats"); + + b.Property("ScreeningId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("UpdatedAt"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("ScreeningId"); + + b.ToTable("Tickets"); + + b.HasData( + new + { + Id = 1, + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + CustomerId = 1, + NumSeats = 2, + ScreeningId = 1, + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) + }, + new + { + Id = 2, + CreatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), + CustomerId = 2, + NumSeats = 4, + ScreeningId = 1, + UpdatedAt = new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) + }); + }); + modelBuilder.Entity("api_cinema_challenge.Models.Screening", b => { b.HasOne("api_cinema_challenge.Models.Movie", "Movie") @@ -243,6 +299,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Movie"); }); + modelBuilder.Entity("api_cinema_challenge.Models.Ticket", b => + { + b.HasOne("api_cinema_challenge.Models.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("api_cinema_challenge.Models.Screening", "Screening") + .WithMany() + .HasForeignKey("ScreeningId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + + b.Navigation("Screening"); + }); + modelBuilder.Entity("api_cinema_challenge.Models.Movie", b => { b.Navigation("Screenings"); diff --git a/api-cinema-challenge/api-cinema-challenge/Models/Ticket.cs b/api-cinema-challenge/api-cinema-challenge/Models/Ticket.cs new file mode 100644 index 00000000..f6e6df42 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Ticket.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace api_cinema_challenge.Models +{ + [Table("Tickets")] + public class Ticket + { + [Key] + [Column("TicketId")] + public int Id { get; set; } + + [Required] + [Column("NumsSeats")] + public int NumSeats { get; set; } + + [Column("CreatedAt")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [Column("UpdatedAt")] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + // Foreign key to Screening + [ForeignKey("Screening")] + public int ScreeningId { get; set; } + public Screening Screening { get; set; } = null!; + + // Foreign key to Customer + [ForeignKey("Customer")] + public int CustomerId { get; set; } + public Customer Customer { get; set; } = null!; + + + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Program.cs b/api-cinema-challenge/api-cinema-challenge/Program.cs index 5749c9e4..49a2d5e6 100644 --- a/api-cinema-challenge/api-cinema-challenge/Program.cs +++ b/api-cinema-challenge/api-cinema-challenge/Program.cs @@ -2,12 +2,14 @@ using api_cinema_challenge.DTOs.CustomerDTOs; using api_cinema_challenge.DTOs.MovieDTOs; using api_cinema_challenge.DTOs.ScreeningDTOs; +using api_cinema_challenge.DTOs.TicketDTOs; using api_cinema_challenge.Endpoints; using api_cinema_challenge.Models; using api_cinema_challenge.Repository; using api_cinema_challenge.Validators.CustomerValidators; using api_cinema_challenge.Validators.MovieValidators; using api_cinema_challenge.Validators.ScreeningValidators; +using api_cinema_challenge.Validators.TicketValidators; using FluentValidation; using Microsoft.EntityFrameworkCore; using Scalar.AspNetCore; @@ -27,12 +29,14 @@ builder.Services.AddScoped, Repository>(); builder.Services.AddScoped, Repository>(); builder.Services.AddScoped, Repository>(); +builder.Services.AddScoped, Repository>(); // Register validators as services builder.Services.AddScoped, CustomerPostValidator>(); builder.Services.AddScoped, CustomerPutValidator>(); builder.Services.AddScoped, MoviePostValidator>(); builder.Services.AddScoped, MoviePutValidator>(); builder.Services.AddScoped, ScreeningPostValidator>(); +builder.Services.AddScoped, TicketPostValidator>(); var app = builder.Build(); diff --git a/api-cinema-challenge/api-cinema-challenge/Validators/TicketValidators/TicketPostValidator.cs b/api-cinema-challenge/api-cinema-challenge/Validators/TicketValidators/TicketPostValidator.cs new file mode 100644 index 00000000..e4654d1c --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Validators/TicketValidators/TicketPostValidator.cs @@ -0,0 +1,15 @@ +using api_cinema_challenge.DTOs.TicketDTOs; +using FluentValidation; + +namespace api_cinema_challenge.Validators.TicketValidators +{ + public class TicketPostValidator : AbstractValidator + { + public TicketPostValidator() + { + RuleFor(t => t.NumSeats) + .GreaterThan(0).WithMessage("Number of seats must be greater than zero.") + .LessThanOrEqualTo(10).WithMessage("Cannot book more than 10 seats at once."); + } + } +} From 06108f193417b32b0b1b54aaed852d26eb1b8775 Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Mon, 25 Aug 2025 08:17:51 +0200 Subject: [PATCH 26/28] updated datacontext with relationships, added navigation prop to customer model to tickets. add migration, upodate db --- .../Data/CinemaContext.cs | 8 ++++- .../Endpoints/CustomerEndpoints.cs | 6 ++-- .../Endpoints/MovieEndpoints.cs | 10 +++++-- ...0825061234_InitialSeedingOfDb.Designer.cs} | 29 +++++++++---------- ...s => 20250825061234_InitialSeedingOfDb.cs} | 16 +++++----- .../Migrations/CinemaContextModelSnapshot.cs | 25 ++++++++-------- .../api-cinema-challenge/Models/Customer.cs | 19 ++---------- 7 files changed, 54 insertions(+), 59 deletions(-) rename api-cinema-challenge/api-cinema-challenge/Migrations/{20250823120028_InitialSeedingDb.Designer.cs => 20250825061234_InitialSeedingOfDb.Designer.cs} (95%) rename api-cinema-challenge/api-cinema-challenge/Migrations/{20250823120028_InitialSeedingDb.cs => 20250825061234_InitialSeedingOfDb.cs} (93%) diff --git a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs index 000523f6..acebe50a 100644 --- a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs +++ b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs @@ -13,12 +13,18 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) ModelSeeder.Seed(modelBuilder); // seed initial data // when a movie is deleted, delete all screenings associated with it - modelBuilder.Entity() + modelBuilder.Entity() // movie-screening relationship .HasMany(m => m.Screenings) .WithOne(s => s.Movie) .HasForeignKey(s => s.MovieId) .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() // ticket-customer relationship + .HasOne(t => t.Customer) + .WithMany(c => c.Tickets) + .HasForeignKey(t => t.CustomerId) + .OnDelete(DeleteBehavior.Cascade); + } public DbSet Customers { get; set; } diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs index c90573fe..6a70a8c1 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs @@ -109,7 +109,7 @@ public static async Task AddCustomer(IRepository repository, return TypedResults.BadRequest(errorResponse); } - var newCustomer = new Customer { Name = model.Name, Email = model.Email, Phone = model.Phone }; + var newCustomer = new Customer { Name = model.Name, Email = model.Email, Phone = model.Phone, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; var addedCustomer = await repository.Add(newCustomer); var customerDto = new CustomerDto { Id = addedCustomer.Id, Name = addedCustomer.Name, Email=addedCustomer.Email, Phone = addedCustomer.Phone, CreatedAt = addedCustomer.CreatedAt, UpdatedAt = addedCustomer.UpdatedAt }; @@ -292,7 +292,9 @@ public static async Task AddTicket(int customerId, int screeningId, IRe { NumSeats = model.NumSeats, CustomerId = customerId, - ScreeningId = screeningId + ScreeningId = screeningId, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow }; var addedTicket = await ticketRepository.Add(newTicket); var ticketDto = new TicketDto diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs index 880f8a9b..992f0030 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs @@ -118,7 +118,7 @@ public static async Task AddMovie(IRepository repository, [FromB return TypedResults.BadRequest(errorResponse); } - var newMovie = new Movie { Title = model.Title, Rating = model.Rating, Description = model.Description, RuntimeMins = model.RuntimeMins }; + var newMovie = new Movie { Title = model.Title, Rating = model.Rating, Description = model.Description, RuntimeMins = model.RuntimeMins, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; var addedMovie = await repository.Add(newMovie); // add optional screenings @@ -131,7 +131,9 @@ public static async Task AddMovie(IRepository repository, [FromB ScreenNumber = screeningDto.ScreenNumber, Capacity = screeningDto.Capacity, StartsAt = screeningDto.StartsAt, - MovieId = addedMovie.Id + MovieId = addedMovie.Id, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow }; await screeningRepository.Add(newScreening); } @@ -312,7 +314,9 @@ public static async Task AddScreeningForMovie(int id, IRepository protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -29,33 +29,27 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("CustomerId"); + .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAt"); + .HasColumnType("timestamp with time zone"); b.Property("Email") .IsRequired() - .HasColumnType("text") - .HasColumnName("CustomerEmail"); + .HasColumnType("text"); b.Property("Name") .IsRequired() - .HasColumnType("text") - .HasColumnName("CustomerName"); + .HasColumnType("text"); b.Property("Phone") .IsRequired() - .HasColumnType("text") - .HasColumnName("CustomerPhone"); + .HasColumnType("text"); b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAt"); + .HasColumnType("timestamp with time zone"); b.HasKey("Id"); @@ -305,7 +299,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("api_cinema_challenge.Models.Ticket", b => { b.HasOne("api_cinema_challenge.Models.Customer", "Customer") - .WithMany() + .WithMany("Tickets") .HasForeignKey("CustomerId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -321,6 +315,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("Screening"); }); + modelBuilder.Entity("api_cinema_challenge.Models.Customer", b => + { + b.Navigation("Tickets"); + }); + modelBuilder.Entity("api_cinema_challenge.Models.Movie", b => { b.Navigation("Screenings"); diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250823120028_InitialSeedingDb.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250825061234_InitialSeedingOfDb.cs similarity index 93% rename from api-cinema-challenge/api-cinema-challenge/Migrations/20250823120028_InitialSeedingDb.cs rename to api-cinema-challenge/api-cinema-challenge/Migrations/20250825061234_InitialSeedingOfDb.cs index 2591a229..030e7c5d 100644 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/20250823120028_InitialSeedingDb.cs +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/20250825061234_InitialSeedingOfDb.cs @@ -9,7 +9,7 @@ namespace api_cinema_challenge.Migrations { /// - public partial class InitialSeedingDb : Migration + public partial class InitialSeedingOfDb : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -18,17 +18,17 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "Customers", columns: table => new { - CustomerId = table.Column(type: "integer", nullable: false) + Id = table.Column(type: "integer", nullable: false) .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - CustomerName = table.Column(type: "text", nullable: false), - CustomerEmail = table.Column(type: "text", nullable: false), - CustomerPhone = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Email = table.Column(type: "text", nullable: false), + Phone = table.Column(type: "text", nullable: false), CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) }, constraints: table => { - table.PrimaryKey("PK_Customers", x => x.CustomerId); + table.PrimaryKey("PK_Customers", x => x.Id); }); migrationBuilder.CreateTable( @@ -92,7 +92,7 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "FK_Tickets_Customers_CustomerId", column: x => x.CustomerId, principalTable: "Customers", - principalColumn: "CustomerId", + principalColumn: "Id", onDelete: ReferentialAction.Cascade); table.ForeignKey( name: "FK_Tickets_Screenings_ScreeningId", @@ -104,7 +104,7 @@ protected override void Up(MigrationBuilder migrationBuilder) migrationBuilder.InsertData( table: "Customers", - columns: new[] { "CustomerId", "CreatedAt", "CustomerEmail", "CustomerName", "CustomerPhone", "UpdatedAt" }, + columns: new[] { "Id", "CreatedAt", "Email", "Name", "Phone", "UpdatedAt" }, values: new object[,] { { 1, new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), "messi@messi.messi", "Lionel Messi", "90121413", new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) }, diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs index e05dccd9..283aab7a 100644 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs @@ -26,33 +26,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Property("Id") .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("CustomerId"); + .HasColumnType("integer"); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("CreatedAt"); + .HasColumnType("timestamp with time zone"); b.Property("Email") .IsRequired() - .HasColumnType("text") - .HasColumnName("CustomerEmail"); + .HasColumnType("text"); b.Property("Name") .IsRequired() - .HasColumnType("text") - .HasColumnName("CustomerName"); + .HasColumnType("text"); b.Property("Phone") .IsRequired() - .HasColumnType("text") - .HasColumnName("CustomerPhone"); + .HasColumnType("text"); b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("UpdatedAt"); + .HasColumnType("timestamp with time zone"); b.HasKey("Id"); @@ -302,7 +296,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("api_cinema_challenge.Models.Ticket", b => { b.HasOne("api_cinema_challenge.Models.Customer", "Customer") - .WithMany() + .WithMany("Tickets") .HasForeignKey("CustomerId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -318,6 +312,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Screening"); }); + modelBuilder.Entity("api_cinema_challenge.Models.Customer", b => + { + b.Navigation("Tickets"); + }); + modelBuilder.Entity("api_cinema_challenge.Models.Movie", b => { b.Navigation("Screenings"); diff --git a/api-cinema-challenge/api-cinema-challenge/Models/Customer.cs b/api-cinema-challenge/api-cinema-challenge/Models/Customer.cs index d99c842b..619f26b4 100644 --- a/api-cinema-challenge/api-cinema-challenge/Models/Customer.cs +++ b/api-cinema-challenge/api-cinema-challenge/Models/Customer.cs @@ -6,28 +6,13 @@ namespace api_cinema_challenge.Models [Table("Customers")] public class Customer { - [Key] - [Column("CustomerId")] public int Id { get; set; } - - [Required] - [Column("CustomerName")] public required string Name { get; set; } - - [Required] - [Column("CustomerEmail")] - [EmailAddress] public required string Email { get; set; } - - [Required] - [Column("CustomerPhone")] - [Phone] public required string Phone { get; set; } - - [Column("CreatedAt")] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - - [Column("UpdatedAt")] public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public ICollection Tickets { get; set; } = new List(); // Navigation property for related tickets } } From e18c06d63a7bd13df3a5b25d8c72335674e3e691 Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Mon, 25 Aug 2025 08:50:46 +0200 Subject: [PATCH 27/28] defined jwt settings in appsettings.json and appsetting.development.json --- .../api-cinema-challenge/api-cinema-challenge.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj index 445d6699..20fcbc09 100644 --- a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj +++ b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj @@ -21,6 +21,8 @@ + + From 87fe230b2be22e3768fbd40e547de48acae3c9e0 Mon Sep 17 00:00:00 2001 From: Mathias Handeland <127216029+MathiasHandeland@users.noreply.github.com> Date: Mon, 25 Aug 2025 13:14:07 +0200 Subject: [PATCH 28/28] added authentication for the endpoints --- .../Controllers/UserController.cs | 99 +++++++++++ .../DTOs/Requests/AuthRequest.cs | 13 ++ .../DTOs/Requests/RegstrationRequest.cs | 19 ++ .../DTOs/Response/AuthResponse.cs | 9 + .../Data/CinemaContext.cs | 7 +- .../Endpoints/CustomerEndpoints.cs | 32 +++- .../Endpoints/MovieEndpoints.cs | 40 +++-- .../api-cinema-challenge/Enums/Role.cs | 8 + .../Helpers/ClaimsPrincipalHelper.cs | 18 ++ ...50825104548_IntialSeedingOfDb.Designer.cs} | 164 +++++++++++++++++- ...cs => 20250825104548_IntialSeedingOfDb.cs} | 122 ++++++++++++- .../Migrations/CinemaContextModelSnapshot.cs | 160 +++++++++++++++++ .../Models/ApplicationUser.cs | 10 ++ .../api-cinema-challenge/Models/Movie.cs | 4 +- .../api-cinema-challenge/Program.cs | 122 ++++++++++++- .../Services/TokenService.cs | 82 +++++++++ .../api-cinema-challenge.csproj | 8 +- 17 files changed, 875 insertions(+), 42 deletions(-) create mode 100644 api-cinema-challenge/api-cinema-challenge/Controllers/UserController.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/DTOs/Requests/AuthRequest.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/DTOs/Requests/RegstrationRequest.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/DTOs/Response/AuthResponse.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/Enums/Role.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/Helpers/ClaimsPrincipalHelper.cs rename api-cinema-challenge/api-cinema-challenge/Migrations/{20250825061234_InitialSeedingOfDb.Designer.cs => 20250825104548_IntialSeedingOfDb.Designer.cs} (69%) rename api-cinema-challenge/api-cinema-challenge/Migrations/{20250825061234_InitialSeedingOfDb.cs => 20250825104548_IntialSeedingOfDb.cs} (61%) create mode 100644 api-cinema-challenge/api-cinema-challenge/Models/ApplicationUser.cs create mode 100644 api-cinema-challenge/api-cinema-challenge/Services/TokenService.cs diff --git a/api-cinema-challenge/api-cinema-challenge/Controllers/UserController.cs b/api-cinema-challenge/api-cinema-challenge/Controllers/UserController.cs new file mode 100644 index 00000000..c4d31f83 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Controllers/UserController.cs @@ -0,0 +1,99 @@ +using api_cinema_challenge.Data; +using api_cinema_challenge.DTOs.Requests; +using api_cinema_challenge.DTOs.Response; +using api_cinema_challenge.Enums; +using api_cinema_challenge.Models; +using api_cinema_challenge.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace api_cinema_challenge.Controllers +{ + [ApiController] + [Route("/api/[controller]")] + public class UsersController : ControllerBase + { + private readonly UserManager _userManager; + private readonly CinemaContext _context; + private readonly TokenService _tokenService; + + public UsersController(UserManager userManager, CinemaContext context, + TokenService tokenService, ILogger logger) + { + _userManager = userManager; + _context = context; + _tokenService = tokenService; + } + + + [HttpPost] + [Route("register")] + public async Task Register(RegistrationRequest request) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + var result = await _userManager.CreateAsync( + new ApplicationUser { UserName = request.Username, Email = request.Email, Role = request.Role }, + request.Password! + ); + + if (result.Succeeded) + { + request.Password = ""; + return CreatedAtAction(nameof(Register), new { email = request.Email, role = Role.User }, request); + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(error.Code, error.Description); + } + + return BadRequest(ModelState); + } + + + [HttpPost] + [Route("login")] + public async Task> Authenticate([FromBody] AuthRequest request) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + var managedUser = await _userManager.FindByEmailAsync(request.Email!); + + if (managedUser == null) + { + return BadRequest("Bad credentials"); + } + + var isPasswordValid = await _userManager.CheckPasswordAsync(managedUser, request.Password!); + + if (!isPasswordValid) + { + return BadRequest("Bad credentials"); + } + + var userInDb = _context.Users.FirstOrDefault(u => u.Email == request.Email); + + if (userInDb is null) + { + return Unauthorized(); + } + + var accessToken = _tokenService.CreateToken(userInDb); + await _context.SaveChangesAsync(); + + return Ok(new AuthResponse + { + Username = userInDb.UserName, + Email = userInDb.Email, + Token = accessToken, + }); + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/Requests/AuthRequest.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/Requests/AuthRequest.cs new file mode 100644 index 00000000..de6bff4b --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/Requests/AuthRequest.cs @@ -0,0 +1,13 @@ +namespace api_cinema_challenge.DTOs.Requests +{ + public class AuthRequest + { + public string? Email { get; set; } + public string? Password { get; set; } + + public bool IsValid() + { + return true; + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/Requests/RegstrationRequest.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/Requests/RegstrationRequest.cs new file mode 100644 index 00000000..c0a850b2 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/Requests/RegstrationRequest.cs @@ -0,0 +1,19 @@ +using api_cinema_challenge.Enums; +using System.ComponentModel.DataAnnotations; + +namespace api_cinema_challenge.DTOs.Requests +{ + public class RegistrationRequest + { + [Required] + public string? Email { get; set; } + + [Required] + public string? Username { get { return this.Email; } set { } } + + [Required] + public string? Password { get; set; } + + public Role Role { get; set; } = Role.User; + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/Response/AuthResponse.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/Response/AuthResponse.cs new file mode 100644 index 00000000..309e0b92 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/Response/AuthResponse.cs @@ -0,0 +1,9 @@ +namespace api_cinema_challenge.DTOs.Response +{ + public class AuthResponse + { + public string? Username { get; set; } + public string? Email { get; set; } + public string? Token { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs index acebe50a..ea18b346 100644 --- a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs +++ b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs @@ -1,15 +1,20 @@ using api_cinema_challenge.Models; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using Newtonsoft.Json.Linq; namespace api_cinema_challenge.Data { - public class CinemaContext : DbContext + public class CinemaContext : IdentityUserContext { public CinemaContext(DbContextOptions options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { + base.OnModelCreating(modelBuilder); + + ModelSeeder.Seed(modelBuilder); // seed initial data // when a movie is deleted, delete all screenings associated with it diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs index 6a70a8c1..ec85c8d4 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs @@ -5,6 +5,7 @@ using FluentValidation; using Microsoft.AspNetCore.Mvc; using System.ComponentModel.DataAnnotations; +using System.Security.Claims; namespace api_cinema_challenge.Endpoints { @@ -14,17 +15,18 @@ public static void ConfigureCustomerEndpoint(this WebApplication app) { var customers = app.MapGroup("customers"); - customers.MapGet("/{id}", GetCustomerById); - customers.MapGet("/", GetCustomers); - customers.MapPost("/", AddCustomer); - customers.MapDelete("/{id}", DeleteCustomer); - customers.MapPut("/{id}", UpdateCustomer); + customers.MapGet("/{id}", GetCustomerById).RequireAuthorization(); + customers.MapGet("/", GetCustomers).RequireAuthorization("Admin"); + customers.MapPost("/", AddCustomer).RequireAuthorization(); // both roles can add customers + customers.MapDelete("/{id}", DeleteCustomer).RequireAuthorization("Admin"); + customers.MapPut("/{id}", UpdateCustomer).RequireAuthorization(); // ticket endpoints - customers.MapPost("/{customerId}/screenings/{screeningId}", AddTicket); - customers.MapGet("/{customerId}/screenings/{screeningId}", GetTickets); + customers.MapPost("/{customerId}/screenings/{screeningId}", AddTicket).RequireAuthorization(); + customers.MapGet("/{customerId}/screenings/{screeningId}", GetTickets).RequireAuthorization(); } + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public static async Task GetCustomerById(int id, IRepository repository) @@ -59,10 +61,13 @@ public static async Task GetCustomerById(int id, IRepository return TypedResults.Ok(response); } + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public static async Task GetCustomers(IRepository repository) + public static async Task GetCustomers(IRepository repository, ClaimsPrincipal user) { + var username = user.Identity?.Name; + var customers = await repository.GetAll(); if (customers == null || !customers.Any()) { @@ -85,6 +90,7 @@ public static async Task GetCustomers(IRepository repository) var response = new { status = "success", + requestedBy = username, // included who requested the customer list data = customerDto }; @@ -92,6 +98,7 @@ public static async Task GetCustomers(IRepository repository) } + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public static async Task AddCustomer(IRepository repository, [FromBody] CustomerPostDto model, IValidator validator, HttpRequest request) @@ -126,10 +133,13 @@ public static async Task AddCustomer(IRepository repository, } + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public static async Task DeleteCustomer(int id, IRepository repository) + public static async Task DeleteCustomer(int id, IRepository repository, ClaimsPrincipal user) { + var username = user.Identity?.Name; + var targetCustomer = await repository.GetById(id); if (targetCustomer == null) { @@ -156,12 +166,14 @@ public static async Task DeleteCustomer(int id, IRepository r var response = new { status = "success", + deletedBy = username, // included who deleted the customer data = customerDto }; return TypedResults.Ok(response); } + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -241,6 +253,7 @@ public static async Task UpdateCustomer(int id, IRepository r // ticket endpoints + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -314,6 +327,7 @@ public static async Task AddTicket(int customerId, int screeningId, IRe return TypedResults.Created(location, response); } + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public static async Task GetTickets(int customerId, int screeningId, IRepository ticketRepository, IRepository customerRepository, IRepository screeningRepository) diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs index 992f0030..bdf9bfc3 100644 --- a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs @@ -4,6 +4,7 @@ using api_cinema_challenge.Repository; using FluentValidation; using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; namespace api_cinema_challenge.Endpoints { @@ -13,19 +14,20 @@ public static void ConfigureMovieEndpoint(this WebApplication app) { var movies = app.MapGroup("movies"); - movies.MapGet("/{id}", GetMovieById); - movies.MapGet("/", GetMovies); - movies.MapPost("/", AddMovie); - movies.MapDelete("/{id}", DeleteMovie); - movies.MapPut("/{id}", UpdateMovie); + movies.MapGet("/{id}", GetMovieById).RequireAuthorization(); + movies.MapGet("/", GetMovies).RequireAuthorization(); + movies.MapPost("/", AddMovie).RequireAuthorization(); + movies.MapDelete("/{id}", DeleteMovie).RequireAuthorization("Admin"); + movies.MapPut("/{id}", UpdateMovie).RequireAuthorization("Admin"); // screening endpoints - movies.MapGet("/{id}/screenings", GetScreeningsForMovie); - movies.MapPost("/{id}/screenings", AddScreeningForMovie); - movies.MapGet("/{movieId}/screenings/{screeningId}", GetScreeningForMovie); + movies.MapGet("/{id}/screenings", GetScreeningsForMovie).RequireAuthorization(); + movies.MapPost("/{id}/screenings", AddScreeningForMovie).RequireAuthorization("Admin"); + movies.MapGet("/{movieId}/screenings/{screeningId}", GetScreeningForMovie).RequireAuthorization(); } + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public static async Task GetMovieById(int id, IRepository repository) @@ -61,6 +63,7 @@ public static async Task GetMovieById(int id, IRepository reposi return TypedResults.Ok(response); } + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public static async Task GetMovies(IRepository repository) @@ -97,6 +100,7 @@ public static async Task GetMovies(IRepository repository) } + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] public static async Task AddMovie(IRepository repository, [FromBody] MoviePostDto model, HttpRequest request, IValidator validator, IRepository screeningRepository) @@ -153,10 +157,13 @@ public static async Task AddMovie(IRepository repository, [FromB } + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public static async Task DeleteMovie(int id, IRepository repository) + public static async Task DeleteMovie(int id, IRepository repository, ClaimsPrincipal user) { + var username = user.Identity?.Name; + var targetMovie = await repository.GetById(id); if (targetMovie == null) { @@ -179,17 +186,20 @@ public static async Task DeleteMovie(int id, IRepository reposit var response = new { status = "success", + deletedBy = username, data = movieDto }; return TypedResults.Ok(response); } + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public static async Task UpdateMovie(int id, IRepository repository, [FromBody] MoviePutDto model, IValidator validator, HttpRequest request) + public static async Task UpdateMovie(int id, IRepository repository, [FromBody] MoviePutDto model, IValidator validator, HttpRequest request, ClaimsPrincipal user) { + var username = user.Identity?.Name; // check if the movie we want to update exists var existingMovie = await repository.GetById(id); if (existingMovie == null) @@ -242,6 +252,7 @@ public static async Task UpdateMovie(int id, IRepository reposit var response = new { status = "success", + updatedBy = username, data = movieDto }; @@ -252,6 +263,8 @@ public static async Task UpdateMovie(int id, IRepository reposit } // screening endpoints + + [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public static async Task GetScreeningsForMovie(int id, IRepository screeningRepository) @@ -288,10 +301,13 @@ public static async Task GetScreeningsForMovie(int id, IRepository AddScreeningForMovie(int id, IRepository screeningRepository, [FromBody] ScreeningPostDto model, IValidator validator, HttpRequest request) + public static async Task AddScreeningForMovie(int id, IRepository screeningRepository, [FromBody] ScreeningPostDto model, IValidator validator, HttpRequest request, ClaimsPrincipal user) { + var username = user.Identity?.Name; + if (model == null) { var errorResponse = new { status = "error", message = "Invalid screening data" }; @@ -334,6 +350,7 @@ public static async Task AddScreeningForMovie(int id, IRepository AddScreeningForMovie(int id, IRepository GetScreeningForMovie(int movieId, int screeningId, IRepository screeningRepository) diff --git a/api-cinema-challenge/api-cinema-challenge/Enums/Role.cs b/api-cinema-challenge/api-cinema-challenge/Enums/Role.cs new file mode 100644 index 00000000..551a6178 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Enums/Role.cs @@ -0,0 +1,8 @@ +namespace api_cinema_challenge.Enums +{ + public enum Role + { + Admin, + User + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Helpers/ClaimsPrincipalHelper.cs b/api-cinema-challenge/api-cinema-challenge/Helpers/ClaimsPrincipalHelper.cs new file mode 100644 index 00000000..8f6f5132 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Helpers/ClaimsPrincipalHelper.cs @@ -0,0 +1,18 @@ +using System.Security.Claims; + +namespace api_cinema_challenge.Helpers +{ + public static class ClaimsPrincipalHelper + { + public static string? UserId(this ClaimsPrincipal user) + { + Claim? claim = user.FindFirst(ClaimTypes.NameIdentifier); + return claim?.Value; + } + public static string? Email(this ClaimsPrincipal user) + { + Claim? claim = user.FindFirst(ClaimTypes.Email); + return claim?.Value; + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250825061234_InitialSeedingOfDb.Designer.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250825104548_IntialSeedingOfDb.Designer.cs similarity index 69% rename from api-cinema-challenge/api-cinema-challenge/Migrations/20250825061234_InitialSeedingOfDb.Designer.cs rename to api-cinema-challenge/api-cinema-challenge/Migrations/20250825104548_IntialSeedingOfDb.Designer.cs index 3403cb8c..3814bd81 100644 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/20250825061234_InitialSeedingOfDb.Designer.cs +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/20250825104548_IntialSeedingOfDb.Designer.cs @@ -12,8 +12,8 @@ namespace api_cinema_challenge.Migrations { [DbContext(typeof(CinemaContext))] - [Migration("20250825061234_InitialSeedingOfDb")] - partial class InitialSeedingOfDb + [Migration("20250825104548_IntialSeedingOfDb")] + partial class IntialSeedingOfDb { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -25,6 +25,139 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + 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.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("api_cinema_challenge.Models.ApplicationUser", 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("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("Role") + .HasColumnType("integer"); + + 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("api_cinema_challenge.Models.Customer", b => { b.Property("Id") @@ -285,6 +418,33 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("api_cinema_challenge.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("api_cinema_challenge.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("api_cinema_challenge.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("api_cinema_challenge.Models.Screening", b => { b.HasOne("api_cinema_challenge.Models.Movie", "Movie") diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250825061234_InitialSeedingOfDb.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250825104548_IntialSeedingOfDb.cs similarity index 61% rename from api-cinema-challenge/api-cinema-challenge/Migrations/20250825061234_InitialSeedingOfDb.cs rename to api-cinema-challenge/api-cinema-challenge/Migrations/20250825104548_IntialSeedingOfDb.cs index 030e7c5d..0d2b22ef 100644 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/20250825061234_InitialSeedingOfDb.cs +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/20250825104548_IntialSeedingOfDb.cs @@ -9,11 +9,37 @@ namespace api_cinema_challenge.Migrations { /// - public partial class InitialSeedingOfDb : Migration + public partial class IntialSeedingOfDb : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Role = table.Column(type: "integer", nullable: false), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + migrationBuilder.CreateTable( name: "Customers", columns: table => new @@ -49,6 +75,67 @@ protected override void Up(MigrationBuilder migrationBuilder) table.PrimaryKey("PK_Movies", x => x.MovieId); }); + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(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(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(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: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(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: "Screenings", columns: table => new @@ -141,6 +228,27 @@ protected override void Up(MigrationBuilder migrationBuilder) { 2, new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc), 2, 4, 1, new DateTime(2023, 3, 14, 11, 1, 56, 633, DateTimeKind.Utc) } }); + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + migrationBuilder.CreateIndex( name: "IX_Screenings_MovieId", table: "Screenings", @@ -160,9 +268,21 @@ protected override void Up(MigrationBuilder migrationBuilder) /// protected override void Down(MigrationBuilder migrationBuilder) { + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + migrationBuilder.DropTable( name: "Tickets"); + migrationBuilder.DropTable( + name: "AspNetUsers"); + migrationBuilder.DropTable( name: "Customers"); diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs index 283aab7a..eea2b649 100644 --- a/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs @@ -22,6 +22,139 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + 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.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("api_cinema_challenge.Models.ApplicationUser", 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("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("Role") + .HasColumnType("integer"); + + 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("api_cinema_challenge.Models.Customer", b => { b.Property("Id") @@ -282,6 +415,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("api_cinema_challenge.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("api_cinema_challenge.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("api_cinema_challenge.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("api_cinema_challenge.Models.Screening", b => { b.HasOne("api_cinema_challenge.Models.Movie", "Movie") diff --git a/api-cinema-challenge/api-cinema-challenge/Models/ApplicationUser.cs b/api-cinema-challenge/api-cinema-challenge/Models/ApplicationUser.cs new file mode 100644 index 00000000..6b59b648 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/ApplicationUser.cs @@ -0,0 +1,10 @@ +using api_cinema_challenge.Enums; +using Microsoft.AspNetCore.Identity; + +namespace api_cinema_challenge.Models +{ + public class ApplicationUser : IdentityUser + { + public Role Role { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs b/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs index 575e9dad..5816b30f 100644 --- a/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs +++ b/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; + using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace api_cinema_challenge.Models @@ -33,6 +33,6 @@ public class Movie public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; // Navigation property for related screenings - public ICollection Screenings { get; set; } + public ICollection Screenings { get; set; } = new List(); } } diff --git a/api-cinema-challenge/api-cinema-challenge/Program.cs b/api-cinema-challenge/api-cinema-challenge/Program.cs index 49a2d5e6..2b89db61 100644 --- a/api-cinema-challenge/api-cinema-challenge/Program.cs +++ b/api-cinema-challenge/api-cinema-challenge/Program.cs @@ -6,21 +6,59 @@ using api_cinema_challenge.Endpoints; using api_cinema_challenge.Models; using api_cinema_challenge.Repository; +using api_cinema_challenge.Services; using api_cinema_challenge.Validators.CustomerValidators; using api_cinema_challenge.Validators.MovieValidators; using api_cinema_challenge.Validators.ScreeningValidators; using api_cinema_challenge.Validators.TicketValidators; using FluentValidation; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; using Scalar.AspNetCore; using System.Diagnostics; +using System.Text; +using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); // Add services to the container. +builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); -builder.Services.AddOpenApi(); + +builder.Services.AddSwaggerGen(option => +{ + option.SwaggerDoc("v1", new OpenApiInfo { Title = "Test API", Version = "v1" }); + option.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Description = "Please enter a valid token", + Name = "Authorization", + Type = SecuritySchemeType.Http, + BearerFormat = "JWT", + Scheme = "Bearer" + }); + option.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + new string[] { } + } + }); +}); + +builder.Services.AddProblemDetails(); + +builder.Services.AddRouting(options => options.LowercaseUrls = true); builder.Services.AddDbContext(options => { options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnectionString")); options.LogTo(message => Debug.WriteLine(message)); @@ -37,22 +75,88 @@ builder.Services.AddScoped, MoviePutValidator>(); builder.Services.AddScoped, ScreeningPostValidator>(); builder.Services.AddScoped, TicketPostValidator>(); +// Register Token Service +builder.Services.AddScoped(); -var app = builder.Build(); +// Support string to enum conversions +builder.Services.AddControllers().AddJsonOptions(opt => +{ + opt.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); +}); -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) + +// Specify identity requirements +// Must be added before .AddAuthentication otherwise a 404 is thrown on authorized endpoints +builder.Services + .AddIdentity(options => + { + options.SignIn.RequireConfirmedAccount = false; + options.User.RequireUniqueEmail = true; + options.Password.RequireDigit = false; + options.Password.RequiredLength = 6; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequireUppercase = false; + }) + .AddRoles() + .AddEntityFrameworkStores(); + +// These will eventually be moved to a secrets file, but for alpha development appsettings is fine +var validIssuer = builder.Configuration.GetValue("JwtTokenSettings:ValidIssuer"); +var validAudience = builder.Configuration.GetValue("JwtTokenSettings:ValidAudience"); +var symmetricSecurityKey = builder.Configuration.GetValue("JwtTokenSettings:SymmetricSecurityKey"); + +builder.Services.AddAuthentication(options => { - app.MapOpenApi(); - app.UseSwaggerUI(options => + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; +}) + .AddJwtBearer(options => { - options.SwaggerEndpoint("/openapi/v1.json", "Demo API"); + options.IncludeErrorDetails = true; + options.TokenValidationParameters = new TokenValidationParameters() + { + ClockSkew = TimeSpan.Zero, + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = validIssuer, + ValidAudience = validAudience, + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(symmetricSecurityKey) + ), + }; }); - app.MapScalarApiReference(); + +// policy-based authorization for Admin and User roles +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("Admin", policy => + policy.RequireRole("Admin")); + options.AddPolicy("User", policy => + policy.RequireRole("User")); +}); + +// build the app +var app = builder.Build(); + +// Configure the HTTP request pipeline +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); } app.UseHttpsRedirection(); +app.UseStatusCodePages(); + +app.UseAuthentication(); +app.UseAuthorization(); + app.ConfigureCustomerEndpoint(); app.ConfigureMovieEndpoint(); + +app.MapControllers(); app.Run(); diff --git a/api-cinema-challenge/api-cinema-challenge/Services/TokenService.cs b/api-cinema-challenge/api-cinema-challenge/Services/TokenService.cs new file mode 100644 index 00000000..740071cf --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Services/TokenService.cs @@ -0,0 +1,82 @@ +using api_cinema_challenge.Models; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace api_cinema_challenge.Services +{ + public class TokenService + { + private const int ExpirationMinutes = 60; + private readonly ILogger _logger; + public TokenService(ILogger logger) + { + _logger = logger; + } + + public string CreateToken(ApplicationUser user) + { + + var expiration = DateTime.UtcNow.AddMinutes(ExpirationMinutes); + var token = CreateJwtToken( + CreateClaims(user), + CreateSigningCredentials(), + expiration + ); + var tokenHandler = new JwtSecurityTokenHandler(); + + _logger.LogInformation("JWT Token created"); + + return tokenHandler.WriteToken(token); + } + + private JwtSecurityToken CreateJwtToken(List claims, SigningCredentials credentials, + DateTime expiration) => + new( + new ConfigurationBuilder().AddJsonFile("appsettings.json").Build().GetSection("JwtTokenSettings")["ValidIssuer"], + new ConfigurationBuilder().AddJsonFile("appsettings.json").Build().GetSection("JwtTokenSettings")["ValidAudience"], + claims, + expires: expiration, + signingCredentials: credentials + ); + + private List CreateClaims(ApplicationUser user) + { + var jwtSub = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build().GetSection("JwtTokenSettings")["JwtRegisteredClaimNamesSub"]; + + try + { + var claims = new List + { + new Claim(JwtRegisteredClaimNames.Sub, jwtSub), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()), + new Claim(ClaimTypes.NameIdentifier, user.Id), + new Claim(ClaimTypes.Name, user.UserName), + new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.Role, user.Role.ToString()) + }; + + return claims; + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + } + + private SigningCredentials CreateSigningCredentials() + { + var symmetricSecurityKey = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build().GetSection("JwtTokenSettings")["SymmetricSecurityKey"]; + + return new SigningCredentials( + new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(symmetricSecurityKey) + ), + SecurityAlgorithms.HmacSha256 + ); + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj index 20fcbc09..6d55aba1 100644 --- a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj +++ b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj @@ -7,13 +7,6 @@ api_cinema_challenge - - - - - - - @@ -23,6 +16,7 @@ +