diff --git a/DeveloperTest/Business/CustomerService.cs b/DeveloperTest/Business/CustomerService.cs new file mode 100644 index 0000000..a056803 --- /dev/null +++ b/DeveloperTest/Business/CustomerService.cs @@ -0,0 +1,66 @@ +using DeveloperTest.Business.Interfaces; +using DeveloperTest.Database; +using DeveloperTest.Database.Models; +using DeveloperTest.Mappers; +using DeveloperTest.Models; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace DeveloperTest.Business +{ + public class CustomerService : ICustomerService + { + private readonly ApplicationDbContext context; + + public CustomerService(ApplicationDbContext context) + { + this.context = context; + } + + public async ValueTask CreateCustomerAsync(BaseCustomerModel model) + { + if (!Enum.TryParse(value: model.Type, ignoreCase: true, out CustomerType result)) + { + throw new ArgumentException($"{nameof(CustomerType)} must be valid."); + } + + var customer = context.Customers.Add(new Customer + { + Name = model.Name, + Type = result + }); + + await context.SaveChangesAsync(); + + return customer + .Entity + .ToModel(); + } + + public async ValueTask GetCustomerAsync(int id) + { + var customer = await context.Customers.FindAsync(id); + return customer?.ToModel(); + } + + public async ValueTask> GetCustomersAsync() + { + return await context.Customers + .Select(customer => new CustomerModel + { + CustomerId = customer.CustomerId, + Name = customer.Name, + Type = customer.Type.ToString() + }) + .ToArrayAsync(); + } + + public IEnumerable GetTypes() + { + return typeof(CustomerType).GetEnumNames(); + } + } +} diff --git a/DeveloperTest/Business/Interfaces/ICustomerService.cs b/DeveloperTest/Business/Interfaces/ICustomerService.cs new file mode 100644 index 0000000..df94254 --- /dev/null +++ b/DeveloperTest/Business/Interfaces/ICustomerService.cs @@ -0,0 +1,14 @@ +using DeveloperTest.Models; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace DeveloperTest.Business.Interfaces +{ + public interface ICustomerService + { + ValueTask> GetCustomersAsync(); + ValueTask GetCustomerAsync(int id); + ValueTask CreateCustomerAsync(BaseCustomerModel model); + IEnumerable GetTypes(); + } +} diff --git a/DeveloperTest/Business/JobService.cs b/DeveloperTest/Business/JobService.cs index 7eb8f64..1a56e92 100644 --- a/DeveloperTest/Business/JobService.cs +++ b/DeveloperTest/Business/JobService.cs @@ -1,38 +1,50 @@ -using System.Linq; +using System; +using System.Linq; using DeveloperTest.Business.Interfaces; using DeveloperTest.Database; using DeveloperTest.Database.Models; using DeveloperTest.Models; +using Microsoft.EntityFrameworkCore; namespace DeveloperTest.Business { public class JobService : IJobService { private readonly ApplicationDbContext context; + private readonly ICustomerService customerService; - public JobService(ApplicationDbContext context) + public JobService(ApplicationDbContext context, + ICustomerService customerService) { this.context = context; + this.customerService = customerService; } public JobModel[] GetJobs() { - return context.Jobs.Select(x => new JobModel - { - JobId = x.JobId, - Engineer = x.Engineer, - When = x.When - }).ToArray(); + return context.Jobs + .Include(j => j.Customer) + .Select(x => new JobModel + { + JobId = x.JobId, + Engineer = x.Engineer, + When = x.When, + CustomerName = x.Customer.Name + }).ToArray(); } public JobModel GetJob(int jobId) { - return context.Jobs.Where(x => x.JobId == jobId).Select(x => new JobModel - { - JobId = x.JobId, - Engineer = x.Engineer, - When = x.When - }).SingleOrDefault(); + return context.Jobs + .Include(c => c.Customer) + .Where(x => x.JobId == jobId).Select(x => new JobModel + { + JobId = x.JobId, + Engineer = x.Engineer, + CustomerId = x.CustomerId.Value, + CustomerName = x.Customer.Name, + When = x.When + }).SingleOrDefault(); } public JobModel CreateJob(BaseJobModel model) @@ -40,6 +52,8 @@ public JobModel CreateJob(BaseJobModel model) var addedJob = context.Jobs.Add(new Job { Engineer = model.Engineer, + CustomerId = model.CustomerId.HasValue + ? model.CustomerId.Value : throw new ArgumentException($"{nameof(model.CustomerId)} cannot be null"), When = model.When }); diff --git a/DeveloperTest/Controllers/CustomerController.cs b/DeveloperTest/Controllers/CustomerController.cs new file mode 100644 index 0000000..4fb8f47 --- /dev/null +++ b/DeveloperTest/Controllers/CustomerController.cs @@ -0,0 +1,64 @@ +using System; +using Microsoft.AspNetCore.Mvc; +using DeveloperTest.Business.Interfaces; +using DeveloperTest.Models; +using System.Threading.Tasks; +using DeveloperTest.Database.Models; + +namespace DeveloperTest.Controllers +{ + [ApiController, Route("[controller]")] + public class CustomerController : ControllerBase + { + private readonly ICustomerService customerService; + + public CustomerController(ICustomerService customerService) + { + this.customerService = customerService; + } + + [HttpGet] + public async ValueTask Get() + { + return Ok(await customerService.GetCustomersAsync()); + } + + [HttpGet("{id}")] + public async ValueTask Get(int id) + { + var customer = await customerService.GetCustomerAsync(id); + + if (customer == null) + { + return NotFound(); + } + + return Ok(customer); + } + + [HttpPost] + public async ValueTask Create(BaseCustomerModel model) + { + const int minCustomerNameLength = 5; + if (model.Name.Length < 5) + { + return BadRequest($"Customer name cannot have less than {minCustomerNameLength} characters"); + } + + if (!Enum.TryParse(value: model.Type, ignoreCase: true, out CustomerType result)) + { + return BadRequest($"Customer type must be chosen."); + } + + var customer = await customerService.CreateCustomerAsync(model); + + return Created($"customer/{customer.CustomerId}", customer); + } + + [HttpGet("types")] + public IActionResult GetTypes() + { + return Ok(customerService.GetTypes()); + } + } +} \ No newline at end of file diff --git a/DeveloperTest/Controllers/JobController.cs b/DeveloperTest/Controllers/JobController.cs index 2ce1c0e..e9ad68d 100644 --- a/DeveloperTest/Controllers/JobController.cs +++ b/DeveloperTest/Controllers/JobController.cs @@ -39,7 +39,12 @@ public IActionResult Create(BaseJobModel model) { if (model.When.Date < DateTime.Now.Date) { - return BadRequest("Date cannot be in the past"); + return BadRequest("Date cannot be in the past."); + } + + if (model.CustomerId is null) + { + return BadRequest("Job must have a customer."); } var job = jobService.CreateJob(model); diff --git a/DeveloperTest/Database/ApplicationDbContext.cs b/DeveloperTest/Database/ApplicationDbContext.cs index f5be4a1..bf8f624 100644 --- a/DeveloperTest/Database/ApplicationDbContext.cs +++ b/DeveloperTest/Database/ApplicationDbContext.cs @@ -7,6 +7,7 @@ namespace DeveloperTest.Database public class ApplicationDbContext : DbContext { public DbSet Jobs { get; set; } + public DbSet Customers { get; set; } public ApplicationDbContext(DbContextOptions options) : base(options) { @@ -15,7 +16,20 @@ public ApplicationDbContext(DbContextOptions options) : ba protected override void OnModelCreating(ModelBuilder modelBuilder) { - base.OnModelCreating(modelBuilder); + modelBuilder.Entity() + .HasKey(x => x.CustomerId); + + modelBuilder.Entity() + .Property(x => x.CustomerId) + .ValueGeneratedOnAdd(); + + modelBuilder.Entity() + .HasData(new Customer + { + CustomerId = 1, + Name = "TestCustomer", + Type = CustomerType.Small + }); modelBuilder.Entity() .HasKey(x => x.JobId); @@ -24,6 +38,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .Property(x => x.JobId) .ValueGeneratedOnAdd(); + modelBuilder.Entity() + .HasOne(j => j.Customer) + .WithMany(c => c.Jobs); + modelBuilder.Entity() .HasData(new Job { @@ -31,6 +49,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) Engineer = "Test", When = new DateTime(2022, 2, 1, 12, 0, 0) }); + + base.OnModelCreating(modelBuilder); + } } } diff --git a/DeveloperTest/Database/Models/Customer.cs b/DeveloperTest/Database/Models/Customer.cs new file mode 100644 index 0000000..529fa8a --- /dev/null +++ b/DeveloperTest/Database/Models/Customer.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace DeveloperTest.Database.Models +{ + public class Customer + { + public int CustomerId { get; set; } + public string Name { get; set; } + public CustomerType Type { get; set; } + public virtual ICollection Jobs { get; set; } + } + + public enum CustomerType + { + Small, + Large + } +} diff --git a/DeveloperTest/Database/Models/Job.cs b/DeveloperTest/Database/Models/Job.cs index 8a2abd0..fe0e6a1 100644 --- a/DeveloperTest/Database/Models/Job.cs +++ b/DeveloperTest/Database/Models/Job.cs @@ -5,9 +5,9 @@ namespace DeveloperTest.Database.Models public class Job { public int JobId { get; set; } - public string Engineer { get; set; } - public DateTime When { get; set; } + public int? CustomerId { get; set; } + public virtual Customer Customer { get; set; } } } diff --git a/DeveloperTest/Mappers/CustomerMappingExtensions.cs b/DeveloperTest/Mappers/CustomerMappingExtensions.cs new file mode 100644 index 0000000..7b7b841 --- /dev/null +++ b/DeveloperTest/Mappers/CustomerMappingExtensions.cs @@ -0,0 +1,31 @@ +using DeveloperTest.Database.Models; +using DeveloperTest.Models; +using System; + +namespace DeveloperTest.Mappers +{ + public static class CustomerMappingExtensions + { + public static Customer ToCustomerDbEntity(this CustomerModel source) + { + return new Customer() + { + CustomerId = source.CustomerId, + Name = source.Name, + Type = Enum.TryParse(source.Type, out CustomerType type) + ? type + : throw new ArgumentException("Invalid customer type. Cannot parse the customer type."), + }; + } + + public static CustomerModel ToModel(this Customer source) + { + return new CustomerModel() + { + CustomerId = source.CustomerId, + Name = source.Name, + Type = source.Type.ToString() + }; + } + } +} diff --git a/DeveloperTest/Migrations/20220331182019_AddedCustomerTable.Designer.cs b/DeveloperTest/Migrations/20220331182019_AddedCustomerTable.Designer.cs new file mode 100644 index 0000000..1ce984d --- /dev/null +++ b/DeveloperTest/Migrations/20220331182019_AddedCustomerTable.Designer.cs @@ -0,0 +1,83 @@ +// +using System; +using DeveloperTest.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DeveloperTest.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20220331182019_AddedCustomerTable")] + partial class AddedCustomerTable + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); + + modelBuilder.Entity("DeveloperTest.Database.Models.Customer", b => + { + b.Property("CustomerId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("CustomerId"), 1L, 1); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("CustomerId"); + + b.ToTable("Customers"); + + b.HasData( + new + { + CustomerId = 1, + Name = "TestCustomer", + Type = 0 + }); + }); + + modelBuilder.Entity("DeveloperTest.Database.Models.Job", b => + { + b.Property("JobId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("JobId"), 1L, 1); + + b.Property("Engineer") + .HasColumnType("nvarchar(max)"); + + b.Property("When") + .HasColumnType("datetime2"); + + b.HasKey("JobId"); + + b.ToTable("Jobs"); + + b.HasData( + new + { + JobId = 1, + Engineer = "Test", + When = new DateTime(2022, 2, 1, 12, 0, 0, 0, DateTimeKind.Unspecified) + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DeveloperTest/Migrations/20220331182019_AddedCustomerTable.cs b/DeveloperTest/Migrations/20220331182019_AddedCustomerTable.cs new file mode 100644 index 0000000..6ec6fb3 --- /dev/null +++ b/DeveloperTest/Migrations/20220331182019_AddedCustomerTable.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DeveloperTest.Migrations +{ + public partial class AddedCustomerTable : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Customers", + columns: table => new + { + CustomerId = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(max)", nullable: true), + Type = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Customers", x => x.CustomerId); + }); + + migrationBuilder.InsertData( + table: "Customers", + columns: new[] { "CustomerId", "Name", "Type" }, + values: new object[] { 1, "TestCustomer", 0 }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Customers"); + } + } +} diff --git a/DeveloperTest/Migrations/20220401183035_AddedJobNullableCustomerIdRelation.Designer.cs b/DeveloperTest/Migrations/20220401183035_AddedJobNullableCustomerIdRelation.Designer.cs new file mode 100644 index 0000000..41c321e --- /dev/null +++ b/DeveloperTest/Migrations/20220401183035_AddedJobNullableCustomerIdRelation.Designer.cs @@ -0,0 +1,102 @@ +// +using System; +using DeveloperTest.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DeveloperTest.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20220401183035_AddedJobNullableCustomerIdRelation")] + partial class AddedJobNullableCustomerIdRelation + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); + + modelBuilder.Entity("DeveloperTest.Database.Models.Customer", b => + { + b.Property("CustomerId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("CustomerId"), 1L, 1); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("CustomerId"); + + b.ToTable("Customers"); + + b.HasData( + new + { + CustomerId = 1, + Name = "TestCustomer", + Type = 0 + }); + }); + + modelBuilder.Entity("DeveloperTest.Database.Models.Job", b => + { + b.Property("JobId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("JobId"), 1L, 1); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("Engineer") + .HasColumnType("nvarchar(max)"); + + b.Property("When") + .HasColumnType("datetime2"); + + b.HasKey("JobId"); + + b.HasIndex("CustomerId"); + + b.ToTable("Jobs"); + + b.HasData( + new + { + JobId = 1, + Engineer = "Test", + When = new DateTime(2022, 2, 1, 12, 0, 0, 0, DateTimeKind.Unspecified) + }); + }); + + modelBuilder.Entity("DeveloperTest.Database.Models.Job", b => + { + b.HasOne("DeveloperTest.Database.Models.Customer", "Customer") + .WithMany("Jobs") + .HasForeignKey("CustomerId"); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("DeveloperTest.Database.Models.Customer", b => + { + b.Navigation("Jobs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DeveloperTest/Migrations/20220401183035_AddedJobNullableCustomerIdRelation.cs b/DeveloperTest/Migrations/20220401183035_AddedJobNullableCustomerIdRelation.cs new file mode 100644 index 0000000..bd24a00 --- /dev/null +++ b/DeveloperTest/Migrations/20220401183035_AddedJobNullableCustomerIdRelation.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DeveloperTest.Migrations +{ + public partial class AddedJobNullableCustomerIdRelation : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CustomerId", + table: "Jobs", + type: "int", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Jobs_CustomerId", + table: "Jobs", + column: "CustomerId"); + + migrationBuilder.AddForeignKey( + name: "FK_Jobs_Customers_CustomerId", + table: "Jobs", + column: "CustomerId", + principalTable: "Customers", + principalColumn: "CustomerId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Jobs_Customers_CustomerId", + table: "Jobs"); + + migrationBuilder.DropIndex( + name: "IX_Jobs_CustomerId", + table: "Jobs"); + + migrationBuilder.DropColumn( + name: "CustomerId", + table: "Jobs"); + } + } +} diff --git a/DeveloperTest/Migrations/ApplicationDbContextModelSnapshot.cs b/DeveloperTest/Migrations/ApplicationDbContextModelSnapshot.cs index 0ee623b..a173f3b 100644 --- a/DeveloperTest/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/DeveloperTest/Migrations/ApplicationDbContextModelSnapshot.cs @@ -22,6 +22,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); + modelBuilder.Entity("DeveloperTest.Database.Models.Customer", b => + { + b.Property("CustomerId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("CustomerId"), 1L, 1); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("CustomerId"); + + b.ToTable("Customers"); + + b.HasData( + new + { + CustomerId = 1, + Name = "TestCustomer", + Type = 0 + }); + }); + modelBuilder.Entity("DeveloperTest.Database.Models.Job", b => { b.Property("JobId") @@ -30,6 +57,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("JobId"), 1L, 1); + b.Property("CustomerId") + .HasColumnType("int"); + b.Property("Engineer") .HasColumnType("nvarchar(max)"); @@ -38,6 +68,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("JobId"); + b.HasIndex("CustomerId"); + b.ToTable("Jobs"); b.HasData( @@ -48,6 +80,20 @@ protected override void BuildModel(ModelBuilder modelBuilder) When = new DateTime(2022, 2, 1, 12, 0, 0, 0, DateTimeKind.Unspecified) }); }); + + modelBuilder.Entity("DeveloperTest.Database.Models.Job", b => + { + b.HasOne("DeveloperTest.Database.Models.Customer", "Customer") + .WithMany("Jobs") + .HasForeignKey("CustomerId"); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("DeveloperTest.Database.Models.Customer", b => + { + b.Navigation("Jobs"); + }); #pragma warning restore 612, 618 } } diff --git a/DeveloperTest/Models/BaseCustomerModel.cs b/DeveloperTest/Models/BaseCustomerModel.cs new file mode 100644 index 0000000..01aaba0 --- /dev/null +++ b/DeveloperTest/Models/BaseCustomerModel.cs @@ -0,0 +1,11 @@ +using DeveloperTest.Database.Models; + +namespace DeveloperTest.Models +{ + public class BaseCustomerModel + { + public string Name { get; set; } + + public string Type { get; set; } + } +} diff --git a/DeveloperTest/Models/BaseJobModel.cs b/DeveloperTest/Models/BaseJobModel.cs index d2bc052..4318963 100644 --- a/DeveloperTest/Models/BaseJobModel.cs +++ b/DeveloperTest/Models/BaseJobModel.cs @@ -5,7 +5,7 @@ namespace DeveloperTest.Models public class BaseJobModel { public string Engineer { get; set; } - public DateTime When { get; set; } + public int? CustomerId { get; set; } } } diff --git a/DeveloperTest/Models/CustomerModel.cs b/DeveloperTest/Models/CustomerModel.cs new file mode 100644 index 0000000..c5b96db --- /dev/null +++ b/DeveloperTest/Models/CustomerModel.cs @@ -0,0 +1,13 @@ +using DeveloperTest.Database.Models; + +namespace DeveloperTest.Models +{ + public class CustomerModel + { + public int CustomerId { get; set; } + + public string Name { get; set; } + + public string Type { get; set; } + } +} diff --git a/DeveloperTest/Models/JobModel.cs b/DeveloperTest/Models/JobModel.cs index 8f2b5be..0047801 100644 --- a/DeveloperTest/Models/JobModel.cs +++ b/DeveloperTest/Models/JobModel.cs @@ -5,9 +5,9 @@ namespace DeveloperTest.Models public class JobModel { public int JobId { get; set; } - public string Engineer { get; set; } - + public int? CustomerId { get; set; } + public string CustomerName { get; set; } public DateTime When { get; set; } } } diff --git a/DeveloperTest/Program.cs b/DeveloperTest/Program.cs index 507adef..b4913ad 100644 --- a/DeveloperTest/Program.cs +++ b/DeveloperTest/Program.cs @@ -1,4 +1,7 @@ +using DeveloperTest.Database; using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace DeveloperTest @@ -7,7 +10,16 @@ public class Program { public static void Main(string[] args) { - CreateHostBuilder(args).Build().Run(); + var host = CreateHostBuilder(args).Build(); + + // run migrations during startup + using (var scope = host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } + + host.Run(); } public static IHostBuilder CreateHostBuilder(string[] args) => diff --git a/DeveloperTest/Startup.cs b/DeveloperTest/Startup.cs index 5242f22..62832ef 100644 --- a/DeveloperTest/Startup.cs +++ b/DeveloperTest/Startup.cs @@ -7,6 +7,7 @@ using DeveloperTest.Business; using DeveloperTest.Business.Interfaces; using DeveloperTest.Database; +using System; namespace DeveloperTest { @@ -25,9 +26,19 @@ public void ConfigureServices(IServiceCollection services) services.AddControllers(); services.AddDbContext(options => - options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"))); + { + options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")); + + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + var isDevelopment = environment == Environments.Development; + if (isDevelopment) + { + options.LogTo(Console.WriteLine); + } + }); services.AddTransient(); + services.AddTransient(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. diff --git a/DeveloperTest/readme.md b/DeveloperTest/readme.md new file mode 100644 index 0000000..0e2bcf0 --- /dev/null +++ b/DeveloperTest/readme.md @@ -0,0 +1,20 @@ +Improvement ideas +# implement repository pattern to decouple data access layer + +TODO LIST + +1. Create a screen that allows a user to manage a list of Customers, the Customer should have a Name and a Type. + [+] FE Name is required and must have a minimum length of 5 characters + [+] BE Name is required and must have a minimum length of 5 characters + [+] Type is required and should be a select box of either "Large" or "Small". + [+] There should be a form to add a new customer (like on the jobs page). + [+] There should be a list to see all the customers (like on the jobs page). + [+] There should be a link on the list to open the customer record (like on the jobs page). + +2. The jobs page needs to be extended to allow assigning a job to a customer. + [+] When creating a job the user should be able to pick a customer from the dropdown. + [+] FE Selecting a customer should be required for creating a job. + [+] BE Selecting a customer should be required for creating a job. + [+] The user should be able to see assigned customer in the jobs list. + [+] For any existing jobs that were not assigned to a customer it should display "Unknown" in the list. + [+] When the user opens the job details from the list this screen should include information about the Customer - Name and Type. \ No newline at end of file diff --git a/ui/package-lock.json b/ui/package-lock.json index abc8434..35b49aa 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -28,7 +28,7 @@ "@angular/language-service": "~13.2.0", "@types/jasmine": "~3.3.8", "@types/jasminewd2": "~2.0.3", - "@types/node": "~8.9.4", + "@types/node": "^17.0.23", "codelyzer": "^6.0.0", "jasmine-core": "~3.4.0", "jasmine-spec-reporter": "~4.2.1", @@ -2519,9 +2519,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "8.9.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-8.9.5.tgz", - "integrity": "sha512-jRHfWsvyMtXdbhnz5CVHxaBgnV6duZnPlQuRSo/dm/GnmikNcmZhxIES4E9OZjUmQ8C+HCl4KJux+cXN/ErGDQ==", + "version": "17.0.23", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.23.tgz", + "integrity": "sha512-UxDxWn7dl97rKVeVS61vErvw086aCYhDLyvRQZ5Rk65rZKepaFdm53GeqXaKBuOhED4e9uWq34IC3TdSdJJ2Gw==", "dev": true }, "node_modules/@types/parse-json": { @@ -5032,12 +5032,6 @@ "node": ">=10.0.0" } }, - "node_modules/engine.io/node_modules/@types/node": { - "version": "17.0.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.14.tgz", - "integrity": "sha512-SbjLmERksKOGzWzPNuW7fJM7fk3YXVTFiZWB/Hs99gwhk+/dnrQRPBQjPW9aO+fi1tAffi9PrwFvsmOKmDTyng==", - "dev": true - }, "node_modules/enhanced-resolve": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.8.3.tgz", @@ -14649,9 +14643,9 @@ "dev": true }, "@types/node": { - "version": "8.9.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-8.9.5.tgz", - "integrity": "sha512-jRHfWsvyMtXdbhnz5CVHxaBgnV6duZnPlQuRSo/dm/GnmikNcmZhxIES4E9OZjUmQ8C+HCl4KJux+cXN/ErGDQ==", + "version": "17.0.23", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.23.tgz", + "integrity": "sha512-UxDxWn7dl97rKVeVS61vErvw086aCYhDLyvRQZ5Rk65rZKepaFdm53GeqXaKBuOhED4e9uWq34IC3TdSdJJ2Gw==", "dev": true }, "@types/parse-json": { @@ -16628,14 +16622,6 @@ "debug": "~4.3.1", "engine.io-parser": "~5.0.0", "ws": "~8.2.3" - }, - "dependencies": { - "@types/node": { - "version": "17.0.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.14.tgz", - "integrity": "sha512-SbjLmERksKOGzWzPNuW7fJM7fk3YXVTFiZWB/Hs99gwhk+/dnrQRPBQjPW9aO+fi1tAffi9PrwFvsmOKmDTyng==", - "dev": true - } } }, "engine.io-parser": { diff --git a/ui/package.json b/ui/package.json index f0f1942..71db3fb 100644 --- a/ui/package.json +++ b/ui/package.json @@ -31,7 +31,7 @@ "@angular/language-service": "~13.2.0", "@types/jasmine": "~3.3.8", "@types/jasminewd2": "~2.0.3", - "@types/node": "~8.9.4", + "@types/node": "^17.0.23", "codelyzer": "^6.0.0", "jasmine-core": "~3.4.0", "jasmine-spec-reporter": "~4.2.1", @@ -45,4 +45,4 @@ "tslint": "~6.1.3", "typescript": "~4.5.5" } -} \ No newline at end of file +} diff --git a/ui/src/app/app-routing.module.ts b/ui/src/app/app-routing.module.ts index a6af4f8..90b239e 100644 --- a/ui/src/app/app-routing.module.ts +++ b/ui/src/app/app-routing.module.ts @@ -3,13 +3,17 @@ import { Routes, RouterModule } from '@angular/router'; import { JobComponent } from './job/job.component'; import { HomeComponent } from './home/home.component'; import { JobDetailComponent } from './job-detail/job-detail.component'; +import { CustomerComponent } from './customer/customer.component'; +import { CustomerDetailComponent } from './customer-detail/customer-detail.component'; const routes: Routes = [ { path: '', component: HomeComponent }, { path: 'home', component: HomeComponent }, { path: 'jobs', component: JobComponent }, - { path: 'job/:id', component: JobDetailComponent } + { path: 'job/:id', component: JobDetailComponent }, + { path: 'customers', component: CustomerComponent }, + { path: 'customer/:id', component: CustomerDetailComponent } ]; @NgModule({ diff --git a/ui/src/app/app.component.html b/ui/src/app/app.component.html index 49133ec..94af8ff 100644 --- a/ui/src/app/app.component.html +++ b/ui/src/app/app.component.html @@ -2,6 +2,7 @@ \ No newline at end of file diff --git a/ui/src/app/app.module.ts b/ui/src/app/app.module.ts index 0d1f678..6b95279 100644 --- a/ui/src/app/app.module.ts +++ b/ui/src/app/app.module.ts @@ -8,13 +8,17 @@ import { AppComponent } from './app.component'; import { JobComponent } from './job/job.component'; import { HomeComponent } from './home/home.component'; import { JobDetailComponent } from './job-detail/job-detail.component'; +import { CustomerComponent } from './customer/customer.component'; +import { CustomerDetailComponent } from './customer-detail/customer-detail.component'; @NgModule({ declarations: [ AppComponent, JobComponent, HomeComponent, - JobDetailComponent + JobDetailComponent, + CustomerComponent, + CustomerDetailComponent ], imports: [ FormsModule, diff --git a/ui/src/app/customer-detail/customer-detail.component.html b/ui/src/app/customer-detail/customer-detail.component.html new file mode 100644 index 0000000..d72d4c2 --- /dev/null +++ b/ui/src/app/customer-detail/customer-detail.component.html @@ -0,0 +1,5 @@ +

CustomerId: {{customer.customerId}}

+

Name: {{customer.name}}

+

Customer type: {{customer.type}}

+ +Back \ No newline at end of file diff --git a/ui/src/app/customer-detail/customer-detail.component.scss b/ui/src/app/customer-detail/customer-detail.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/app/customer-detail/customer-detail.component.spec.ts b/ui/src/app/customer-detail/customer-detail.component.spec.ts new file mode 100644 index 0000000..8ae027e --- /dev/null +++ b/ui/src/app/customer-detail/customer-detail.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CustomerDetailComponent } from './customer-detail.component'; + +describe('CustomerDetailComponent', () => { + let component: CustomerDetailComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ CustomerDetailComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CustomerDetailComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/customer-detail/customer-detail.component.ts b/ui/src/app/customer-detail/customer-detail.component.ts new file mode 100644 index 0000000..21328b5 --- /dev/null +++ b/ui/src/app/customer-detail/customer-detail.component.ts @@ -0,0 +1,33 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { CustomerModel } from '../models/customer.model'; +import { CustomerService } from '../services/customer.service'; + +@Component({ + selector: 'app-customer-detail', + templateUrl: './customer-detail.component.html', + styleUrls: ['./customer-detail.component.scss'] +}) +export class CustomerDetailComponent implements OnInit { + + public customerId: number; + public customer: CustomerModel; + sub: Subscription | undefined; + + constructor( + private route: ActivatedRoute, + private customerService: CustomerService) { + this.customerId = route.snapshot.params.id; + } + + ngOnInit(): void { + this.sub = this.customerService.GetCustomer(this.customerId) + .subscribe(customer => this.customer = customer); + } + + ngOnDestroy() { + this.sub?.unsubscribe(); + } + +} diff --git a/ui/src/app/customer/customer.component.html b/ui/src/app/customer/customer.component.html new file mode 100644 index 0000000..9799f53 --- /dev/null +++ b/ui/src/app/customer/customer.component.html @@ -0,0 +1,33 @@ +

New customer form

+
+ + + Invalid customer name + + + Please select a customer type + +
+ +

Customers list

+ + + + + + + + + + + + + + + +
NameCustomer type
{{customer.name}}{{customer.type}} + Open +
\ No newline at end of file diff --git a/ui/src/app/customer/customer.component.scss b/ui/src/app/customer/customer.component.scss new file mode 100644 index 0000000..2591986 --- /dev/null +++ b/ui/src/app/customer/customer.component.scss @@ -0,0 +1,43 @@ +h2 { + margin-left: 15px; +} + +form { + margin: 15px; + + label { + display: block; + } + + input, + select, + button { + display: block; + width: 250px; + margin-bottom: 15px; + } + + small { + color: red; + margin-top: -12px; + margin-bottom: 15px; + display: block; + } +} + +table { + margin: 15px; + border-collapse: collapse; + + th, + td { + border: 1px solid #ddd; + padding: 5px; + min-width: 100px; + text-align: left; + } + + th { + background-color: #ddd; + } +} diff --git a/ui/src/app/customer/customer.component.spec.ts b/ui/src/app/customer/customer.component.spec.ts new file mode 100644 index 0000000..2092a8c --- /dev/null +++ b/ui/src/app/customer/customer.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CustomerComponent } from './customer.component'; + +describe('CustomerComponent', () => { + let component: CustomerComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ CustomerComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CustomerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/customer/customer.component.ts b/ui/src/app/customer/customer.component.ts new file mode 100644 index 0000000..bd00ab2 --- /dev/null +++ b/ui/src/app/customer/customer.component.ts @@ -0,0 +1,52 @@ +import { forEach } from '@angular-devkit/schematics'; +import { Component, OnInit } from '@angular/core'; +import { NgForm } from '@angular/forms'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { CustomerModel } from '../models/customer.model'; +import { CustomerService } from '../services/customer.service'; + +@Component({ + selector: 'app-customer', + templateUrl: './customer.component.html', + styleUrls: ['./customer.component.scss'] +}) +export class CustomerComponent implements OnInit { + + private subs: Subscription[] = []; + public customerTypes: string[] = []; + public customers: CustomerModel[] = []; + public newCustomer: CustomerModel = { + customerId: null, + name: null, + type: null + }; + + constructor(private customerService: CustomerService) { } + + ngOnInit(): void { + const getCustomersSub = this.customerService.GetCustomers() + .subscribe(customers => this.customers = customers); + this.subs.push(getCustomersSub); + + const getCustomerTypeSub = this.customerService.GetCustomerTypes() + .subscribe(customerTypes => this.customerTypes = customerTypes); + this.subs.push(getCustomerTypeSub); + } + + public createCustomer(form: NgForm): void { + if (form.invalid) { + alert('form is not valid'); + } else { + this.customerService.CreateCustomer(this.newCustomer).then(() => { + this.subs.push(this.customerService.GetCustomers().subscribe(jobs => this.customers = jobs)); + }); + } + } + + ngOnDestroy() { + this.subs.forEach(sub => { + sub?.unsubscribe(); + }); + } + +} diff --git a/ui/src/app/job/job.component.html b/ui/src/app/job/job.component.html index 085c531..a44930d 100644 --- a/ui/src/app/job/job.component.html +++ b/ui/src/app/job/job.component.html @@ -5,7 +5,12 @@

New job form

- Please select an engineer + + + Please select a customer Please select a valid date @@ -17,6 +22,7 @@

Jobs list

Engineer + Customer When @@ -24,6 +30,7 @@

Jobs list

{{job.engineer}} + {{job.customerName ? job.customerName : unknown}} {{job.when | date:'shortDate'}} Open diff --git a/ui/src/app/job/job.component.ts b/ui/src/app/job/job.component.ts index e9de751..85ba351 100644 --- a/ui/src/app/job/job.component.ts +++ b/ui/src/app/job/job.component.ts @@ -3,6 +3,9 @@ import { NgForm } from '@angular/forms'; import { EngineerService } from '../services/engineer.service'; import { JobService } from '../services/job.service'; import { JobModel } from '../models/job.model'; +import { CustomerModel } from '../models/customer.model'; +import { CustomerService } from '../services/customer.service'; +import { Subscription } from 'rxjs/internal/Subscription'; @Component({ selector: 'app-job', @@ -11,23 +14,33 @@ import { JobModel } from '../models/job.model'; }) export class JobComponent implements OnInit { + private subs: Subscription[] = []; public engineers: string[] = []; - + public customers: CustomerModel[] = []; public jobs: JobModel[] = []; - public newJob: JobModel = { jobId: null, engineer: null, + customerId: null, + customerName: null, when: null }; + public selectedCustomer: CustomerModel = undefined; + public readonly unknown = 'Unknown'; constructor( private engineerService: EngineerService, + private customerService: CustomerService, private jobService: JobService) { } ngOnInit() { - this.engineerService.GetEngineers().subscribe(engineers => this.engineers = engineers); - this.jobService.GetJobs().subscribe(jobs => this.jobs = jobs); + const getEngineersSubscription = this.engineerService.GetEngineers().subscribe(engineers => this.engineers = engineers); + this.subs.push(getEngineersSubscription); + const getCustomersSubscription = this.customerService.GetCustomers().subscribe(customers => this.customers = customers); + this.subs.push(getCustomersSubscription); + const getJobsSubscription = this.jobService.GetJobs().subscribe(jobs => this.jobs = jobs); + this.subs.push(getJobsSubscription); + } public createJob(form: NgForm): void { @@ -40,4 +53,15 @@ export class JobComponent implements OnInit { } } + onSelectCustomer(e: any): void { + this.selectedCustomer = e; + this.newJob.customerId = this.selectedCustomer.customerId; + this.newJob.customerName = this.selectedCustomer.name; + } + + ngOnDestroy() { + this.subs.forEach(sub => { + sub?.unsubscribe(); + }); + } } diff --git a/ui/src/app/models/customer.model.ts b/ui/src/app/models/customer.model.ts new file mode 100644 index 0000000..ca50302 --- /dev/null +++ b/ui/src/app/models/customer.model.ts @@ -0,0 +1,10 @@ +export interface CustomerModel { + customerId: number; + name: string; + type: CustomerType; +} + +export enum CustomerType { + Small, + Large +} diff --git a/ui/src/app/models/job.model.ts b/ui/src/app/models/job.model.ts index 5c3342c..cc3718f 100644 --- a/ui/src/app/models/job.model.ts +++ b/ui/src/app/models/job.model.ts @@ -1,5 +1,7 @@ export interface JobModel { jobId: number; engineer: string; + customerId: number; + customerName: string; when: Date; } diff --git a/ui/src/app/services/customer.service.spec.ts b/ui/src/app/services/customer.service.spec.ts new file mode 100644 index 0000000..adabb97 --- /dev/null +++ b/ui/src/app/services/customer.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { CustomerService } from './customer.service'; + +describe('CustomerService', () => { + let service: CustomerService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(CustomerService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/ui/src/app/services/customer.service.ts b/ui/src/app/services/customer.service.ts new file mode 100644 index 0000000..00ee164 --- /dev/null +++ b/ui/src/app/services/customer.service.ts @@ -0,0 +1,29 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { environment } from 'src/environments/environment'; +import { CustomerModel } from '../models/customer.model'; + +@Injectable({ + providedIn: 'root' +}) + +export class CustomerService { + constructor(private httpClient: HttpClient) { } + + public GetCustomerTypes(): Observable { + return this.httpClient.get(environment.apiUrl + '/customer/types'); + } + + public GetCustomers(): Observable { + return this.httpClient.get(environment.apiUrl + '/customer'); + } + + public GetCustomer(customerId: number): Observable { + return this.httpClient.get(environment.apiUrl + `/customer/${customerId}`); + } + + public CreateCustomer(customer: CustomerModel): Promise { + return this.httpClient.post(environment.apiUrl + '/customer', customer).toPromise(); + } +} diff --git a/ui/src/environments/environment.ts b/ui/src/environments/environment.ts index 7b4f817..6433347 100644 --- a/ui/src/environments/environment.ts +++ b/ui/src/environments/environment.ts @@ -3,7 +3,8 @@ // The list of file replacements can be found in `angular.json`. export const environment = { - production: false + production: false, + apiUrl: "http://localhost:63235" }; /*