diff --git a/api-cinema-challenge/api-cinema-challenge/Controllers/CustomersController.cs b/api-cinema-challenge/api-cinema-challenge/Controllers/CustomersController.cs new file mode 100644 index 00000000..54059690 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Controllers/CustomersController.cs @@ -0,0 +1,51 @@ +using api_cinema_challenge.Dtos; +using api_cinema_challenge.Models; +using api_cinema_challenge.Repositories; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace api_cinema_challenge.Controllers +{ + [ApiController] + [Route("customers")] + public class CustomersController : ControllerBase + { + private readonly ICustomerRepository _repo; + public CustomersController(ICustomerRepository repo) => _repo = repo; + + [HttpPost] + [Authorize(Roles = "Admin")] + public async Task> Create(CreateCustomer req) + { + var c = await _repo.AddAsync(new Customer { Name = req.Name, Email = req.Email, Phone = req.Phone }); + return CreatedAtAction(nameof(GetAll), null, + new CustomerDto(c.Id, c.Name, c.Email, c.Phone, c.CreatedAt, c.UpdatedAt)); + } + + [HttpGet] + [Authorize(Roles = "Admin")] + public async Task>> GetAll() + { + var list = await _repo.GetAllAsync(); + return Ok(list.Select(c => new CustomerDto(c.Id, c.Name, c.Email, c.Phone, c.CreatedAt, c.UpdatedAt))); + } + + [HttpPut("{id:int}")] + [Authorize(Roles = "Admin")] + public async Task> Update(int id, CreateCustomer req) + { + var updated = await _repo.UpdateAsync(id, new Customer { Name = req.Name, Email = req.Email, Phone = req.Phone }); + if (updated == null) return NotFound(); + return Ok(new CustomerDto(updated.Id, updated.Name, updated.Email, updated.Phone, updated.CreatedAt, updated.UpdatedAt)); + } + + [HttpDelete("{id:int}")] + [Authorize(Roles = "Admin")] + public async Task> Delete(int id) + { + var deleted = await _repo.DeleteAsync(id); + if (deleted == null) return NotFound(); + return Ok(new CustomerDto(deleted.Id, deleted.Name, deleted.Email, deleted.Phone, deleted.CreatedAt, deleted.UpdatedAt)); + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Controllers/MoviesController.cs b/api-cinema-challenge/api-cinema-challenge/Controllers/MoviesController.cs new file mode 100644 index 00000000..a7ede775 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Controllers/MoviesController.cs @@ -0,0 +1,81 @@ +using api_cinema_challenge.Dtos; +using api_cinema_challenge.Models; +using api_cinema_challenge.Repositories; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace api_cinema_challenge.Controllers +{ + [ApiController] + [Route("movies")] + public class MoviesController : ControllerBase + { + private readonly IMovieRepository _movies; + private readonly IScreeningRepository _screenings; + + public MoviesController(IMovieRepository movies, IScreeningRepository screenings) + { _movies = movies; _screenings = screenings; } + + [HttpPost] + [Authorize(Roles = "Admin")] + public async Task> Create(CreateMovie req) + { + var m = await _movies.AddAsync(new Movie { Title = req.Title, Rating = req.Rating, Description = req.Description, RuntimeMins = req.RuntimeMins }); + return CreatedAtAction(nameof(GetAll), null, + new MovieDto(m.Id, m.Title, m.Rating, m.Description, m.RuntimeMins, m.CreatedAt, m.UpdatedAt)); + } + + [HttpGet] + [Authorize] + public async Task>> GetAll() + { + var list = await _movies.GetAllAsync(); + return Ok(list.Select(m => new MovieDto(m.Id, m.Title, m.Rating, m.Description, m.RuntimeMins, m.CreatedAt, m.UpdatedAt))); + } + + [HttpPut("{id:int}")] + [Authorize(Roles = "Admin")] + public async Task> Update(int id, UpdateMovie req) + { + var updated = await _movies.UpdateAsync(id, new Movie { Title = req.Title, Rating = req.Rating, Description = req.Description, RuntimeMins = req.RuntimeMins }); + if (updated == null) return NotFound(); + return Ok(new MovieDto(updated.Id, updated.Title, updated.Rating, updated.Description, updated.RuntimeMins, updated.CreatedAt, updated.UpdatedAt)); + } + + [HttpDelete("{id:int}")] + [Authorize(Roles = "Admin")] + public async Task> Delete(int id) + { + var deleted = await _movies.DeleteAsync(id); + if (deleted == null) return NotFound(); + return Ok(new MovieDto(deleted.Id, deleted.Title, deleted.Rating, deleted.Description, deleted.RuntimeMins, deleted.CreatedAt, deleted.UpdatedAt)); + } + + [HttpPost("{id:int}/screenings")] + [Authorize(Roles = "Admin")] + public async Task> CreateScreening(int id, CreateScreening req) + { + var startsUtc = req.StartsAt.Kind == DateTimeKind.Utc + ? req.StartsAt + : DateTime.SpecifyKind(req.StartsAt, DateTimeKind.Utc); + + var s = await _screenings.AddForMovieAsync(id, new Screening + { + ScreenNumber = req.ScreenNumber, + Capacity = req.Capacity, + StartsAt = startsUtc + }); + + return CreatedAtAction(nameof(GetScreenings), new { id }, + new ScreeningDto(s.Id, s.ScreenNumber, s.Capacity, s.StartsAt, s.CreatedAt, s.UpdatedAt)); + } + + [HttpGet("{id:int}/screenings")] + [Authorize] + public async Task>> GetScreenings(int id) + { + var list = await _screenings.GetForMovieAsync(id); + return Ok(list.Select(s => new ScreeningDto(s.Id, s.ScreenNumber, s.Capacity, s.StartsAt, s.CreatedAt, s.UpdatedAt))); + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Controllers/UsersController.cs b/api-cinema-challenge/api-cinema-challenge/Controllers/UsersController.cs new file mode 100644 index 00000000..08540096 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Controllers/UsersController.cs @@ -0,0 +1,101 @@ +using api_cinema_challenge.Data; +using api_cinema_challenge.DataTransfer.Requests; +using api_cinema_challenge.DataTransfer.Response; +using api_cinema_challenge.Enum; +using api_cinema_challenge.Models; +using api_cinema_challenge.Services; +using Microsoft.AspNetCore.Authorization; +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.ApplicationUsers.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, + }); + } + } +} \ No newline at end of file diff --git a/api-cinema-challenge/api-cinema-challenge/DTO/CustomerDTO.cs b/api-cinema-challenge/api-cinema-challenge/DTO/CustomerDTO.cs new file mode 100644 index 00000000..7251be6a --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTO/CustomerDTO.cs @@ -0,0 +1,5 @@ +namespace api_cinema_challenge.Dtos +{ + public record CreateCustomer(string Name, string Email, string Phone); + public record CustomerDto(int Id, string Name, string Email, string Phone, DateTime CreatedAt, DateTime UpdatedAt); +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTO/MovieDTO.cs b/api-cinema-challenge/api-cinema-challenge/DTO/MovieDTO.cs new file mode 100644 index 00000000..94afd17b --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTO/MovieDTO.cs @@ -0,0 +1,6 @@ +namespace api_cinema_challenge.Dtos +{ + public record CreateMovie(string Title, string Rating, string Description, int RuntimeMins); + public record UpdateMovie(string Title, string Rating, string Description, int RuntimeMins); + public record MovieDto(int Id, string Title, string Rating, string Description, int RuntimeMins, DateTime CreatedAt, DateTime UpdatedAt); +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTO/ScreeningDTO.cs b/api-cinema-challenge/api-cinema-challenge/DTO/ScreeningDTO.cs new file mode 100644 index 00000000..b8967228 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTO/ScreeningDTO.cs @@ -0,0 +1,5 @@ +namespace api_cinema_challenge.Dtos +{ + public record CreateScreening(int ScreenNumber, int Capacity, DateTime StartsAt); + public record ScreeningDto(int Id, int ScreenNumber, int Capacity, DateTime StartsAt, DateTime CreatedAt, DateTime UpdatedAt); +} diff --git a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs index ad4fe854..db717d76 100644 --- a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs +++ b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs @@ -1,5 +1,5 @@ using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json.Linq; +using api_cinema_challenge.Models; namespace api_cinema_challenge.Data { @@ -18,9 +18,72 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) optionsBuilder.UseNpgsql(_connectionString); } + public DbSet Customers => Set(); + public DbSet Movies => Set(); + public DbSet Screenings => Set(); + public DbSet Tickets => Set(); + public DbSet ApplicationUsers { get; set; } + protected override void OnModelCreating(ModelBuilder modelBuilder) { + // UTC timestamptz for tracked timestamps + modelBuilder.Entity().Property(p => p.CreatedAt).HasColumnType("timestamptz"); + modelBuilder.Entity().Property(p => p.UpdatedAt).HasColumnType("timestamptz"); + modelBuilder.Entity().Property(p => p.CreatedAt).HasColumnType("timestamptz"); + modelBuilder.Entity().Property(p => p.UpdatedAt).HasColumnType("timestamptz"); + modelBuilder.Entity().Property(p => p.StartsAt).HasColumnType("timestamptz"); + modelBuilder.Entity().Property(p => p.CreatedAt).HasColumnType("timestamptz"); + modelBuilder.Entity().Property(p => p.UpdatedAt).HasColumnType("timestamptz"); + modelBuilder.Entity().Property(p => p.CreatedAt).HasColumnType("timestamptz"); + modelBuilder.Entity().Property(p => p.UpdatedAt).HasColumnType("timestamptz"); + + // Screening -> Movie + modelBuilder.Entity() + .HasOne(s => s.Movie) + .WithMany(m => m.Screenings) + .HasForeignKey(s => s.MovieId) + .OnDelete(DeleteBehavior.Cascade); + + // Ticket table + relations + modelBuilder.Entity().ToTable("ticket"); + modelBuilder.Entity() + .HasOne(t => t.Customer) + .WithMany() + .HasForeignKey(t => t.CustomerId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(t => t.Screening) + .WithMany() + .HasForeignKey(t => t.ScreeningId) + .OnDelete(DeleteBehavior.Cascade); + + // Required fields per spec + modelBuilder.Entity().Property(m => m.Title).IsRequired(); + modelBuilder.Entity().Property(m => m.Rating).IsRequired(); + modelBuilder.Entity().Property(m => m.Description).IsRequired(); + modelBuilder.Entity().Property(m => m.RuntimeMins).IsRequired(); + modelBuilder.Entity().Property(c => c.Name).IsRequired(); + modelBuilder.Entity().Property(c => c.Email).IsRequired(); + modelBuilder.Entity().Property(c => c.Phone).IsRequired(); + } + + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + foreach (var e in ChangeTracker.Entries()) + { + if (e.State == EntityState.Added) + { + if (e.Metadata.FindProperty("CreatedAt") != null) e.CurrentValues["CreatedAt"] = now; + if (e.Metadata.FindProperty("UpdatedAt") != null) e.CurrentValues["UpdatedAt"] = now; + } + else if (e.State == EntityState.Modified) + { + if (e.Metadata.FindProperty("UpdatedAt") != null) e.CurrentValues["UpdatedAt"] = now; + } + } + return base.SaveChangesAsync(cancellationToken); } } } diff --git a/api-cinema-challenge/api-cinema-challenge/Data/DbSeeder.cs b/api-cinema-challenge/api-cinema-challenge/Data/DbSeeder.cs new file mode 100644 index 00000000..df389e2c --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Data/DbSeeder.cs @@ -0,0 +1,67 @@ +using api_cinema_challenge.Models; +using Microsoft.EntityFrameworkCore; + +namespace api_cinema_challenge.Data +{ + public static class DbSeeder + { + public static async Task SeedAsync(CinemaContext ctx) + { + if (!await ctx.Customers.AnyAsync()) + { + ctx.Customers.AddRange( + new Customer { Name = "Ada Lovelace", Email = "ada@example.com", Phone = "+47 900 00 001" }, + new Customer { Name = "Alan Turing", Email = "alan@example.com", Phone = "+47 900 00 002" } + ); + await ctx.SaveChangesAsync(); + } + + if (!await ctx.Movies.AnyAsync()) + { + ctx.Movies.AddRange( + new Movie { Title = "Oppenheimer", Rating = "15+", Description = "Biographical drama.", RuntimeMins = 180 }, + new Movie { Title = "Barbie", Rating = "9+", Description = "Adventure comedy.", RuntimeMins = 114 } + ); + await ctx.SaveChangesAsync(); + } + + if (!await ctx.Screenings.AnyAsync()) + { + var now = DateTime.UtcNow; + var movies = await ctx.Movies.AsNoTracking().ToListAsync(); + + var s1 = new Screening + { + MovieId = movies[0].Id, + ScreenNumber = 1, + Capacity = 120, + StartsAt = new DateTime(now.Year, now.Month, now.Day, 18, 0, 0, DateTimeKind.Utc).AddDays(1) + }; + var s2 = new Screening + { + MovieId = movies[1].Id, + ScreenNumber = 2, + Capacity = 100, + StartsAt = new DateTime(now.Year, now.Month, now.Day, 20, 0, 0, DateTimeKind.Utc).AddDays(2) + }; + + ctx.Screenings.AddRange(s1, s2); + await ctx.SaveChangesAsync(); + } + + if (!await ctx.Tickets.AnyAsync()) + { + var customer = await ctx.Customers.FirstAsync(); + var screening = await ctx.Screenings.FirstAsync(); + + ctx.Tickets.Add(new Ticket + { + CustomerId = customer.Id, + ScreeningId = screening.Id, + SeatLabel = "B12" + }); + await ctx.SaveChangesAsync(); + } + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/AuthRequest.cs b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/AuthRequest.cs new file mode 100644 index 00000000..811ba311 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/AuthRequest.cs @@ -0,0 +1,12 @@ +namespace api_cinema_challenge.DataTransfer.Requests; + +public class AuthRequest +{ + public string? Email { get; set; } + public string? Password { get; set; } + + public bool IsValid() + { + return true; + } +} \ No newline at end of file diff --git a/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/RegistrationRequest.cs b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/RegistrationRequest.cs new file mode 100644 index 00000000..573618c1 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/RegistrationRequest.cs @@ -0,0 +1,19 @@ +namespace api_cinema_challenge.DataTransfer.Requests; + +using api_cinema_challenge.Enum; +using System.ComponentModel.DataAnnotations; + + +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; +} \ No newline at end of file diff --git a/api-cinema-challenge/api-cinema-challenge/DataTransfer/Response/AuthResponse.cs b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Response/AuthResponse.cs new file mode 100644 index 00000000..7b448154 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Response/AuthResponse.cs @@ -0,0 +1,9 @@ +namespace api_cinema_challenge.DataTransfer.Response; + + +public class AuthResponse +{ + public string? Username { get; set; } + public string? Email { get; set; } + public string? Token { get; set; } +} \ No newline at end of file diff --git a/api-cinema-challenge/api-cinema-challenge/Enum/Role.cs b/api-cinema-challenge/api-cinema-challenge/Enum/Role.cs new file mode 100644 index 00000000..e4711ba8 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Enum/Role.cs @@ -0,0 +1,8 @@ +namespace api_cinema_challenge.Enum +{ + 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..838188d8 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Helpers/ClaimsPrincipalHelper.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using System.Runtime.CompilerServices; +using System.Security.Claims; + +namespace api_cinema_challenge +{ + 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; + } + + } +} \ No newline at end of file diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250901125359_johanFikserAlt.Designer.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250901125359_johanFikserAlt.Designer.cs new file mode 100644 index 00000000..97983920 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/20250901125359_johanFikserAlt.Designer.cs @@ -0,0 +1,251 @@ +// +using System; +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("20250901125359_johanFikserAlt")] + partial class johanFikserAlt + { + /// + 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.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") + .HasColumnType("text"); + + 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") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("ApplicationUsers"); + }); + + modelBuilder.Entity("api_cinema_challenge.Models.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.HasKey("Id"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("api_cinema_challenge.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Rating") + .IsRequired() + .HasColumnType("text"); + + b.Property("RuntimeMins") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.HasKey("Id"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("api_cinema_challenge.Models.Screening", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Capacity") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("MovieId") + .HasColumnType("integer"); + + b.Property("ScreenNumber") + .HasColumnType("integer"); + + b.Property("StartsAt") + .HasColumnType("timestamptz"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.ToTable("Screenings"); + }); + + modelBuilder.Entity("api_cinema_challenge.Models.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("CustomerId") + .HasColumnType("integer"); + + b.Property("ScreeningId") + .HasColumnType("integer"); + + b.Property("SeatLabel") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("ScreeningId"); + + b.ToTable("ticket", (string)null); + }); + + modelBuilder.Entity("api_cinema_challenge.Models.Screening", b => + { + b.HasOne("api_cinema_challenge.Models.Movie", "Movie") + .WithMany("Screenings") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Migrations/20250901125359_johanFikserAlt.cs b/api-cinema-challenge/api-cinema-challenge/Migrations/20250901125359_johanFikserAlt.cs new file mode 100644 index 00000000..f7fcf68e --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/20250901125359_johanFikserAlt.cs @@ -0,0 +1,164 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace api_cinema_challenge.Migrations +{ + /// + public partial class johanFikserAlt : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ApplicationUsers", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Role = table.Column(type: "integer", nullable: false), + UserName = table.Column(type: "text", nullable: true), + NormalizedUserName = table.Column(type: "text", nullable: true), + Email = table.Column(type: "text", nullable: true), + NormalizedEmail = table.Column(type: "text", 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_ApplicationUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Customers", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + 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: "timestamptz", nullable: false), + UpdatedAt = table.Column(type: "timestamptz", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Customers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Movies", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Title = table.Column(type: "text", nullable: false), + Rating = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: false), + RuntimeMins = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamptz", nullable: false), + UpdatedAt = table.Column(type: "timestamptz", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Movies", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Screenings", + columns: table => new + { + Id = 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: "timestamptz", nullable: false), + CreatedAt = table.Column(type: "timestamptz", nullable: false), + UpdatedAt = table.Column(type: "timestamptz", nullable: false), + MovieId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Screenings", x => x.Id); + table.ForeignKey( + name: "FK_Screenings_Movies_MovieId", + column: x => x.MovieId, + principalTable: "Movies", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ticket", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CustomerId = table.Column(type: "integer", nullable: false), + ScreeningId = table.Column(type: "integer", nullable: false), + SeatLabel = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamptz", nullable: false), + UpdatedAt = table.Column(type: "timestamptz", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ticket", x => x.Id); + table.ForeignKey( + name: "FK_ticket_Customers_CustomerId", + column: x => x.CustomerId, + principalTable: "Customers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ticket_Screenings_ScreeningId", + column: x => x.ScreeningId, + principalTable: "Screenings", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Screenings_MovieId", + table: "Screenings", + column: "MovieId"); + + migrationBuilder.CreateIndex( + name: "IX_ticket_CustomerId", + table: "ticket", + column: "CustomerId"); + + migrationBuilder.CreateIndex( + name: "IX_ticket_ScreeningId", + table: "ticket", + column: "ScreeningId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ApplicationUsers"); + + migrationBuilder.DropTable( + name: "ticket"); + + 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 new file mode 100644 index 00000000..d11a74dd --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Migrations/CinemaContextModelSnapshot.cs @@ -0,0 +1,248 @@ +// +using System; +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.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") + .HasColumnType("text"); + + 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") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("ApplicationUsers"); + }); + + modelBuilder.Entity("api_cinema_challenge.Models.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.HasKey("Id"); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("api_cinema_challenge.Models.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Rating") + .IsRequired() + .HasColumnType("text"); + + b.Property("RuntimeMins") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.HasKey("Id"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("api_cinema_challenge.Models.Screening", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Capacity") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("MovieId") + .HasColumnType("integer"); + + b.Property("ScreenNumber") + .HasColumnType("integer"); + + b.Property("StartsAt") + .HasColumnType("timestamptz"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.ToTable("Screenings"); + }); + + modelBuilder.Entity("api_cinema_challenge.Models.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("CustomerId") + .HasColumnType("integer"); + + b.Property("ScreeningId") + .HasColumnType("integer"); + + b.Property("SeatLabel") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("ScreeningId"); + + b.ToTable("ticket", (string)null); + }); + + modelBuilder.Entity("api_cinema_challenge.Models.Screening", b => + { + b.HasOne("api_cinema_challenge.Models.Movie", "Movie") + .WithMany("Screenings") + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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"); + }); +#pragma warning restore 612, 618 + } + } +} 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..cce01ab4 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/ApplicationUser.cs @@ -0,0 +1,10 @@ +using api_cinema_challenge.Enum; +using Microsoft.AspNetCore.Identity; + +namespace api_cinema_challenge.Models +{ + public class ApplicationUser : IdentityUser + { + public Role Role { get; set; } + } +} \ No newline at end of file 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..28c3a279 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Customer.cs @@ -0,0 +1,12 @@ +namespace api_cinema_challenge.Models +{ + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } = default!; + public string Email { get; set; } = default!; + public string Phone { get; set; } = default!; + public DateTime CreatedAt { get; set; } // UTC + public DateTime UpdatedAt { get; set; } // UTC + } +} 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..3c39cdf0 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs @@ -0,0 +1,15 @@ +namespace api_cinema_challenge.Models +{ + public class Movie + { + public int Id { get; set; } + public string Title { get; set; } = default!; + public string Rating { get; set; } = default!; + public string Description { get; set; } = default!; + public int RuntimeMins { get; set; } + public DateTime CreatedAt { get; set; } // UTC + public DateTime UpdatedAt { get; set; } // UTC + + public ICollection Screenings { get; set; } = new List(); + } +} 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..9f2a8ad9 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Screening.cs @@ -0,0 +1,15 @@ +namespace api_cinema_challenge.Models +{ + public class Screening + { + public int Id { get; set; } + public int ScreenNumber { get; set; } + public int Capacity { get; set; } + public DateTime StartsAt { get; set; } // UTC + public DateTime CreatedAt { get; set; } // UTC + public DateTime UpdatedAt { get; set; } // UTC + + public int MovieId { get; set; } + public Movie Movie { get; set; } = default!; + } +} 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..6c51c96d --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Ticket.cs @@ -0,0 +1,18 @@ +namespace api_cinema_challenge.Models +{ + public class Ticket + { + public int Id { get; set; } + + public int CustomerId { get; set; } + public Customer Customer { get; set; } = default!; + + public int ScreeningId { get; set; } + public Screening Screening { get; set; } = default!; + + public string SeatLabel { get; set; } = default!; // e.g. "B12" + + public DateTime CreatedAt { get; set; } // UTC + public DateTime UpdatedAt { get; set; } // UTC + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Program.cs b/api-cinema-challenge/api-cinema-challenge/Program.cs index e55d9d54..c54d72d9 100644 --- a/api-cinema-challenge/api-cinema-challenge/Program.cs +++ b/api-cinema-challenge/api-cinema-challenge/Program.cs @@ -1,15 +1,124 @@ using api_cinema_challenge.Data; +using api_cinema_challenge.Models; +using api_cinema_challenge.Repositories; +using api_cinema_challenge.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using System.Diagnostics; +using System.Text; +using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); -// Add services to the container. +// Toggle InMemory for quick Swagger testing +//var useInMemory = builder.Configuration.GetValue("UseInMemoryDatabase"); +var cs = builder.Configuration.GetConnectionString("DefaultConnectionString"); + +builder.Services.AddControllers() + .AddJsonOptions(o => o.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase); + builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); -builder.Services.AddDbContext(); +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(opt => +{ + opt.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnectionString")); + opt.LogTo(message => Debug.WriteLine(message)); + +}); + +// repositories +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddScoped(); + +builder.Services.AddControllers().AddJsonOptions(opt => +{ + opt.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); +}); + +// 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 => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; +}) + .AddJwtBearer(options => + { + 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) + ), + }; + }); var app = builder.Build(); -// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseSwagger(); @@ -17,4 +126,18 @@ } app.UseHttpsRedirection(); +app.MapControllers(); + +app.UseStatusCodePages(); + +app.UseAuthentication(); +app.UseAuthorization(); + +using (var scope = app.Services.CreateScope()) +{ + var ctx = scope.ServiceProvider.GetRequiredService(); + //if (!useInMemory) await ctx.Database.MigrateAsync(); // create/update schema for Postgres + await DbSeeder.SeedAsync(ctx); // minimal demo data +} + app.Run(); diff --git a/api-cinema-challenge/api-cinema-challenge/Repositories/CustomerRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repositories/CustomerRepository.cs new file mode 100644 index 00000000..1b9fdec9 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repositories/CustomerRepository.cs @@ -0,0 +1,43 @@ +using api_cinema_challenge.Data; +using api_cinema_challenge.Models; +using Microsoft.EntityFrameworkCore; + +namespace api_cinema_challenge.Repositories +{ + public class CustomerRepository : ICustomerRepository + { + private readonly CinemaContext _ctx; + public CustomerRepository(CinemaContext ctx) => _ctx = ctx; + + public async Task AddAsync(Customer c) + { + _ctx.Customers.Add(c); + await _ctx.SaveChangesAsync(); + return c; + } + + public Task> GetAllAsync() + => _ctx.Customers.AsNoTracking().ToListAsync(); + + public Task GetAsync(int id) + => _ctx.Customers.FindAsync(id).AsTask(); + + public async Task UpdateAsync(int id, Customer update) + { + var c = await _ctx.Customers.FindAsync(id); + if (c == null) return null; + c.Name = update.Name; c.Email = update.Email; c.Phone = update.Phone; + await _ctx.SaveChangesAsync(); + return c; + } + + public async Task DeleteAsync(int id) + { + var c = await _ctx.Customers.FindAsync(id); + if (c == null) return null; + _ctx.Customers.Remove(c); + await _ctx.SaveChangesAsync(); + return c; + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Repositories/ICustomerRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repositories/ICustomerRepository.cs new file mode 100644 index 00000000..738868d8 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repositories/ICustomerRepository.cs @@ -0,0 +1,13 @@ +using api_cinema_challenge.Models; + +namespace api_cinema_challenge.Repositories +{ + public interface ICustomerRepository + { + Task AddAsync(Customer c); + Task> GetAllAsync(); + Task GetAsync(int id); + Task UpdateAsync(int id, Customer update); + Task DeleteAsync(int id); + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Repositories/IMovieRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repositories/IMovieRepository.cs new file mode 100644 index 00000000..3790d7a6 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repositories/IMovieRepository.cs @@ -0,0 +1,13 @@ +using api_cinema_challenge.Models; + +namespace api_cinema_challenge.Repositories +{ + public interface IMovieRepository + { + Task AddAsync(Movie m); + Task> GetAllAsync(); + Task GetAsync(int id); + Task UpdateAsync(int id, Movie update); + Task DeleteAsync(int id); + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Repositories/IScreeningRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repositories/IScreeningRepository.cs new file mode 100644 index 00000000..2a98710f --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repositories/IScreeningRepository.cs @@ -0,0 +1,10 @@ +using api_cinema_challenge.Models; + +namespace api_cinema_challenge.Repositories +{ + public interface IScreeningRepository + { + Task AddForMovieAsync(int movieId, Screening s); + Task> GetForMovieAsync(int movieId); + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Repositories/ITicketRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repositories/ITicketRepository.cs new file mode 100644 index 00000000..7763a8a9 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repositories/ITicketRepository.cs @@ -0,0 +1,10 @@ +using api_cinema_challenge.Models; + +namespace api_cinema_challenge.Repositories +{ + public interface ITicketRepository + { + Task AddAsync(Ticket t); + Task> GetByScreeningAsync(int screeningId); + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Repositories/MovieRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repositories/MovieRepository.cs new file mode 100644 index 00000000..13d59605 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repositories/MovieRepository.cs @@ -0,0 +1,43 @@ +using api_cinema_challenge.Data; +using api_cinema_challenge.Models; +using Microsoft.EntityFrameworkCore; + +namespace api_cinema_challenge.Repositories +{ + public class MovieRepository : IMovieRepository + { + private readonly CinemaContext _ctx; + public MovieRepository(CinemaContext ctx) => _ctx = ctx; + + public async Task AddAsync(Movie m) + { + _ctx.Movies.Add(m); + await _ctx.SaveChangesAsync(); + return m; + } + + public Task> GetAllAsync() + => _ctx.Movies.AsNoTracking().ToListAsync(); + + public Task GetAsync(int id) + => _ctx.Movies.FindAsync(id).AsTask(); + + public async Task UpdateAsync(int id, Movie update) + { + var m = await _ctx.Movies.FindAsync(id); + if (m == null) return null; + m.Title = update.Title; m.Rating = update.Rating; m.Description = update.Description; m.RuntimeMins = update.RuntimeMins; + await _ctx.SaveChangesAsync(); + return m; + } + + public async Task DeleteAsync(int id) + { + var m = await _ctx.Movies.FindAsync(id); + if (m == null) return null; + _ctx.Movies.Remove(m); + await _ctx.SaveChangesAsync(); + return m; + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Repositories/ScreeningRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repositories/ScreeningRepository.cs new file mode 100644 index 00000000..ae37d768 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repositories/ScreeningRepository.cs @@ -0,0 +1,29 @@ +using api_cinema_challenge.Data; +using api_cinema_challenge.Models; +using Microsoft.EntityFrameworkCore; + +namespace api_cinema_challenge.Repositories +{ + public class ScreeningRepository : IScreeningRepository + { + private readonly CinemaContext _ctx; + public ScreeningRepository(CinemaContext ctx) => _ctx = ctx; + + public async Task AddForMovieAsync(int movieId, Screening s) + { + if (s.StartsAt.Kind != DateTimeKind.Utc) + s.StartsAt = DateTime.SpecifyKind(s.StartsAt, DateTimeKind.Utc); + + s.MovieId = movieId; + _ctx.Screenings.Add(s); + await _ctx.SaveChangesAsync(); + return s; + } + + public Task> GetForMovieAsync(int movieId) + => _ctx.Screenings.AsNoTracking() + .Where(x => x.MovieId == movieId) + .OrderBy(x => x.StartsAt) + .ToListAsync(); + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Repositories/TicketRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repositories/TicketRepository.cs new file mode 100644 index 00000000..378403fd --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repositories/TicketRepository.cs @@ -0,0 +1,24 @@ +using api_cinema_challenge.Data; +using api_cinema_challenge.Models; +using Microsoft.EntityFrameworkCore; + +namespace api_cinema_challenge.Repositories +{ + public class TicketRepository : ITicketRepository + { + private readonly CinemaContext _ctx; + public TicketRepository(CinemaContext ctx) => _ctx = ctx; + + public async Task AddAsync(Ticket t) + { + _ctx.Tickets.Add(t); + await _ctx.SaveChangesAsync(); + return t; + } + + public Task> GetByScreeningAsync(int screeningId) + => _ctx.Tickets.AsNoTracking() + .Where(t => t.ScreeningId == screeningId) + .ToListAsync(); + } +} 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..e4c830df --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Services/TokenService.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Http.HttpResults; +using api_cinema_challenge.Models; +using Microsoft.AspNetCore.Identity; +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 + ); + } +} \ No newline at end of file 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..a72816a4 100644 --- a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj +++ b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -8,27 +8,27 @@ - - - - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + all runtime; build; native; contentfiles; analyzers; buildtransitive + - - - - - + + + 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 diff --git a/api-cinema-challenge/package-lock.json b/api-cinema-challenge/package-lock.json new file mode 100644 index 00000000..c8994855 --- /dev/null +++ b/api-cinema-challenge/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "api-cinema-challenge", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}