diff --git a/Controllers/AuthController.cs b/Controllers/AuthController.cs new file mode 100644 index 0000000..2a38cfd --- /dev/null +++ b/Controllers/AuthController.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Mvc; +using LogisticsApp.Server.Services; +using LogisticsApp.Server.DTOs; + +namespace LogisticsApp.Server.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class AuthController : ControllerBase + { + private readonly IAuthService _authService; + + public AuthController(IAuthService authService) + { + _authService = authService; + } + + [HttpPost("login")] + public async Task>> Login(LoginRequest request) + { + + if (string.IsNullOrEmpty(request.Username) || string.IsNullOrEmpty(request.Password)) + { + return BadRequest(new ApiResponse(false, "Username and password are required")); + } + + var result = await _authService.LoginAsync(request); + + if (result == null) + { + return Unauthorized(new ApiResponse(false, "Invalid username or password")); + } + + Response.Cookies.Append("auth_token", result.Token, new CookieOptions + { + HttpOnly = false, + Secure = false, + SameSite = SameSiteMode.Strict, + Expires = result.Expires + }); + + return Ok(new ApiResponse(true, "Login successful", result)); + } + + [HttpPost("logout")] + public ActionResult> Logout() + { + Response.Cookies.Delete("auth_token"); + return Ok(new ApiResponse(true, "Logout successful")); + } + } +} \ No newline at end of file diff --git a/Controllers/OrdersController.cs b/Controllers/OrdersController.cs new file mode 100644 index 0000000..3970197 --- /dev/null +++ b/Controllers/OrdersController.cs @@ -0,0 +1,205 @@ +using LogisticsApp.Server.Data; +using LogisticsApp.Server.DTOs; +using LogisticsApp.Server.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace LogisticsApp.Server.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class OrdersController : ControllerBase + { + private readonly ApplicationDbContext _context; + + public OrdersController(ApplicationDbContext context) + { + _context = context; + } + + // GET: api/orders + [Authorize] + [HttpGet] + public async Task>>> GetOrders([FromQuery] OrderFilters filters) + { + try + { + var query = _context.Orders.AsQueryable(); + + + if (!string.IsNullOrEmpty(filters.ClientName)) + { + query = query.Where(o => o.ClientName.ToLower().Contains(filters.ClientName.ToLower())); + } + + if (filters.MinCost.HasValue) + { + query = query.Where(o => o.OrderCost >= filters.MinCost.Value); + } + + if (filters.MaxCost.HasValue) + { + query = query.Where(o => o.OrderCost <= filters.MaxCost.Value); + } + + if (filters.OrderDate.HasValue) + { + query = query.Where(o => o.OrderDate.Date == filters.OrderDate.Value.Date); + } + + if (!string.IsNullOrEmpty(filters.Status)) + { + query = query.Where(o => o.Status == filters.Status); + } + + var totalCount = await query.CountAsync(); + + + var orders = await query + .OrderByDescending(o => o.OrderDate) + .Skip((filters.Page - 1) * filters.PageSize) + .Take(filters.PageSize) + .ToListAsync(); + + var result = new PagedResult + { + Items = orders, + TotalCount = totalCount, + Page = filters.Page, + PageSize = filters.PageSize, + TotalPages = (int)Math.Ceiling(totalCount / (double)filters.PageSize) + }; + + return Ok(new ApiResponse>(true, "Orders retrieved successfully", result)); + } + catch (Exception ex) + { + return StatusCode(500, new ApiResponse(false, $"Internal server error: {ex.Message}")); + } + } + + // GET: api/orders/5 + [Authorize] + [HttpGet("{id}")] + public async Task>> GetOrder(int id) + { + var order = await _context.Orders.FindAsync(id); + + if (order == null) + { + return NotFound(new ApiResponse(false, "Order not found")); + } + + return Ok(new ApiResponse(true, "Order retrieved successfully", order)); + } + + // PUT: api/orders/5 + [Authorize] + [HttpPut("{id}")] + public async Task>> PutOrder(int id, Order order) + { + if (id != order.Id) + { + return BadRequest(new ApiResponse(false, "ID mismatch")); + } + + + var validStatuses = new[] { "pending", "in_progress", "completed", "cancelled" }; + if (!validStatuses.Contains(order.Status)) + { + return BadRequest(new ApiResponse(false, "Invalid status")); + } + + + if (order.OrderCost <= 0) + { + return BadRequest(new ApiResponse(false, "Order cost must be positive")); + } + + + _context.Entry(order).State = EntityState.Modified; + + try + { + await _context.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!OrderExists(id)) + { + return NotFound(new ApiResponse(false, "Order not found")); + } + else + { + throw; + } + } + + return Ok(new ApiResponse(true, "Order updated successfully", order)); + } + + // POST: api/orders + [Authorize] + [HttpPost] + public async Task>> PostOrder(Order order) + { + + if (string.IsNullOrEmpty(order.ClientName) || order.ClientName.Length > 200) + { + return BadRequest(new ApiResponse(false, "Client name is required and must be less than 200 characters")); + } + + if (order.OrderCost <= 0) + { + return BadRequest(new ApiResponse(false, "Order cost must be positive")); + } + + if (string.IsNullOrEmpty(order.Address) || order.Address.Length > 500) + { + return BadRequest(new ApiResponse(false, "Address is required and must be less than 500 characters")); + } + + var validStatuses = new[] { "pending", "in_progress", "completed", "cancelled" }; + if (!validStatuses.Contains(order.Status)) + { + return BadRequest(new ApiResponse(false, "Invalid status")); + } + + _context.Orders.Add(order); + await _context.SaveChangesAsync(); + + return CreatedAtAction("GetOrder", new { id = order.Id }, + new ApiResponse(true, "Order created successfully", order)); + } + + // DELETE: api/orders/5 + [Authorize] + [HttpDelete("{id}")] + public async Task>> DeleteOrder(int id) + { + try + { + var order = await _context.Orders.FindAsync(id); + if (order == null) + { + return NotFound(new ApiResponse(false, "Order not found")); + } + + _context.Orders.Remove(order); + await _context.SaveChangesAsync(); + + return Ok(new ApiResponse(true, "Order deleted successfully")); + } + catch (Exception ex) + { + return StatusCode(500, new ApiResponse(false, $"Internal server error: {ex.Message}")); + } + } + + private bool OrderExists(int id) + { + return _context.Orders.Any(e => e.Id == id); + } + } +} \ No newline at end of file diff --git a/Controllers/VehiclesController.cs b/Controllers/VehiclesController.cs new file mode 100644 index 0000000..c1b0d4d --- /dev/null +++ b/Controllers/VehiclesController.cs @@ -0,0 +1,214 @@ + +using LogisticsApp.Server.Data; +using LogisticsApp.Server.DTOs; +using LogisticsApp.Server.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace LogisticsApp.Server.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class VehiclesController : ControllerBase + { + private readonly ApplicationDbContext _context; + + public VehiclesController(ApplicationDbContext context) + { + _context = context; + } + + // GET: api/vehicles + [Authorize] + [HttpGet] + public async Task>>> GetVehicles([FromQuery] VehicleFilters filters) + { + try + { + var query = _context.Vehicles.AsQueryable(); + + + if (!string.IsNullOrEmpty(filters.DriverName)) + { + query = query.Where(v => v.DriverName.ToLower().Contains(filters.DriverName.ToLower())); + } + + if (!string.IsNullOrEmpty(filters.VehicleType)) + { + query = query.Where(v => v.VehicleType.ToLower().Contains(filters.VehicleType.ToLower())); + } + + if (!string.IsNullOrEmpty(filters.LicensePlate)) + { + query = query.Where(v => v.LicensePlate.ToLower().Contains(filters.LicensePlate.ToLower())); + } + + var totalCount = await query.CountAsync(); + + var vehicles = await query + .OrderBy(v => v.DriverName) + .Skip((filters.Page - 1) * filters.PageSize) + .Take(filters.PageSize) + .ToListAsync(); + + var result = new PagedResult + { + Items = vehicles, + TotalCount = totalCount, + Page = filters.Page, + PageSize = filters.PageSize, + TotalPages = (int)Math.Ceiling(totalCount / (double)filters.PageSize) + }; + + return Ok(new ApiResponse>(true, "Vehicles retrieved successfully", result)); + } + catch (Exception ex) + { + return StatusCode(500, new ApiResponse(false, $"Internal server error: {ex.Message}")); + } + } + + // GET: api/vehicles/5 + [Authorize] + [HttpGet("{id}")] + public async Task>> GetVehicle(int id) + { + try + { + var vehicle = await _context.Vehicles + .FirstOrDefaultAsync(v => v.Id == id); + + if (vehicle == null) + { + return NotFound(new ApiResponse(false, "Vehicle not found")); + } + + return Ok(new ApiResponse(true, "Vehicle retrieved successfully", vehicle)); + } + catch (Exception ex) + { + return StatusCode(500, new ApiResponse(false, $"Internal server error: {ex.Message}")); + } + } + + // POST: api/vehicles + [Authorize] + [HttpPost] + public async Task>> PostVehicle(Vehicle vehicle) + { + try + { + + if (string.IsNullOrEmpty(vehicle.DriverName) || vehicle.DriverName.Length > 100) + { + return BadRequest(new ApiResponse(false, "Driver name is required and must be less than 100 characters")); + } + + if (string.IsNullOrEmpty(vehicle.VehicleType) || vehicle.VehicleType.Length > 50) + { + return BadRequest(new ApiResponse(false, "Vehicle type is required and must be less than 50 characters")); + } + + if (string.IsNullOrEmpty(vehicle.LicensePlate) || vehicle.LicensePlate.Length > 20) + { + return BadRequest(new ApiResponse(false, "License plate is required and must be less than 20 characters")); + } + + vehicle.CreatedAt = DateTime.UtcNow; + _context.Vehicles.Add(vehicle); + await _context.SaveChangesAsync(); + + return CreatedAtAction("GetVehicle", new { id = vehicle.Id }, + new ApiResponse(true, "Vehicle created successfully", vehicle)); + } + catch (Exception ex) + { + return StatusCode(500, new ApiResponse(false, $"Internal server error: {ex.Message}")); + } + } + + // PUT: api/vehicles/5 + [Authorize] + [HttpPut("{id}")] + public async Task>> PutVehicle(int id, Vehicle vehicle) + { + try + { + if (id != vehicle.Id) + { + return BadRequest(new ApiResponse(false, "ID mismatch")); + } + + + if (string.IsNullOrEmpty(vehicle.DriverName) || vehicle.DriverName.Length > 100) + { + return BadRequest(new ApiResponse(false, "Driver name is required and must be less than 100 characters")); + } + + if (string.IsNullOrEmpty(vehicle.VehicleType) || vehicle.VehicleType.Length > 50) + { + return BadRequest(new ApiResponse(false, "Vehicle type is required and must be less than 50 characters")); + } + + if (string.IsNullOrEmpty(vehicle.LicensePlate) || vehicle.LicensePlate.Length > 20) + { + return BadRequest(new ApiResponse(false, "License plate is required and must be less than 20 characters")); + } + + _context.Entry(vehicle).State = EntityState.Modified; + + try + { + await _context.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!VehicleExists(id)) + { + return NotFound(new ApiResponse(false, "Vehicle not found")); + } + else + { + throw; + } + } + + return Ok(new ApiResponse(true, "Vehicle updated successfully", vehicle)); + } + catch (Exception ex) + { + return StatusCode(500, new ApiResponse(false, $"Internal server error: {ex.Message}")); + } + } + + // DELETE: api/vehicles/5 + [Authorize] + [HttpDelete("{id}")] + public async Task>> DeleteVehicle(int id) + { + try + { + var vehicle = await _context.Vehicles.FindAsync(id); + if (vehicle == null) + { + return NotFound(new ApiResponse(false, "Vehicle not found")); + } + + _context.Vehicles.Remove(vehicle); + await _context.SaveChangesAsync(); + + return Ok(new ApiResponse(true, "Vehicle deleted successfully")); + } + catch (Exception ex) + { + return StatusCode(500, new ApiResponse(false, $"Internal server error: {ex.Message}")); + } + } + + private bool VehicleExists(int id) + { + return _context.Vehicles.Any(e => e.Id == id); + } + } +} \ No newline at end of file diff --git a/Controllers/WaybillEntriesController.cs b/Controllers/WaybillEntriesController.cs new file mode 100644 index 0000000..938bdc6 --- /dev/null +++ b/Controllers/WaybillEntriesController.cs @@ -0,0 +1,231 @@ + +using LogisticsApp.Server.Data; +using LogisticsApp.Server.DTOs; +using LogisticsApp.Server.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace LogisticsApp.Server.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class WaybillEntriesController : ControllerBase + { + private readonly ApplicationDbContext _context; + + public WaybillEntriesController(ApplicationDbContext context) + { + _context = context; + } + + // GET: api/waybillentries + [Authorize] + [HttpGet] + public async Task>>> GetWaybillEntries() + { + try + { + var entries = await _context.WaybillEntries + .OrderByDescending(w => w.Date) + .ThenBy(w => w.StartTime) + .ToListAsync(); + + return Ok(new ApiResponse>(true, "Waybill entries retrieved successfully", entries)); + } + catch (Exception ex) + { + return StatusCode(500, new ApiResponse(false, $"Internal server error: {ex.Message}")); + } + } + + // GET: api/waybillentries/5 + [Authorize] + [HttpGet("{id}")] + public async Task>> GetWaybillEntry(int id) + { + try + { + var waybillEntry = await _context.WaybillEntries + .FirstOrDefaultAsync(w => w.Id == id); + + if (waybillEntry == null) + { + return NotFound(new ApiResponse(false, "Waybill entry not found")); + } + + return Ok(new ApiResponse(true, "Waybill entry retrieved successfully", waybillEntry)); + } + catch (Exception ex) + { + return StatusCode(500, new ApiResponse(false, $"Internal server error: {ex.Message}")); + } + } + + // POST: api/waybillentries + [Authorize] + [HttpPost] + public async Task>> PostWaybillEntry(WaybillEntry waybillEntry) + { + try + { + + if (waybillEntry.VehicleId <= 0) + { + return BadRequest(new ApiResponse(false, "Valid VehicleId is required")); + } + + if (waybillEntry.OrderId <= 0) + { + return BadRequest(new ApiResponse(false, "Valid OrderId is required")); + } + + + var vehicleExists = await _context.Vehicles.AnyAsync(v => v.Id == waybillEntry.VehicleId); + if (!vehicleExists) + { + return BadRequest(new ApiResponse(false, "Vehicle not found")); + } + + var orderExists = await _context.Orders.AnyAsync(o => o.Id == waybillEntry.OrderId); + if (!orderExists) + { + return BadRequest(new ApiResponse(false, "Order not found")); + } + + if (string.IsNullOrEmpty(waybillEntry.StartTime) || !IsValidTimeFormat(waybillEntry.StartTime)) + { + return BadRequest(new ApiResponse(false, "StartTime must be in HH:mm format")); + } + + if (string.IsNullOrEmpty(waybillEntry.EndTime) || !IsValidTimeFormat(waybillEntry.EndTime)) + { + return BadRequest(new ApiResponse(false, "EndTime must be in HH:mm format")); + } + + waybillEntry.CreatedAt = DateTime.UtcNow; + _context.WaybillEntries.Add(waybillEntry); + await _context.SaveChangesAsync(); + + var createdEntry = await _context.WaybillEntries + .FirstOrDefaultAsync(w => w.Id == waybillEntry.Id); + + return CreatedAtAction("GetWaybillEntry", new { id = waybillEntry.Id }, + new ApiResponse(true, "Waybill entry created successfully", createdEntry)); + } + catch (Exception ex) + { + return StatusCode(500, new ApiResponse(false, $"Internal server error: {ex.Message}")); + } + } + + // PUT: api/waybillentries/5 + [Authorize] + [HttpPut("{id}")] + public async Task>> PutWaybillEntry(int id, WaybillEntry waybillEntry) + { + try + { + if (id != waybillEntry.Id) + { + return BadRequest(new ApiResponse(false, "ID mismatch")); + } + + + if (waybillEntry.VehicleId <= 0) + { + return BadRequest(new ApiResponse(false, "Valid VehicleId is required")); + } + + if (waybillEntry.OrderId <= 0) + { + return BadRequest(new ApiResponse(false, "Valid OrderId is required")); + } + + var vehicleExists = await _context.Vehicles.AnyAsync(v => v.Id == waybillEntry.VehicleId); + if (!vehicleExists) + { + return BadRequest(new ApiResponse(false, "Vehicle not found")); + } + + var orderExists = await _context.Orders.AnyAsync(o => o.Id == waybillEntry.OrderId); + if (!orderExists) + { + return BadRequest(new ApiResponse(false, "Order not found")); + } + + if (string.IsNullOrEmpty(waybillEntry.StartTime) || !IsValidTimeFormat(waybillEntry.StartTime)) + { + return BadRequest(new ApiResponse(false, "StartTime must be in HH:mm format")); + } + + if (string.IsNullOrEmpty(waybillEntry.EndTime) || !IsValidTimeFormat(waybillEntry.EndTime)) + { + return BadRequest(new ApiResponse(false, "EndTime must be in HH:mm format")); + } + + _context.Entry(waybillEntry).State = EntityState.Modified; + + try + { + await _context.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!WaybillEntryExists(id)) + { + return NotFound(new ApiResponse(false, "Waybill entry not found")); + } + else + { + throw; + } + } + + + var updatedEntry = await _context.WaybillEntries + .FirstOrDefaultAsync(w => w.Id == waybillEntry.Id); + + return Ok(new ApiResponse(true, "Waybill entry updated successfully", updatedEntry)); + } + catch (Exception ex) + { + return StatusCode(500, new ApiResponse(false, $"Internal server error: {ex.Message}")); + } + } + + // DELETE: api/waybillentries/5 + [Authorize] + [HttpDelete("{id}")] + public async Task>> DeleteWaybillEntry(int id) + { + try + { + var waybillEntry = await _context.WaybillEntries.FindAsync(id); + if (waybillEntry == null) + { + return NotFound(new ApiResponse(false, "Waybill entry not found")); + } + + _context.WaybillEntries.Remove(waybillEntry); + await _context.SaveChangesAsync(); + + return Ok(new ApiResponse(true, "Waybill entry deleted successfully")); + } + catch (Exception ex) + { + return StatusCode(500, new ApiResponse(false, $"Internal server error: {ex.Message}")); + } + } + + private bool WaybillEntryExists(int id) + { + return _context.WaybillEntries.Any(e => e.Id == id); + } + + private bool IsValidTimeFormat(string time) + { + return System.Text.RegularExpressions.Regex.IsMatch(time, @"^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$"); + } + } +} \ No newline at end of file diff --git a/DTOs/ApiResponse.cs b/DTOs/ApiResponse.cs new file mode 100644 index 0000000..0951457 --- /dev/null +++ b/DTOs/ApiResponse.cs @@ -0,0 +1,26 @@ + +namespace LogisticsApp.Server.DTOs +{ + public class ApiResponse + { + public bool Success { get; set; } + public string Message { get; set; } = string.Empty; + public T? Data { get; set; } + + public ApiResponse(bool success, string message, T? data = default) + { + Success = success; + Message = message; + Data = data; + } + } + + public class PagedResult + { + public List Items { get; set; } = new(); + public int TotalCount { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } + public int TotalPages { get; set; } + } +} \ No newline at end of file diff --git a/DTOs/LoginRequest.cs b/DTOs/LoginRequest.cs new file mode 100644 index 0000000..a7bfd04 --- /dev/null +++ b/DTOs/LoginRequest.cs @@ -0,0 +1,8 @@ +namespace LogisticsApp.Server.DTOs +{ + public class LoginRequest + { + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/DTOs/LoginResponse.cs b/DTOs/LoginResponse.cs new file mode 100644 index 0000000..4920d2e --- /dev/null +++ b/DTOs/LoginResponse.cs @@ -0,0 +1,10 @@ +namespace LogisticsApp.Server.DTOs +{ + public class LoginResponse + { + public string Token { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public string Role { get; set; } = string.Empty; + public DateTime Expires { get; set; } + } +} \ No newline at end of file diff --git a/DTOs/OrderFilters.cs b/DTOs/OrderFilters.cs new file mode 100644 index 0000000..824e544 --- /dev/null +++ b/DTOs/OrderFilters.cs @@ -0,0 +1,13 @@ +namespace LogisticsApp.Server.DTOs +{ + public class OrderFilters + { + public string? ClientName { get; set; } + public decimal? MinCost { get; set; } + public decimal? MaxCost { get; set; } + public DateTime? OrderDate { get; set; } + public string? Status { get; set; } + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 50; + } +} \ No newline at end of file diff --git a/DTOs/VehicleFilters.cs b/DTOs/VehicleFilters.cs new file mode 100644 index 0000000..c2b44e4 --- /dev/null +++ b/DTOs/VehicleFilters.cs @@ -0,0 +1,11 @@ +namespace LogisticsApp.Server.DTOs +{ + public class VehicleFilters + { + public string? DriverName { get; set; } + public string? VehicleType { get; set; } + public string? LicensePlate { get; set; } + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 50; + } +} \ No newline at end of file diff --git a/Data/ApplicationDbContext.cs b/Data/ApplicationDbContext.cs new file mode 100644 index 0000000..3e20e7e --- /dev/null +++ b/Data/ApplicationDbContext.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore; +using LogisticsApp.Server.Models; + +namespace LogisticsApp.Server.Data +{ + public class ApplicationDbContext : DbContext + { + public ApplicationDbContext(DbContextOptions options) : base(options) { } + + public DbSet Orders { get; set; } + public DbSet Vehicles { get; set; } + public DbSet WaybillEntries { get; set; } + public DbSet Users { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + foreach (var property in entityType.GetProperties()) + { + if (property.ClrType == typeof(DateTime)) + { + modelBuilder.Entity(entityType.ClrType) + .Property(property.Name) + .HasConversion( + v => v.Kind == DateTimeKind.Unspecified + ? DateTime.SpecifyKind(v, DateTimeKind.Utc) + : v.ToUniversalTime(), + v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); + } + else if (property.ClrType == typeof(DateTime?)) + { + modelBuilder.Entity(entityType.ClrType) + .Property(property.Name) + .HasConversion( + v => !v.HasValue + ? v + : (v.Value.Kind == DateTimeKind.Unspecified + ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) + : v.Value.ToUniversalTime()), + v => v.HasValue + ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) + : v); + } + } + } + + + modelBuilder.Entity() + .HasIndex(o => o.Status); + + modelBuilder.Entity() + .HasIndex(o => o.OrderDate); + + modelBuilder.Entity() + .HasIndex(o => o.ClientName); + + modelBuilder.Entity() + .HasIndex(v => v.LicensePlate); + + modelBuilder.Entity() + .HasIndex(w => new { w.VehicleId, w.Date }); + + modelBuilder.Entity() + .HasIndex(u => u.Username) + .IsUnique(); + + base.OnModelCreating(modelBuilder); + } + } +} \ No newline at end of file diff --git a/Data/DbInitializer.cs b/Data/DbInitializer.cs new file mode 100644 index 0000000..7c62703 --- /dev/null +++ b/Data/DbInitializer.cs @@ -0,0 +1,82 @@ + +using LogisticsApp.Server.Models; + +namespace LogisticsApp.Server.Data +{ + public static class DbInitializer + { + public static async Task Initialize(ApplicationDbContext context) + { + + if (context.Users.Any() || context.Orders.Any()) + { + return; + } + + + var user = new User + { + Username = "admin", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("admin123"), + Role = "admin", + CreatedAt = DateTime.UtcNow + }; + context.Users.Add(user); + + user = new User + { + Username = "user", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("user123"), + Role = "user", + CreatedAt = DateTime.UtcNow + }; + context.Users.Add(user); + + var orders = new[] + { + new Order { Id = 1, ClientName = "Иванов Иван Иванович", OrderCost = 15000, OrderDate = DateTime.Parse("2025-01-15"), Status = "completed", Address = "ул. Ленина, 10", Description = "Доставка строительных материалов", CreatedAt = DateTime.UtcNow }, + new Order { Id = 2, ClientName = "ООО 'СтройМир'", OrderCost = 25000, OrderDate = DateTime.Parse("2025-01-16"), Status = "in_progress", Address = "пр. Мира, 25", Description = "Перевозка оборудования", CreatedAt = DateTime.UtcNow }, + new Order { Id = 3, ClientName = "ЗАО 'ТехноПром'", OrderCost = 18000, OrderDate = DateTime.Parse("2025-01-17"), Status = "completed", Address = "ул. Промышленная, 45", Description = "Доставка офисной мебели", CreatedAt = DateTime.UtcNow }, + new Order { Id = 4, ClientName = "Сергеев Алексей Петрович", OrderCost = 12000, OrderDate = DateTime.Parse("2025-01-18"), Status = "in_progress", Address = "пр. Космонавтов, 78", Description = "Переезд квартиры", CreatedAt = DateTime.UtcNow }, + new Order { Id = 5, ClientName = "ИП Козлова Марина Сергеевна", OrderCost = 22000, OrderDate = DateTime.Parse("2025-01-19"), Status = "in_progress", Address = "ул. Торговая, 12", Description = "Доставка товаров для магазина", CreatedAt = DateTime.UtcNow }, + new Order { Id = 6, ClientName = "ООО 'СтройГрад'", OrderCost = 35000, OrderDate = DateTime.Parse("2025-01-20"), Status = "pending", Address = "ул. Строителей, 33", Description = "Перевозка кирпича", CreatedAt = DateTime.UtcNow }, + new Order { Id = 7, ClientName = "Федоров Дмитрий Иванович", OrderCost = 8000, OrderDate = DateTime.Parse("2025-01-21"), Status = "completed", Address = "ул. Садовая, 15", Description = "Перевозка бытовой техники", CreatedAt = DateTime.UtcNow }, + new Order { Id = 8, ClientName = "ООО 'ПромСнаб'", OrderCost = 28000, OrderDate = DateTime.Parse("2025-01-22"), Status = "cancelled", Address = "пр. Заводской, 67", Description = "Доставка станков", CreatedAt = DateTime.UtcNow }, + new Order { Id = 9, ClientName = "Александрова Ольга Викторовна", OrderCost = 9500, OrderDate = DateTime.Parse("2025-01-23"), Status = "cancelled", Address = "ул. Центральная, 89", Description = "Перевозка личных вещей", CreatedAt = DateTime.UtcNow }, + new Order { Id = 10, ClientName = "ООО 'ТоргСервис'", OrderCost = 19500, OrderDate = DateTime.Parse("2025-01-24"), Status = "in_progress", Address = "ул. Коммерческая, 56", Description = "Доставка продуктов питания", CreatedAt = DateTime.UtcNow }, + new Order { Id = 11, ClientName = "Васильев Павел Сергеевич", OrderCost = 11000, OrderDate = DateTime.Parse("2025-01-25"), Status = "completed", Address = "ул. Молодежная, 23", Description = "Переезд на новую квартиру", CreatedAt = DateTime.UtcNow }, + new Order { Id = 12, ClientName = "ЗАО 'ПромИнвест'", OrderCost = 42000, OrderDate = DateTime.Parse("2025-01-26"), Status = "pending", Address = "пр. Индустриальный, 44", Description = "Перевозка промышленного оборудования", CreatedAt = DateTime.UtcNow } + }; + context.Orders.AddRange(orders); + + + var vehicles = new[] + { + new Vehicle { Id = 1, DriverName = "Петров Петр Петрович", VehicleType = "Газель", LicensePlate = "А123БВ77", CreatedAt = DateTime.UtcNow }, + new Vehicle { Id = 2, DriverName = "Сидоров Алексей Владимирович", VehicleType = "Камаз", LicensePlate = "В456ГД77", CreatedAt = DateTime.UtcNow }, + new Vehicle { Id = 3, DriverName = "Кузнецов Михаил Иванович", VehicleType = "Газель", LicensePlate = "С789ЕЖ77", CreatedAt = DateTime.UtcNow }, + new Vehicle { Id = 4, DriverName = "Николаев Андрей Сергеевич", VehicleType = "ЗИЛ", LicensePlate = "Д321ФГ77", CreatedAt = DateTime.UtcNow }, + new Vehicle { Id = 5, DriverName = "Морозов Виктор Павлович", VehicleType = "Камаз", LicensePlate = "Е654ХЦ77", CreatedAt = DateTime.UtcNow }, + new Vehicle { Id = 6, DriverName = "Орлов Денис Александрович", VehicleType = "Газель", LicensePlate = "Ж987ЧШ77", CreatedAt = DateTime.UtcNow }, + new Vehicle { Id = 7, DriverName = "Белов Артем Игоревич", VehicleType = "Фургон", LicensePlate = "З159ЩР77", CreatedAt = DateTime.UtcNow }, + new Vehicle { Id = 8, DriverName = "Громов Сергей Викторович", VehicleType = "ЗИЛ", LicensePlate = "И753ЪЫ77", CreatedAt = DateTime.UtcNow } + }; + context.Vehicles.AddRange(vehicles); + + + var waybillEntries = new[] + { + new WaybillEntry { VehicleId = 6, OrderId = 1, StartTime = "20:00", EndTime = "22:00", Date = new DateOnly(2025, 11, 21), CreatedAt = DateTime.UtcNow }, + new WaybillEntry { VehicleId = 6, OrderId = 4, StartTime = "14:00", EndTime = "16:00", Date = new DateOnly(2025, 11, 21), CreatedAt = DateTime.UtcNow }, + new WaybillEntry { VehicleId = 6, OrderId = 2, StartTime = "10:00", EndTime = "12:00", Date = new DateOnly(2025, 11, 21), CreatedAt = DateTime.UtcNow }, + new WaybillEntry { VehicleId = 2, OrderId = 2, StartTime = "20:00", EndTime = "22:00", Date = new DateOnly(2025, 11, 21), CreatedAt = DateTime.UtcNow }, + new WaybillEntry { VehicleId = 2, OrderId = 4, StartTime = "08:00", EndTime = "09:00", Date = new DateOnly(2025, 11, 21), CreatedAt = DateTime.UtcNow }, + new WaybillEntry { VehicleId = 1, OrderId = 1, StartTime = "08:00", EndTime = "11:00", Date = new DateOnly(2025, 11, 21), CreatedAt = DateTime.UtcNow }, + new WaybillEntry { VehicleId = 2, OrderId = 4, StartTime = "08:00", EndTime = "10:00", Date = new DateOnly(2025, 11, 23), CreatedAt = DateTime.UtcNow } + }; + context.WaybillEntries.AddRange(waybillEntries); + + await context.SaveChangesAsync(); + } + } +} \ No newline at end of file diff --git a/LogisticsApp.Server.csproj b/LogisticsApp.Server.csproj new file mode 100644 index 0000000..edb3a4d --- /dev/null +++ b/LogisticsApp.Server.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/LogisticsApp.Server.http b/LogisticsApp.Server.http new file mode 100644 index 0000000..c81410f --- /dev/null +++ b/LogisticsApp.Server.http @@ -0,0 +1,6 @@ +@LogisticsApp.Server_HostAddress = http://localhost:5155 + +GET {{LogisticsApp.Server_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/LogisticsApp.Server.sln b/LogisticsApp.Server.sln new file mode 100644 index 0000000..e71d71a --- /dev/null +++ b/LogisticsApp.Server.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36121.58 d17.14 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LogisticsApp.Server", "LogisticsApp.Server.csproj", "{BF240DA8-0F2B-443A-98F6-47565A946F26}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {BF240DA8-0F2B-443A-98F6-47565A946F26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF240DA8-0F2B-443A-98F6-47565A946F26}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF240DA8-0F2B-443A-98F6-47565A946F26}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF240DA8-0F2B-443A-98F6-47565A946F26}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {8F81766E-91B3-450A-84EA-45D3E4D33C17} + EndGlobalSection +EndGlobal diff --git a/Middleware/JwtMiddleware.cs b/Middleware/JwtMiddleware.cs new file mode 100644 index 0000000..1623f2e --- /dev/null +++ b/Middleware/JwtMiddleware.cs @@ -0,0 +1,60 @@ +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Text; + +namespace LogisticsApp.Server.Middleware +{ + public class JwtMiddleware + { + private readonly RequestDelegate _next; + private readonly IConfiguration _configuration; + + public JwtMiddleware(RequestDelegate next, IConfiguration configuration) + { + _next = next; + _configuration = configuration; + } + + public async Task Invoke(HttpContext context) + { + var token = context.Request.Headers["Authorization"].FirstOrDefault()?.Split(" ").Last() + ?? context.Request.Cookies["auth_token"]; + + if (token != null) + { + AttachUserToContext(context, token); + } + + await _next(context); + } + + private void AttachUserToContext(HttpContext context, string token) + { + try + { + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.UTF8.GetBytes(_configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key not configured")); + + tokenHandler.ValidateToken(token, new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(key), + ValidateIssuer = true, + ValidateAudience = true, + ValidIssuer = _configuration["Jwt:Issuer"], + ValidAudience = _configuration["Jwt:Audience"], + ClockSkew = TimeSpan.Zero + }, out SecurityToken validatedToken); + + var jwtToken = (JwtSecurityToken)validatedToken; + var userId = int.Parse(jwtToken.Claims.First(x => x.Type == "nameid").Value); + + + context.Items["UserId"] = userId; + context.Items["User"] = jwtToken.Claims.First(x => x.Type == "name").Value; + context.Items["Role"] = jwtToken.Claims.First(x => x.Type == "role").Value; + } + catch { } + } + } +} \ No newline at end of file diff --git a/Migrations/20251128103633_fix.Designer.cs b/Migrations/20251128103633_fix.Designer.cs new file mode 100644 index 0000000..532a6a9 --- /dev/null +++ b/Migrations/20251128103633_fix.Designer.cs @@ -0,0 +1,203 @@ +// +using System; +using LogisticsApp.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LogisticsApp.Server.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20251128103633_fix")] + partial class fix + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LogisticsApp.Server.Models.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrderCost") + .HasColumnType("decimal(18,2)"); + + b.Property("OrderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ClientName"); + + b.HasIndex("OrderDate"); + + b.HasIndex("Status"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("LogisticsApp.Server.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("LogisticsApp.Server.Models.Vehicle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DriverName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LicensePlate") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VehicleType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("LicensePlate"); + + b.ToTable("Vehicles"); + }); + + modelBuilder.Entity("LogisticsApp.Server.Models.WaybillEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("EndTime") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("StartTime") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("VehicleId", "Date"); + + b.ToTable("WaybillEntries"); + }); + + modelBuilder.Entity("LogisticsApp.Server.Models.WaybillEntry", b => + { + b.HasOne("LogisticsApp.Server.Models.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LogisticsApp.Server.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("Vehicle"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20251128103633_fix.cs b/Migrations/20251128103633_fix.cs new file mode 100644 index 0000000..bdeacfc --- /dev/null +++ b/Migrations/20251128103633_fix.cs @@ -0,0 +1,150 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LogisticsApp.Server.Migrations +{ + /// + public partial class fix : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Orders", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ClientName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + OrderCost = table.Column(type: "numeric(18,2)", nullable: false), + OrderDate = table.Column(type: "timestamp with time zone", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Address = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + Description = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Orders", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Username = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + PasswordHash = table.Column(type: "text", nullable: false), + Role = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Vehicles", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + DriverName = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + VehicleType = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + LicensePlate = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Vehicles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "WaybillEntries", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + VehicleId = table.Column(type: "integer", nullable: false), + OrderId = table.Column(type: "integer", nullable: false), + StartTime = table.Column(type: "character varying(5)", maxLength: 5, nullable: false), + EndTime = table.Column(type: "character varying(5)", maxLength: 5, nullable: false), + Date = table.Column(type: "date", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WaybillEntries", x => x.Id); + table.ForeignKey( + name: "FK_WaybillEntries_Orders_OrderId", + column: x => x.OrderId, + principalTable: "Orders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_WaybillEntries_Vehicles_VehicleId", + column: x => x.VehicleId, + principalTable: "Vehicles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Orders_ClientName", + table: "Orders", + column: "ClientName"); + + migrationBuilder.CreateIndex( + name: "IX_Orders_OrderDate", + table: "Orders", + column: "OrderDate"); + + migrationBuilder.CreateIndex( + name: "IX_Orders_Status", + table: "Orders", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_Users_Username", + table: "Users", + column: "Username", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Vehicles_LicensePlate", + table: "Vehicles", + column: "LicensePlate"); + + migrationBuilder.CreateIndex( + name: "IX_WaybillEntries_OrderId", + table: "WaybillEntries", + column: "OrderId"); + + migrationBuilder.CreateIndex( + name: "IX_WaybillEntries_VehicleId_Date", + table: "WaybillEntries", + columns: new[] { "VehicleId", "Date" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Users"); + + migrationBuilder.DropTable( + name: "WaybillEntries"); + + migrationBuilder.DropTable( + name: "Orders"); + + migrationBuilder.DropTable( + name: "Vehicles"); + } + } +} diff --git a/Migrations/ApplicationDbContextModelSnapshot.cs b/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..38903f9 --- /dev/null +++ b/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,200 @@ +// +using System; +using LogisticsApp.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LogisticsApp.Server.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LogisticsApp.Server.Models.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrderCost") + .HasColumnType("decimal(18,2)"); + + b.Property("OrderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ClientName"); + + b.HasIndex("OrderDate"); + + b.HasIndex("Status"); + + b.ToTable("Orders"); + }); + + modelBuilder.Entity("LogisticsApp.Server.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("LogisticsApp.Server.Models.Vehicle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DriverName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LicensePlate") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VehicleType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("LicensePlate"); + + b.ToTable("Vehicles"); + }); + + modelBuilder.Entity("LogisticsApp.Server.Models.WaybillEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("EndTime") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("OrderId") + .HasColumnType("integer"); + + b.Property("StartTime") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("VehicleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("VehicleId", "Date"); + + b.ToTable("WaybillEntries"); + }); + + modelBuilder.Entity("LogisticsApp.Server.Models.WaybillEntry", b => + { + b.HasOne("LogisticsApp.Server.Models.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LogisticsApp.Server.Models.Vehicle", "Vehicle") + .WithMany() + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("Vehicle"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Models/Orders.cs b/Models/Orders.cs new file mode 100644 index 0000000..17c4023 --- /dev/null +++ b/Models/Orders.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace LogisticsApp.Server.Models +{ + public class Order + { + [Key] + public int Id { get; set; } + + [Required] + [StringLength(200)] + public string ClientName { get; set; } = string.Empty; + + [Required] + [Column(TypeName = "decimal(18,2)")] + public decimal OrderCost { get; set; } + + [Required] + public DateTime OrderDate { get; set; } = DateTime.UtcNow; + + [Required] + [StringLength(20)] + public string Status { get; set; } = "pending"; // pending, in_progress, completed, cancelled + + [Required] + [StringLength(500)] + public string Address { get; set; } = string.Empty; + + public string Description { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? UpdatedAt { get; set; } = DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/Models/User.cs b/Models/User.cs new file mode 100644 index 0000000..27e3a43 --- /dev/null +++ b/Models/User.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace LogisticsApp.Server.Models +{ + public class User + { + [Key] + public int Id { get; set; } + + [Required] + [StringLength(50)] + public string Username { get; set; } = string.Empty; + + [Required] + public string PasswordHash { get; set; } = string.Empty; + + [Required] + public string Role { get; set; } = "admin"; // admin, user + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/Models/Vehicle.cs b/Models/Vehicle.cs new file mode 100644 index 0000000..cc9966a --- /dev/null +++ b/Models/Vehicle.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace LogisticsApp.Server.Models +{ + public class Vehicle + { + [Key] + public int Id { get; set; } + + [Required] + [StringLength(100)] + public string DriverName { get; set; } = string.Empty; + + [Required] + [StringLength(50)] + public string VehicleType { get; set; } = string.Empty; + + [Required] + [StringLength(20)] + public string LicensePlate { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/Models/WaybillEntry.cs b/Models/WaybillEntry.cs new file mode 100644 index 0000000..aa4cec5 --- /dev/null +++ b/Models/WaybillEntry.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace LogisticsApp.Server.Models +{ + public class WaybillEntry + { + [Key] + public int Id { get; set; } + + [Required] + [ForeignKey("Vehicle")] + public int VehicleId { get; set; } + + [Required] + [ForeignKey("Order")] + public int OrderId { get; set; } + + [Required] + [StringLength(5)] + public string StartTime { get; set; } = string.Empty; // Format: "HH:mm" + + [Required] + [StringLength(5)] + public string EndTime { get; set; } = string.Empty; // Format: "HH:mm" + + [Required] + public DateOnly Date { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..816a51a --- /dev/null +++ b/Program.cs @@ -0,0 +1,121 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using System.Text; +using LogisticsApp.Server.Data; +using LogisticsApp.Server.Services; +using LogisticsApp.Server.Middleware; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); + +// Configure Swagger +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new OpenApiInfo + { + Title = "Logistics API", + Version = "v1", + Description = "API for Logistics Management System" + }); + + + c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Description = "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + Scheme = "Bearer" + }); + + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + new string[] {} + } + }); +}); + +// Database Configuration +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); + +// JWT Authentication +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = builder.Configuration["Jwt:Issuer"], + ValidAudience = builder.Configuration["Jwt:Audience"], + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key not configured"))) + }; + }); + +// Services +builder.Services.AddScoped(); + +// CORS for React client +builder.Services.AddCors(options => +{ + options.AddPolicy("ReactClient", policy => + { + policy.WithOrigins("http://localhost:3000") + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials() + .WithExposedHeaders("Set-Cookie"); + }); +}); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseCors("ReactClient"); +app.UseMiddleware(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); + +// Initialize database with sample data +using (var scope = app.Services.CreateScope()) +{ + var services = scope.ServiceProvider; + try + { + var context = services.GetRequiredService(); + context.Database.EnsureCreated(); + await DbInitializer.Initialize(context); + } + catch (Exception ex) + { + var logger = services.GetRequiredService>(); + logger.LogError(ex, "An error occurred while seeding the database."); + } +} + +app.Run(); \ No newline at end of file diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..47ca89f --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:53244", + "sslPort": 44332 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5155", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7071;http://localhost:5155", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Services/AuthService.cs b/Services/AuthService.cs new file mode 100644 index 0000000..bcd0131 --- /dev/null +++ b/Services/AuthService.cs @@ -0,0 +1,80 @@ +using LogisticsApp.Server.Data; +using LogisticsApp.Server.DTOs; +using LogisticsApp.Server.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace LogisticsApp.Server.Services +{ + public interface IAuthService + { + Task LoginAsync(LoginRequest request); + string GenerateJwtToken(User user); + } + + public class AuthService : IAuthService + { + private readonly ApplicationDbContext _context; + private readonly IConfiguration _configuration; + + public AuthService(ApplicationDbContext context, IConfiguration configuration) + { + _context = context; + _configuration = configuration; + } + + public async Task LoginAsync(LoginRequest request) + { + var user = await _context.Users + .FirstOrDefaultAsync(u => u.Username == request.Username); + + if (user == null || !VerifyPassword(request.Password, user.PasswordHash)) + { + return null; + } + + var token = GenerateJwtToken(user); + + return new LoginResponse + { + Token = token, + Username = user.Username, + Role = user.Role, + Expires = DateTime.UtcNow.AddHours(24) + }; + } + + public string GenerateJwtToken(User user) + { + var key = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(_configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key not configured"))); + + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Username), + new Claim(ClaimTypes.Role, user.Role) + }; + + var token = new JwtSecurityToken( + issuer: _configuration["Jwt:Issuer"], + audience: _configuration["Jwt:Audience"], + claims: claims, + expires: DateTime.UtcNow.AddHours(24), + signingCredentials: credentials + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private static bool VerifyPassword(string password, string passwordHash) + { + return BCrypt.Net.BCrypt.Verify(password, passwordHash); + } + } +} \ No newline at end of file diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..5c3b8f4 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,17 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=logisticsdb;Username=postgres;Password=postgres" + }, + "Jwt": { + "Key": "your-super-secret-key-at-least-32-characters-long", + "Issuer": "LogisticsApp", + "Audience": "LogisticsAppClient" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file