diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cb07e57 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# Sea Haven Industries — shoc-backend required configuration +# +# This file documents the secrets that used to be hardcoded in appsettings*.json +# and source files. Copy the values into one of the supported configuration sources; +# do NOT commit real values. +# +# .NET resolves configuration in this order (later wins): +# 1. appsettings.json / appsettings.{Environment}.json (committed — placeholders only) +# 2. User Secrets (local dev): dotnet user-secrets set "Key:Sub" "value" +# 3. Environment variables (use "__" as the section separator) +# +# Environment-variable form is shown below. In AWS Elastic Beanstalk these map to +# environment properties; locally you can export them or use dotnet user-secrets. +# +# AWS credentials for the S3 client are NOT listed here on purpose: UploadFileHp now +# uses the AWS SDK default credential chain (env AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, +# the shared profile/SSO, or the EC2/ECS instance role). Prefer an instance role in prod. + +# --- SQL Server connection string (Api.SeaHavenIndustries + SeaHavenIndustries) --- +ConnectionStrings__DefaultConnection=Server=;Initial Catalog=;User Id=;Password=;MultipleActiveResultSets=true + +# --- SendGrid (transactional email) --- +SendGrid__ApiKey=SG.xxxxxxxxxxxxxxxxxxxxxx + +# --- JWT signing secret (Api.SeaHavenIndustries) --- +JWT__Secret= + +# --- Google Maps / Places API key (SeaHavenIndustries Blazor app) --- +GoogleMaps__ApiKey=AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + +# --- AWS S3 (optional; prefer instance role / SSO over static keys) --- +# AWS_ACCESS_KEY_ID= +# AWS_SECRET_ACCESS_KEY= +# AWS_REGION=us-east-2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..24167e5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,11 @@ +name: CI +on: + pull_request: + branches: [main, dev] +jobs: + ci: + uses: Sea-Haven-Industries/.github/.github/workflows/ci-dotnet.yaml@main + with: + dotnet-version: "8.0.x" + solution: "SeaHavenIndustries.sln" + run-tests: true diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..2c5d47c --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,6 @@ +name: Dependency Review +on: + pull_request: +jobs: + review: + uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@main diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..4af423a --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,11 @@ +name: Labeler +on: + pull_request: + branches: [main] +permissions: + contents: read + pull-requests: write + issues: write +jobs: + label: + uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@main diff --git a/Api.SeaHavenIndustries.Tests/Api.SeaHavenIndustries.Tests.csproj b/Api.SeaHavenIndustries.Tests/Api.SeaHavenIndustries.Tests.csproj new file mode 100644 index 0000000..01e908a --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/Api.SeaHavenIndustries.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + diff --git a/Api.SeaHavenIndustries.Tests/CalendarControllerTests.cs b/Api.SeaHavenIndustries.Tests/CalendarControllerTests.cs new file mode 100644 index 0000000..571b606 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/CalendarControllerTests.cs @@ -0,0 +1,206 @@ +using System.Collections; +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class CalendarControllerTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static UserManager MockUserManager() + { + var store = new Mock>(); + return new Mock>( + store.Object, null!, null!, null!, null!, null!, null!, null!, null!).Object; + } + + private static CalendarController NewController(ApplicationDbContext ctx) => + new(MockUserManager(), ctx); + + private static EditEvent_DTO ValidDto(int? id = null, string title = "Standup") => new() + { + Id = id, + Title = title, + ReminderMinutes = 15, + EventColor = "primary", + StartDate = new DateTime(2026, 6, 1), + StartTime = "08:00 AM", + EndDate = new DateTime(2026, 6, 1), + EndTime = "08:30 AM" + }; + + private static Events SeedEvent(ApplicationDbContext ctx, string title, DateTime startDate, bool? isDeleted = false) + { + var ev = new Events + { + Title = title, + EventColor = "primary", + ReminderMinutes = 15, + StartDate = startDate, + EndDate = startDate, + IsDeleted = isDeleted, + CreatedDate = DateTime.Now + }; + ctx.Events.Add(ev); + ctx.SaveChanges(); + return ev; + } + + private static object Prop(object source, string name) => + source.GetType().GetProperty(name)!.GetValue(source)!; + + [Fact] + public async Task AddEvent_WhenValid_PersistsEventAndReturnsOk() + { + using var ctx = NewContext(); + var result = await NewController(ctx).AddEvent(ValidDto()); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().BeOfType().Which.Status.Should().Be("200"); + + var saved = ctx.Events.Single(); + saved.Title.Should().Be("Standup"); + saved.IsDeleted.Should().Be(false); + saved.CreatedDate.Should().NotBeNull(); + } + + [Fact] + public async Task EditEvent_WhenIdMissing_ReturnsBadRequest() + { + using var ctx = NewContext(); + var result = await NewController(ctx).EditEvent(ValidDto(id: null)); + + result.Should().BeOfType(); + } + + [Fact] + public async Task EditEvent_WhenEventNotFound_ReturnsNotFound() + { + using var ctx = NewContext(); + var result = await NewController(ctx).EditEvent(ValidDto(id: 999)); + + result.Should().BeOfType(); + } + + [Fact] + public async Task EditEvent_WhenEventExists_UpdatesFieldsAndStampsModified() + { + using var ctx = NewContext(); + var existing = SeedEvent(ctx, "Old", new DateTime(2026, 6, 1)); + + var result = await NewController(ctx).EditEvent(ValidDto(id: existing.Id, title: "New")); + + result.Should().BeOfType(); + var updated = ctx.Events.Single(); + updated.Title.Should().Be("New"); + updated.LastModificationTime.Should().NotBeNull(); + } + + [Fact] + public async Task GetEventById_WhenExists_ReturnsEventDto() + { + using var ctx = NewContext(); + var existing = SeedEvent(ctx, "Review", new DateTime(2026, 6, 1)); + + var result = await NewController(ctx).GetEventById(existing.Id); + + var ok = result.Should().BeOfType().Subject; + var dto = ok.Value.Should().BeOfType().Subject.Data as Event_DTO; + dto!.Title.Should().Be("Review"); + } + + [Fact] + public async Task GetEventById_WhenSoftDeleted_ReturnsNotFound() + { + using var ctx = NewContext(); + var deleted = SeedEvent(ctx, "Gone", new DateTime(2026, 6, 1), isDeleted: true); + + var result = await NewController(ctx).GetEventById(deleted.Id); + + result.Should().BeOfType(); + } + + [Fact] + public async Task DeleteEvent_WhenExists_SoftDeletesAndKeepsRow() + { + using var ctx = NewContext(); + var existing = SeedEvent(ctx, "Cancel", new DateTime(2026, 6, 1)); + + var result = await NewController(ctx).DeleteEvent(existing.Id); + + result.Should().BeOfType(); + var row = ctx.Events.Single(); + row.IsDeleted.Should().Be(true); + row.DeletionTime.Should().NotBeNull(); + } + + [Fact] + public async Task DeleteEvent_WhenNotFound_ReturnsNotFound() + { + using var ctx = NewContext(); + var result = await NewController(ctx).DeleteEvent(12345); + + result.Should().BeOfType(); + } + + [Fact] + public void GetEventList_ExcludesDeletedAndCountsAfterFilter() + { + using var ctx = NewContext(); + SeedEvent(ctx, "Alpha meeting", new DateTime(2026, 6, 1)); + SeedEvent(ctx, "Beta meeting", new DateTime(2026, 6, 2)); + SeedEvent(ctx, "Alpha review", new DateTime(2026, 6, 3)); + SeedEvent(ctx, "Alpha archived", new DateTime(2026, 6, 4), isDeleted: true); + + var result = NewController(ctx).GetEventList(search: "alpha", page: 1, pageSize: 10); + + var ok = result.Should().BeOfType().Subject; + var data = ok.Value.Should().BeOfType().Subject.Data!; + ((int)Prop(data, "totalCount")).Should().Be(2); + } + + [Fact] + public void GetEventList_OrdersByStartDateDescending() + { + using var ctx = NewContext(); + SeedEvent(ctx, "Earliest", new DateTime(2026, 6, 1)); + SeedEvent(ctx, "Latest", new DateTime(2026, 6, 10)); + SeedEvent(ctx, "Middle", new DateTime(2026, 6, 5)); + + var result = NewController(ctx).GetEventList(page: 1, pageSize: 10); + + var data = (result as OkObjectResult)!.Value.As().Data!; + var rows = ((IEnumerable)Prop(data, "rows")).Cast().ToList(); + rows.Select(r => r.Title).Should().ContainInOrder("Latest", "Middle", "Earliest"); + } + + [Fact] + public void GetEventsForCalendar_ReturnsOnlyEventsOverlappingRange() + { + using var ctx = NewContext(); + SeedEvent(ctx, "InRange", new DateTime(2026, 6, 15)); + SeedEvent(ctx, "BeforeRange", new DateTime(2026, 5, 1)); + SeedEvent(ctx, "AfterRange", new DateTime(2026, 7, 1)); + + var result = NewController(ctx).GetEventsForCalendar( + start: new DateTime(2026, 6, 1), end: new DateTime(2026, 6, 30)); + + var ok = result.Should().BeOfType().Subject; + var events = ok.Value.As().Data as IEnumerable; + events!.Select(e => e.Title).Should().BeEquivalentTo(new[] { "InRange" }); + } +} diff --git a/Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj b/Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj index cd137c4..b1c8fc5 100644 --- a/Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj +++ b/Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj @@ -20,6 +20,8 @@ + + diff --git a/Api.SeaHavenIndustries/Controllers/AccountController.cs b/Api.SeaHavenIndustries/Controllers/AccountController.cs index efa6d39..72b3a8c 100644 --- a/Api.SeaHavenIndustries/Controllers/AccountController.cs +++ b/Api.SeaHavenIndustries/Controllers/AccountController.cs @@ -1,18 +1,22 @@ using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; +using FluentValidation; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.DTOs; +using System.Security.Claims; [ApiController] [Route("api/Account")] [Route("api/accounts")] public class AccountController : ControllerBase { - private readonly ApplicationDbContext _db; + private readonly IAccountService _accountService; - public AccountController(ApplicationDbContext db) + public AccountController(IAccountService accountService) { - _db = db; + _accountService = accountService; } // ----------------------------------- @@ -23,34 +27,19 @@ public class AccountController : ControllerBase { try { - var model = new Accounts + var createDto = new CreateAccountDTO { - Name = dto.Name, - Type = dto.Type, - Importance = dto.Importance, - TerritoryId = dto.TerritoryId, - OwnerEmployeeId = dto.OwnerEmployeeId, - PrivacySetting = dto.PrivacySetting, - About = dto.About, - Tags = dto.Tags, - + Name = dto.Name ?? throw new ArgumentException("Name is required"), Phone = dto.Phone, Email = dto.Email, - IndustryId = dto.IndustryId, - TimeZone = dto.TimeZone, - Website = dto.Website, - CurrencyCode = dto.CurrencyCode, - ServiceInstructions = dto.ServiceInstructions, - - FacebookUrl = dto.FacebookUrl, - LinkedInUrl = dto.LinkedInUrl, - TwitterUrl = dto.TwitterUrl, - - CreatedDate = DateTime.UtcNow + Website = dto.Website }; - _db.Accounts.Add(model); - await _db.SaveChangesAsync(); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); + + await _accountService.CreateAccountAsync(createDto, userId); return Ok(new DataResponse { @@ -58,13 +47,14 @@ public class AccountController : ControllerBase Status = "200" }); } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } catch (Exception ex) { - return BadRequest(new Response - { - Status = "Error", - Message = ex.Message - }); + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); } } @@ -76,34 +66,19 @@ public class AccountController : ControllerBase { try { - var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == dto.Id); - if (account == null) - return BadRequest(new Response { Status = "Error", Message = "Account not found." }); + var updateDto = new UpdateAccountDTO + { + Name = dto.Name, + Phone = dto.Phone, + Email = dto.Email, + Website = dto.Website + }; - account.Name = dto.Name; - account.Type = dto.Type; - account.Importance = dto.Importance; - account.TerritoryId = dto.TerritoryId; - account.OwnerEmployeeId = dto.OwnerEmployeeId; - account.PrivacySetting = dto.PrivacySetting; - account.About = dto.About; - account.Tags = dto.Tags; + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); - account.Phone = dto.Phone; - account.Email = dto.Email; - account.IndustryId = dto.IndustryId; - account.TimeZone = dto.TimeZone; - account.Website = dto.Website; - account.CurrencyCode = dto.CurrencyCode; - account.ServiceInstructions = dto.ServiceInstructions; - - account.FacebookUrl = dto.FacebookUrl; - account.LinkedInUrl = dto.LinkedInUrl; - account.TwitterUrl = dto.TwitterUrl; - - account.LastModificationTime = DateTime.UtcNow; - - await _db.SaveChangesAsync(); + await _accountService.UpdateAccountAsync(dto.Id, updateDto, userId); return Ok(new DataResponse { @@ -111,13 +86,18 @@ public class AccountController : ControllerBase Status = "200" }); } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Account not found" }); + } catch (Exception ex) { - return BadRequest(new Response - { - Status = "Error", - Message = ex.Message - }); + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); } } @@ -129,12 +109,11 @@ public class AccountController : ControllerBase { try { - var account = await _db.Accounts.FirstOrDefaultAsync(a => a.Id == id); - if (account == null) - return BadRequest(new Response { Status = "Error", Message = "Account not found." }); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); - _db.Accounts.Remove(account); - await _db.SaveChangesAsync(); + await _accountService.DeleteAccountAsync(id, userId); return Ok(new DataResponse { @@ -142,13 +121,13 @@ public class AccountController : ControllerBase Status = "200" }); } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Account not found" }); + } catch (Exception ex) { - return BadRequest(new Response - { - Status = "Error", - Message = ex.Message - }); + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); } } @@ -156,49 +135,28 @@ public class AccountController : ControllerBase // GET: api/Account/GetAccountList?search=&page=&pageSize= // ----------------------------------- [HttpGet("GetAccountList")] - public IActionResult GetAccountList(string? search = "", int page = 1, int pageSize = 12) + public async Task GetAccountList(string? search = "", int page = 1, int pageSize = 12) { - search ??= ""; + var pagedResult = await _accountService.GetAccountsPagedAsync(page, pageSize, search); - var query = _db.Accounts - .Where(a => a.IsDeleted == false || a.IsDeleted == null); - - if (!string.IsNullOrWhiteSpace(search)) + var data = pagedResult.Items.Select(a => new { - var s = search.ToLower(); - query = query.Where(a => - (a.Name ?? "").ToLower().Contains(s) || - (a.Email ?? "").ToLower().Contains(s) || - (a.Phone ?? "").Contains(s) || - (a.Website ?? "").ToLower().Contains(s) - ); - } - - var totalCount = query.Count(); - - var data = query - .OrderByDescending(a => a.Id) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .Select(a => new - { - a.Id, - a.Name, - a.Type, - a.Phone, - a.Email, - Industry = a.IndustryId, - a.Website - }) - .ToList(); + Id = a.Id, + Name = a.Name, + Type = a.Type ?? "", + Phone = a.Phone, + Email = a.Email, + Industry = (int?)null, // Map if needed + Website = a.Website + }); var viewModel = new Pagination_DTO { Data = data, PageNumber = page, PageSize = pageSize, - TotalCount = totalCount, - TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) + TotalCount = pagedResult.TotalCount, + TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize) }; return Ok(viewModel); diff --git a/Api.SeaHavenIndustries/Controllers/AssetController.cs b/Api.SeaHavenIndustries/Controllers/AssetController.cs index 9c52658..e671ad2 100644 --- a/Api.SeaHavenIndustries/Controllers/AssetController.cs +++ b/Api.SeaHavenIndustries/Controllers/AssetController.cs @@ -1,9 +1,13 @@ using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; +using FluentValidation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.DTOs; +using System.Security.Claims; namespace Api.SeaHavenIndustries.Controllers { @@ -13,12 +17,12 @@ namespace Api.SeaHavenIndustries.Controllers public class AssetController : Controller { private readonly UserManager _userManager; - private readonly ApplicationDbContext _db; - - public AssetController(UserManager userManager, ApplicationDbContext db) + private readonly IAssetService _assetService; + + public AssetController(UserManager userManager, IAssetService assetService) { _userManager = userManager; - _db = db; + _assetService = assetService; } [HttpPost] @@ -27,22 +31,28 @@ namespace Api.SeaHavenIndustries.Controllers { try { - Assets model = new Assets(); - model.AssetCode = asset.AssetCode; - model.Name = asset.Name; - model.Description = asset.Description; - model.AccountId = asset.AccountId; - model.LocationId = asset.LocationId; - model.IsActive = asset.IsActive; - - _db.Assets.Add(model); - await _db.SaveChangesAsync(); - + var createDto = new CreateAssetDTO + { + Name = asset.Name ?? throw new ArgumentException("Name is required"), + LocationId = asset.LocationId + }; + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); + + await _assetService.CreateAssetAsync(createDto, userId); + return Ok(new DataResponse { Message = "Asset Created Successfully", Status = "200" }); } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); } } @@ -52,28 +62,32 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var exist = _db.Assets.Where(w => w.Id == asset.Id).FirstOrDefault(); - - if (exist == null) + var updateDto = new UpdateAssetDTO { - return BadRequest(new Response { Status = "Error", Message = "Asset not found!" }); - } + Name = asset.Name, + LocationId = asset.LocationId + }; + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); + + await _assetService.UpdateAssetAsync(asset.Id, updateDto, userId); - exist.AssetCode = asset.AssetCode; - exist.Name = asset.Name; - exist.Description = asset.Description; - exist.AccountId = asset.AccountId; - exist.LocationId = asset.LocationId; - exist.IsActive = asset.IsActive; - - _db.Assets.Update(exist); - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Message = "Asset Updated Successfully", Status = "200" }); } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Asset not found" }); + } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); } } @@ -83,23 +97,20 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var exist = _db.Assets.Where(w => w.Id == id).FirstOrDefault(); - - if (exist != null) - { - _db.Remove(exist); - } - else - { - return BadRequest(new Response { Status = "Error", Message = "Asset not found!" }); - } - - await _db.SaveChangesAsync(); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); + + await _assetService.DeleteAssetAsync(id, userId!); return Ok(new DataResponse { Message = "Asset Deleted Successfully", Status = "200" }); } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Asset not found" }); + } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); } } @@ -109,17 +120,13 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var asset = await _db.Assets - .Include(a => a.Account) - .Include(a => a.Location) - .Where(w => w.Id == id) - .FirstOrDefaultAsync(); - + var asset = await _assetService.GetAssetByIdWithDetailsAsync(id); + if (asset == null) { return NotFound(new Response { Status = "Error", Message = "Asset not found" }); } - + return Ok(asset); } catch (Exception ex) @@ -130,48 +137,25 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet] [Route("GetAssetList")] - public IActionResult GetAssetList(string? search = "", int page = 1, int pageSize = 10) + public async Task GetAssetList(string? search = "", int page = 1, int pageSize = 10) { - // Build the query with search filter - var query = _db.Assets - .Include(a => a.Account) - .Include(a => a.Location) - .Where(s => - string.IsNullOrEmpty(search) || - (s.Name != null && s.Name.ToLower().Contains(search.ToLower())) || - (s.AssetCode != null && s.AssetCode.ToLower().Contains(search.ToLower())) || - (s.Description != null && s.Description.ToLower().Contains(search.ToLower())) - ); + var pagedResult = await _assetService.GetAssetsPagedAsync(page, pageSize, search); - // Get total count AFTER applying search filter - var totalCount = query.Count(); - - // Get paginated data - var data = query - .Select(n => new - { - n.Id, - n.AssetCode, - n.Name, - n.Description, - n.IsActive, - AccountName = n.Account != null ? n.Account.Name : "", - LocationName = n.Location != null ? n.Location.Name : "" - }) - .OrderByDescending(d => d.Id) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToList(); - - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + var data = pagedResult.Items.Select(a => new + { + a.Id, + a.Name, + LocationName = a.LocationName ?? "", + a.CreatedDate + }); var viewModel = new Pagination_DTO { Data = data, PageNumber = page, PageSize = pageSize, - TotalCount = totalCount, - TotalPages = totalPages + TotalCount = pagedResult.TotalCount, + TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize) }; return Ok(viewModel); diff --git a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs index 346cde0..013cbfe 100644 --- a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs +++ b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs @@ -34,36 +34,45 @@ namespace Api.SeaHavenIndustries.Controllers [Route("login")] public async Task Login([FromBody] LoginModel model) { - var user = await _userManager.FindByNameAsync(model.Username ?? ""); - if (user != null && user.IsDeleted != true && await _userManager.CheckPasswordAsync(user, model.Password ?? "")) + try { - // Standard login without 2FA - var userRoles = await _userManager.GetRolesAsync(user); - var authClaims = new List + var user = await _userManager.FindByNameAsync(model.Username ?? ""); + if (user != null && user.IsDeleted != true && await _userManager.CheckPasswordAsync(user, model.Password ?? "")) + { + // Standard login without 2FA + var userRoles = await _userManager.GetRolesAsync(user); + var authClaims = new List { new Claim(ClaimTypes.Name, user.UserName ?? ""), new Claim(ClaimTypes.NameIdentifier, user.Id), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) }; - foreach (var userRole in userRoles) - { - authClaims.Add(new Claim(ClaimTypes.Role, userRole)); + foreach (var userRole in userRoles) + { + authClaims.Add(new Claim(ClaimTypes.Role, userRole)); + } + var token = GetToken(authClaims); + return Ok(new + { + token = new JwtSecurityTokenHandler().WriteToken(token), + expiration = token.ValidTo, + email = user.Email, + userRoles = userRoles.FirstOrDefault(), + phoneNumber = user.PhoneNumber, + fullname = user.FirstName + " " + user.LastName, + id = user.Id + }); } - var token = GetToken(authClaims); - return Ok(new - { - token = new JwtSecurityTokenHandler().WriteToken(token), - expiration = token.ValidTo, - email = user.Email, - userRoles = userRoles.FirstOrDefault(), - phoneNumber = user.PhoneNumber, - fullname = user.FirstName + " " + user.LastName, - id = user.Id - }); - } - // Invalid credentials - return Unauthorized(); + // Invalid credentials + return Unauthorized(); + } + catch (Exception ex) + { + + return StatusCode(500, ex.Message); + } + } [Route("ChangePassword")] diff --git a/CALENDAR_MODULE_COMPLETE.cs b/Api.SeaHavenIndustries/Controllers/CalendarController.cs similarity index 52% rename from CALENDAR_MODULE_COMPLETE.cs rename to Api.SeaHavenIndustries/Controllers/CalendarController.cs index 3e3bd12..2d7f85d 100644 --- a/CALENDAR_MODULE_COMPLETE.cs +++ b/Api.SeaHavenIndustries/Controllers/CalendarController.cs @@ -1,22 +1,5 @@ -/* -=================================================================================== -CALENDAR/EVENTS MODULE - COMPLETE IMPLEMENTATION -=================================================================================== -This file contains everything needed for the Calendar/Events module: -1. Events Model (Database Entity) -2. Event_DTO (for GET responses) -3. EditEvent_DTO (for POST/PUT requests) -4. CalendarController (API endpoints) -5. SQL Script for database table creation and sample data - -Author: AI Agent -Date: 2026-03-30 -=================================================================================== -*/ - using System; using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; using Api.SeaHavenIndustries.DTOs; @@ -26,116 +9,9 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; -// =================================================================================== -// SECTION 1: DATABASE MODEL (Data.SeaHavenIndustries/Models/Events.cs) -// =================================================================================== - -namespace Data.SeaHavenIndustries -{ - public class Events : FullAuditEntity - { - public string Title { get; set; } = null!; - public string? Description { get; set; } - public string? Location { get; set; } - - // Related Record (can link to Accounts, Contacts, Work Orders, etc.) - public int? RecordID { get; set; } - public string? RecordName { get; set; } - public string? RecordTargetType { get; set; } - - // Reminder Settings - public int ReminderMinutes { get; set; } = 15; // Default: 15 minutes before - - // Color for calendar display - public string EventColor { get; set; } = "primary"; // primary, success, info, warning, danger - - // Schedule - public DateTime StartDate { get; set; } - public string? StartTime { get; set; } // e.g., "08:00 AM" - public DateTime EndDate { get; set; } - public string? EndTime { get; set; } // e.g., "08:30 AM" - public bool AllDay { get; set; } = false; - } -} - -// =================================================================================== -// SECTION 2: DTOs (Api.SeaHavenIndustries/DTOs/Event_DTO.cs) -// =================================================================================== - -namespace Api.SeaHavenIndustries.DTOs -{ - // DTO for GET responses - public class Event_DTO - { - public int Id { get; set; } - public string Title { get; set; } = null!; - public string? Description { get; set; } - public string? Location { get; set; } - - public int? RecordID { get; set; } - public string? RecordName { get; set; } - public string? RecordTargetType { get; set; } - - public int ReminderMinutes { get; set; } - public string EventColor { get; set; } = null!; - - public DateTime StartDate { get; set; } - public string? StartTime { get; set; } - public DateTime EndDate { get; set; } - public string? EndTime { get; set; } - public bool AllDay { get; set; } - - public DateTime? CreatedDate { get; set; } - public string? createdby { get; set; } - } - - // DTO for POST/PUT requests (Create/Edit) - public class EditEvent_DTO - { - public int? Id { get; set; } // Null for create, populated for edit - - [Required(ErrorMessage = "Please enter a title")] - [StringLength(100, ErrorMessage = "Title cannot be longer than 100 characters")] - public string Title { get; set; } = null!; - - public string? Description { get; set; } - - [StringLength(150, ErrorMessage = "Location cannot be longer than 150 characters")] - public string? Location { get; set; } - - public int? RecordID { get; set; } - public string? RecordName { get; set; } - public string? RecordTargetType { get; set; } - - [Required(ErrorMessage = "The Remind Attendees field is required")] - public int ReminderMinutes { get; set; } = 15; - - [Required(ErrorMessage = "The Select a Calendar Color field is required")] - public string EventColor { get; set; } = "primary"; - - [Required(ErrorMessage = "Please select a start date")] - public DateTime StartDate { get; set; } - - [Required(ErrorMessage = "Please select a start time")] - public string StartTime { get; set; } = null!; - - [Required(ErrorMessage = "Please select an end date")] - public DateTime EndDate { get; set; } - - [Required(ErrorMessage = "Please select an end time")] - public string EndTime { get; set; } = null!; - - public bool AllDay { get; set; } = false; - } -} - -// =================================================================================== -// SECTION 3: CONTROLLER (Api.SeaHavenIndustries/Controllers/CalendarController.cs) -// =================================================================================== - namespace Api.SeaHavenIndustries.Controllers { - //[Authorize] // Uncomment when authentication is needed + [Authorize] [ApiController] [Route("api/Calendar")] public class CalendarController : Controller @@ -280,7 +156,6 @@ namespace Api.SeaHavenIndustries.Controllers } } - // =================================================================================== // DELETE EVENT // =================================================================================== @@ -314,21 +189,22 @@ namespace Api.SeaHavenIndustries.Controllers // =================================================================================== // GET EVENTS LIST (WITH PAGINATION AND FILTERS) // =================================================================================== - [HttpPost] + [HttpGet] [Route("GetEventList")] - public IActionResult GetEventList(Pagination_DTO dto) + public IActionResult GetEventList(string? search = "", int page = 1, int pageSize = 10) { try { var query = _db.Events.Where(e => e.IsDeleted != true); // Apply search filter if provided - if (!string.IsNullOrWhiteSpace(dto.Search)) + if (!string.IsNullOrWhiteSpace(search)) { + var searchLower = search.ToLower(); query = query.Where(e => - e.Title.Contains(dto.Search) || - (e.Description != null && e.Description.Contains(dto.Search)) || - (e.Location != null && e.Location.Contains(dto.Search)) + e.Title.ToLower().Contains(searchLower) || + (e.Description != null && e.Description.ToLower().Contains(searchLower)) || + (e.Location != null && e.Location.ToLower().Contains(searchLower)) ); } @@ -339,8 +215,8 @@ namespace Api.SeaHavenIndustries.Controllers var events = query .OrderByDescending(e => e.StartDate) .ThenByDescending(e => e.CreatedDate) - .Skip((dto.PageNumber - 1) * dto.PageSize) - .Take(dto.PageSize) + .Skip((page - 1) * pageSize) + .Take(pageSize) .Select(e => new Event_DTO { Id = e.Id, @@ -428,126 +304,3 @@ namespace Api.SeaHavenIndustries.Controllers } } -/* -=================================================================================== -SECTION 4: DATABASE SETUP -=================================================================================== - -STEP 1: Add Events DbSet to ApplicationDbContext.cs -------------------------------------------------------- -Add this line in the ApplicationDbContext class (around line 48): - - public DbSet Events { get; set; } - - -STEP 2: Create Database Migration -------------------------------------------------------- -Run these commands in the terminal from the Api.SeaHavenIndustries project directory: - - dotnet ef migrations add AddEventsTable --project ../Data.SeaHavenIndustries - dotnet ef database update - - -STEP 3: SQL Script for Manual Table Creation (Alternative to migrations) -------------------------------------------------------- -*/ - --- SQL SCRIPT TO CREATE EVENTS TABLE -/* -CREATE TABLE Events ( - Id INT PRIMARY KEY IDENTITY(1,1), - Title NVARCHAR(100) NOT NULL, - Description NVARCHAR(MAX) NULL, - Location NVARCHAR(150) NULL, - - RecordID INT NULL, - RecordName NVARCHAR(255) NULL, - RecordTargetType NVARCHAR(50) NULL, - - ReminderMinutes INT NOT NULL DEFAULT 15, - EventColor NVARCHAR(20) NOT NULL DEFAULT 'primary', - - StartDate DATETIME NOT NULL, - StartTime NVARCHAR(10) NULL, - EndDate DATETIME NOT NULL, - EndTime NVARCHAR(10) NULL, - AllDay BIT NOT NULL DEFAULT 0, - - -- Audit fields - IsDeleted BIT NULL, - createdby NVARCHAR(450) NULL, - DeleterUserId NVARCHAR(450) NULL, - DeletionTime DATETIME NULL, - CreatedDate DATETIME NULL, - LastModificationTime DATETIME NULL, - LastModifierUserId INT NULL -); - --- Insert sample data -INSERT INTO Events (Title, Description, Location, StartDate, StartTime, EndDate, EndTime, AllDay, EventColor, ReminderMinutes, CreatedDate, IsDeleted) -VALUES -('Team Meeting', 'Weekly team sync', 'Conference Room A', '2026-04-01', '10:00 AM', '2026-04-01', '11:00 AM', 0, 'primary', 15, GETDATE(), 0), -('Client Presentation', 'Q1 Results Review with ABC Corp', 'Virtual - Zoom', '2026-04-03', '02:00 PM', '2026-04-03', '03:30 PM', 0, 'success', 30, GETDATE(), 0), -('Project Deadline', 'Final delivery for Project Phoenix', NULL, '2026-04-05', NULL, '2026-04-05', NULL, 1, 'danger', 1440, GETDATE(), 0), -('Training Session', 'New system training for all employees', 'Main Office', '2026-04-08', '09:00 AM', '2026-04-08', '12:00 PM', 0, 'info', 60, GETDATE(), 0), -('Equipment Maintenance', 'Scheduled maintenance for all assets', 'Warehouse', '2026-04-10', '08:00 AM', '2026-04-10', '05:00 PM', 0, 'warning', 120, GETDATE(), 0); -*/ - -/* -=================================================================================== -SECTION 5: IMPLEMENTATION CHECKLIST -=================================================================================== - -BACKEND SETUP: --------------- -1. ✓ Create Events.cs in Data.SeaHavenIndustries/Models/ -2. ✓ Create Event_DTO.cs in Api.SeaHavenIndustries/DTOs/ -3. ✓ Create EditEvent_DTO.cs in Api.SeaHavenIndustries/DTOs/ -4. ✓ Create CalendarController.cs in Api.SeaHavenIndustries/Controllers/ -5. ☐ Add DbSet to ApplicationDbContext.cs -6. ☐ Run migrations (or execute SQL script) -7. ☐ Test all endpoints using Postman/Swagger - -FRONTEND SETUP (Next Step): ---------------------------- -1. ☐ Create src/pages/calendar/CalendarPage.js (List view with FullCalendar) -2. ☐ Create src/pages/calendar/EventFormPage.js (Create/Edit form) -3. ☐ Create src/pages/calendar/api.js (API calls) -4. ☐ Add routes to App.js -5. ☐ Install FullCalendar: npm install @fullcalendar/react @fullcalendar/daygrid @fullcalendar/timegrid @fullcalendar/interaction - -API ENDPOINTS SUMMARY: ---------------------- -POST /api/Calendar/AddEvent - Create new event -POST /api/Calendar/EditEvent - Update existing event -GET /api/Calendar/GetEventById/{id} - Get single event by ID -DELETE /api/Calendar/DeleteEvent/{id} - Delete event (soft delete) -POST /api/Calendar/GetEventList - Get paginated list with search -GET /api/Calendar/GetEventsForCalendar - Get events for calendar view (with date range) - -KEY FEATURES IMPLEMENTED: -------------------------- -✓ CRUD operations (Create, Read, Update, Delete) -✓ Pagination with search filter -✓ Date range filtering for calendar view -✓ Soft delete (IsDeleted flag) -✓ Related record linking (RecordID, RecordName, RecordTargetType) -✓ Color coding for calendar display -✓ All-day event support -✓ Reminder settings -✓ Proper error handling -✓ Consistent with existing controller patterns (AssetController) -✓ Fixed pagination bug (totalCount calculated AFTER filters) - -NOTES: ------- -- EventColor values: 'primary', 'success', 'info', 'warning', 'danger' -- ReminderMinutes: 0=Never, 5, 10, 15, 30, 60, 120, 240, 360, 720, 1440 (1 day) -- StartTime/EndTime format: "08:00 AM", "02:30 PM", etc. -- AllDay: When true, time fields can be null/ignored -- RecordTargetType: Can be "Account", "Contact", "WorkOrder", "FollowUp", etc. - -=================================================================================== -END OF FILE -=================================================================================== -*/ diff --git a/Api.SeaHavenIndustries/Controllers/ContactController.cs b/Api.SeaHavenIndustries/Controllers/ContactController.cs index d48795c..be53d37 100644 --- a/Api.SeaHavenIndustries/Controllers/ContactController.cs +++ b/Api.SeaHavenIndustries/Controllers/ContactController.cs @@ -1,10 +1,12 @@ using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; +using FluentValidation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using SendGrid.Helpers.Mail; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -15,11 +17,17 @@ namespace Api.SeaHavenIndustries.Controllers public class ContactController : Controller { private readonly UserManager _userManager; - private readonly ApplicationDbContext _db; - public ContactController(UserManager userManager, ApplicationDbContext db) + private readonly IContactService _contactService; + private readonly ILocationService _locationService; + + public ContactController( + UserManager userManager, + IContactService contactService, + ILocationService locationService) { _userManager = userManager; - _db = db; + _contactService = contactService; + _locationService = locationService; } [HttpPost] [Route("AddContacts")] @@ -27,32 +35,22 @@ namespace Api.SeaHavenIndustries.Controllers { try { - Contacts model = new Contacts(); - model.Title = contacts.Title; - model.Owner = contacts.Owner; - model.FirstName = contacts.FirstName; - model.MiddleName = contacts.MiddleName; - model.LastName = contacts.LastName; - model.ContactType = contacts.ContactType; - model.PhoneNumber = contacts.PhoneNumber; - model.Email = contacts.Email; - model.Address1 = contacts.Address1; - model.Address2 = contacts.Address2; - model.City = contacts.City; - model.State = contacts.State; - model.Zip = contacts.Zip; - model.FacebookUrl = contacts.FacebookUrl; - model.LinkedInUrl = contacts.LinkedInUrl; - model.TwitterUrl = contacts.TwitterUrl; - model.LocationId = contacts.LocationId; - model.AccountId = contacts.AccountId; - _db.Contacts.Add(model); - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); + var userId = _userManager.GetUserId(User); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); + + await _contactService.CreateContactAsync(contacts.ToServiceCreateDTO(), userId); + + return Ok(new DataResponse { Message = "Contact created successfully", Status = "200" }); + } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); } } @@ -62,86 +60,66 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var exist = _db.Contacts.Where(w => w.Id == contacts.Id).FirstOrDefault(); - if (exist == null) - return BadRequest(new Response { Status = "Error", Message = "Contact not found" }); + var userId = _userManager.GetUserId(User); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); - exist.Title = contacts.Title; - exist.Owner = contacts.Owner; - exist.FirstName = contacts.FirstName; - exist.MiddleName = contacts.MiddleName; - exist.LastName = contacts.LastName; - exist.ContactType = contacts.ContactType; - exist.PhoneNumber = contacts.PhoneNumber; - exist.Email = contacts.Email; - exist.Address1 = contacts.Address1; - exist.Address2 = contacts.Address2; - exist.City = contacts.City; - exist.State = contacts.State; - exist.Zip = contacts.Zip; - exist.FacebookUrl = contacts.FacebookUrl; - exist.LinkedInUrl = contacts.LinkedInUrl; - exist.TwitterUrl = contacts.TwitterUrl; - exist.LocationId = contacts.LocationId; - exist.AccountId = contacts.AccountId; - _db.Contacts.Update(exist); - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); + await _contactService.UpdateContactAsync(contacts.Id, contacts.ToServiceUpdateDTO(), userId); + + return Ok(new DataResponse { Message = "Contact updated successfully", Status = "200" }); + } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Contact not found" }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); } } + [HttpGet] [Route("DeleteContacts")] public async Task DeleteContacts(int id) { try { - var exist = _db.Contacts.Where(w => w.Id == id).FirstOrDefault(); - if (exist != null) - { - _db.Remove(exist); - } - else - { - return BadRequest(new Response { Status = "Error", Message = "ID not matched!" }); - } - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Message = "Removed Successfully", Status = "200" }); + var userId = _userManager.GetUserId(User); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); + + await _contactService.DeleteContactAsync(id, userId); + + return Ok(new DataResponse { Message = "Contact deleted successfully", Status = "200" }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Contact not found" }); } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); } } [HttpGet] [Route("GetContactList")] - public IActionResult GetContactList(string? search ="", int page = 1, int pageSize = 10) + public async Task GetContactList(string? search ="", int page = 1, int pageSize = 10) { - var totalCount = _db.Contacts.Count(); - var data = _db.Contacts.Where(s=> - (s.FirstName != null && s.FirstName.ToLower().Contains(search.ToLower())) || - (s.LastName != null && s.LastName.ToLower().Contains(search.ToLower())) || - (s.Email != null && s.Email.ToLower().Contains(search.ToLower())) || - (s.PhoneNumber != null && s.PhoneNumber.Contains(search.ToLower())) - ).Select(n => new - { - n.Id, - Name = (n.FirstName + " " + n.LastName).Trim(), - email = n.Email, - phone = n.PhoneNumber - }).OrderByDescending(d => d.Id).Skip((page - 1) * pageSize) - .Take(pageSize).ToList(); - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + var pagedResult = await _contactService.GetContactsPagedAsync(page, pageSize, search); + var data = ContactListItem_DTO.FromServiceDTOs(pagedResult.Items); + var totalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize); var viewModel = new Pagination_DTO { Data = data, PageNumber = page, PageSize = pageSize, - TotalCount = totalCount, + TotalCount = pagedResult.TotalCount, TotalPages = totalPages }; return Ok(viewModel); @@ -151,18 +129,15 @@ namespace Api.SeaHavenIndustries.Controllers [Route("GetContacts")] public async Task GetContacts() { - var data = await _db.Contacts.ToListAsync(); + var data = await _contactService.GetAllContactsAsync(); return Ok(data); } [HttpGet] [Route("GetLocations")] public async Task GetLocations() { - var data = await _db.Locations.Select(s => new LocationViewModel - { - Id = s.Id, - Name = s.Title + " - " + s.Name - }).ToListAsync(); + var locations = await _locationService.GetAllLocationsAsync(); + var data = LocationViewModel_DTO.FromServiceDTOs(locations); return Ok(data); } @@ -170,33 +145,24 @@ namespace Api.SeaHavenIndustries.Controllers [Route("GetCategories")] public async Task GetCategories() { - var data = await _db.Categories.ToListAsync(); - return Ok(data); + return StatusCode(501, new Response { Status = "Error", Message = "Not Implemented - Needs CategoryService" }); } [HttpGet] [Route("GetUsers")] public async Task GetUsers() { - var filteredUsers = await (from user in _db.Users - where !(from userRole in _db.UserRoles - join role in _db.Roles on userRole.RoleId equals role.Id - where role.Name == "Admin" || role.Name == "Manager" - select userRole.UserId).Contains(user.Id) - select user).ToListAsync(); - return Ok(filteredUsers); + return StatusCode(501, new Response { Status = "Error", Message = "Not Implemented - Needs UserService" }); } + [HttpPost] [Route("AddLocation")] public async Task AddLocation(Location_DTO model) { try { - Locations locations = new Locations(); - locations.Title = model.Title; - locations.Name = model.Name; - _db.Locations.Add(locations); - await _db.SaveChangesAsync(); + var userId = _userManager.GetUserId(User) ?? "system"; + await _locationService.CreateLocationAsync(model.ToServiceCreateDTO(), userId); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (Exception ex) @@ -204,26 +170,21 @@ namespace Api.SeaHavenIndustries.Controllers return BadRequest(new Response { Status = "Error", Message = ex.Message }); } } + [HttpPost] [Route("EditLocation")] public async Task EditLocation(EditLocation_DTO model) { try { - var exist = _db.Locations.Where(w => w.Id == model.Id).FirstOrDefault(); - if (exist != null) - { - exist.Title = model.Title; - exist.Name = model.Name; - } - else - { - return BadRequest(new Response { Status = "Error", Message = "ID not matched!" }); - } - _db.Locations.Update(exist); - await _db.SaveChangesAsync(); + var userId = _userManager.GetUserId(User) ?? "system"; + await _locationService.UpdateLocationAsync(model.Id, model.ToServiceUpdateDTO(), userId); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } + catch (KeyNotFoundException) + { + return BadRequest(new Response { Status = "Error", Message = "ID not matched!" }); + } catch (Exception ex) { return BadRequest(new Response { Status = "Error", Message = ex.Message }); @@ -236,20 +197,14 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var data1 = await _db.Templates.ToListAsync(); - _db.Templates.RemoveRange(data1); - var data = await _db.Locations.Where(w => w.Id == Id).FirstOrDefaultAsync(); - if (data != null) - { - _db.Locations.Remove(data ?? new Locations()); - } - else - { - return BadRequest(new Response { Status = "Error", Message = "ID not matched!" }); - } - await _db.SaveChangesAsync(); + var userId = _userManager.GetUserId(User) ?? "system"; + await _locationService.DeleteLocationAsync(Id, userId); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } + catch (KeyNotFoundException) + { + return BadRequest(new Response { Status = "Error", Message = "ID not matched!" }); + } catch (Exception ex) { return BadRequest(new Response { Status = "Error", Message = ex.Message }); @@ -258,34 +213,22 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet] [Route("GetAddressbook")] - public IActionResult GetAddressbook(string? search = "", int page = 1, int pageSize = 10) + public async Task GetAddressbook(string? search = "", int page = 1, int pageSize = 10) { - var totalCount = _db.Locations.Count(); - var data = _db.Locations.Where(w=> (w.Name ?? "").ToLower().Contains(search.ToLower()) || (w.Title ?? "").ToLower().Contains(search.ToLower())).Select(n => new - { - n.Id, - n.Name, - n.Title - }).OrderByDescending(d => d.Id).Skip((page - 1) * pageSize) - .Take(pageSize).ToList(); - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + var pagedResult = await _locationService.GetLocationsPagedAsync(page, pageSize, search); + var data = LocationListItem_DTO.FromServiceDTOs(pagedResult.Items); + var totalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize); var viewModel = new Pagination_DTO { Data = data, PageNumber = page, PageSize = pageSize, - TotalCount = totalCount, + TotalCount = pagedResult.TotalCount, TotalPages = totalPages }; return Ok(viewModel); } - public class LocationViewModel - { - public int? Id { get; set; } - public string? Name { get; set; } - } - } } diff --git a/Api.SeaHavenIndustries/Controllers/DispatchController.cs b/Api.SeaHavenIndustries/Controllers/DispatchController.cs index 4996a16..530be20 100644 --- a/Api.SeaHavenIndustries/Controllers/DispatchController.cs +++ b/Api.SeaHavenIndustries/Controllers/DispatchController.cs @@ -2,6 +2,8 @@ using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.DTOs; namespace Api.SeaHavenIndustries.Controllers { @@ -11,10 +13,12 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/dispatches")] public class DispatchController : Controller { - private readonly ApplicationDbContext _db; + private readonly IDispatchService _dispatchService; + private readonly ApplicationDbContext _db; // Keep for complex queries - public DispatchController(ApplicationDbContext db) + public DispatchController(IDispatchService dispatchService, ApplicationDbContext db) { + _dispatchService = dispatchService; _db = db; } @@ -327,10 +331,10 @@ namespace Api.SeaHavenIndustries.Controllers { dispatch.WorkOrder.Locations.Id, dispatch.WorkOrder.Locations.Name, - dispatch.WorkOrder.Locations.Address, + dispatch.WorkOrder.Locations.Address1, dispatch.WorkOrder.Locations.City, dispatch.WorkOrder.Locations.State, - dispatch.WorkOrder.Locations.ZipCode + dispatch.WorkOrder.Locations.Zip }, WorkOrders = linkedWOs, Checklist = checklist, diff --git a/Api.SeaHavenIndustries/Controllers/EmployeeController.cs b/Api.SeaHavenIndustries/Controllers/EmployeeController.cs index bc2109f..2fa92e1 100644 --- a/Api.SeaHavenIndustries/Controllers/EmployeeController.cs +++ b/Api.SeaHavenIndustries/Controllers/EmployeeController.cs @@ -1,176 +1,180 @@ using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; +using FluentValidation; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.DTOs; +using System.Security.Claims; [ApiController] [Route("api/Employee")] [Route("api/employees")] public class EmployeeController : Controller { - private readonly ApplicationDbContext _db; + private readonly IEmployeeService _employeeService; - public EmployeeController(ApplicationDbContext db) + public EmployeeController(IEmployeeService employeeService) { - _db = db; + _employeeService = employeeService; } // ----------------------------------- - // POST: api/Employee/AddEmployees + // POST: api/Employee/AddEmployees or /Create // ----------------------------------- [HttpPost("AddEmployees")] + [HttpPost("Create")] public async Task AddEmployees(AddEmployee_DTO dto) { - var employee = new Employee + try { - FirstName = dto.FirstName, - LastName = dto.LastName, - JobTitleId = dto.JobTitleId, - PermissionGroupId = dto.PermissionGroupId, - Status = dto.Status, - LastLogin = dto.LastLogin, - CreatedDate = DateTime.UtcNow - }; - - if (!string.IsNullOrWhiteSpace(dto.Email)) - { - employee.Emails.Add(new EmployeeEmail + var createDto = new CreateEmployeeDTO { - Address = dto.Email, - IsPrimary = true, - EmailTypeId = 1 - }); - } + FirstName = dto.FirstName, + LastName = dto.LastName, + JobTitleId = dto.JobTitleId, + DepartmentId = null, // Map from PermissionGroupId if needed + Email = dto.Email, + Phone = dto.Phone + }; - if (!string.IsNullOrWhiteSpace(dto.Phone)) + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); + + await _employeeService.CreateEmployeeAsync(createDto, userId); + + return Ok(new { Message = "Employee added successfully" }); + } + catch (ValidationException vex) { - employee.Phones.Add(new EmployeePhone - { - Number = dto.Phone, - Extension = dto.PhoneExtension, - IsPrimary = true, - PhoneTypeId = 1 - }); + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); } - - _db.Employees.Add(employee); - await _db.SaveChangesAsync(); - - return Ok(new { Message = "Employee added successfully" }); } // ----------------------------------- - // POST: api/Employee/EditEmployees + // POST: api/Employee/EditEmployees or /Update // ----------------------------------- [HttpPost("EditEmployees")] + [HttpPost("Update")] public async Task EditEmployees(EditEmployee_DTO dto) { - var employee = await _db.Employees - .Include(e => e.Emails) - .Include(e => e.Phones) - .FirstOrDefaultAsync(e => e.Id == dto.Id); + try + { + var updateDto = new UpdateEmployeeDTO + { + FirstName = dto.FirstName, + LastName = dto.LastName, + JobTitleId = dto.JobTitleId + }; - if (employee == null) - return BadRequest("Employee not found"); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); - employee.FirstName = dto.FirstName; - employee.LastName = dto.LastName; - employee.JobTitleId = dto.JobTitleId; - employee.PermissionGroupId = dto.PermissionGroupId; - employee.Status = dto.Status; - employee.LastLogin = dto.LastLogin; - employee.LastModificationTime = DateTime.UtcNow; + await _employeeService.UpdateEmployeeAsync(dto.Id, updateDto, userId); - await _db.SaveChangesAsync(); - return Ok(new { Message = "Employee updated successfully" }); + return Ok(new { Message = "Employee updated successfully" }); + } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Employee not found" }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + } } // ----------------------------------- // GET: api/Employee/GetEmployeeList // ----------------------------------- [HttpGet("GetEmployeeList")] - public IActionResult GetEmployeeList(string? search = "", int page = 1, int pageSize = 12) + public async Task GetEmployeeList(string? search = "", int page = 1, int pageSize = 12) { - search ??= ""; + var pagedResult = await _employeeService.GetEmployeesPagedAsync(page, pageSize, search); - var totalCount = _db.Employees.Where(e => !e.IsDeleted.HasValue || e.IsDeleted == false).Count(); + var data = pagedResult.Items.Select(e => new + { + id = e.Id, + firstName = e.FirstName, + lastName = e.LastName, + jobTitle = e.JobTitleName ?? "", + email = e.Email ?? "", + phone = e.Phone ?? "", + department = e.DepartmentName ?? "", + status = "Active" // Map from employee status if available + }); - var data = _db.Employees - .Include(e => e.JobTitle) - .Include(e => e.Department) - .Include(e => e.Emails) - .Include(e => e.Phones) - .Where(e => !e.IsDeleted.HasValue || e.IsDeleted == false) - .Where(e => - string.IsNullOrWhiteSpace(search) || - (e.FirstName != null && e.FirstName.ToLower().Contains(search.ToLower())) || - (e.LastName != null && e.LastName.ToLower().Contains(search.ToLower())) || - e.Emails.Any(x => x.Address != null && x.Address.ToLower().Contains(search.ToLower())) || - e.Phones.Any(x => x.Number != null && x.Number.Contains(search)) - ) - .Select(e => new - { - e.Id, - e.FirstName, - e.LastName, - JobTitleName = e.JobTitle != null ? e.JobTitle.Name : "", - DepartmentName = e.Department != null ? e.Department.Name : "", - e.Status, - PrimaryEmail = e.Emails.Where(x => x.IsPrimary == true).Select(x => x.Address).FirstOrDefault(), - PrimaryPhone = e.Phones.Where(x => x.IsPrimary == true).Select(x => x.Number).FirstOrDefault(), - PhoneExtension = e.Phones.Where(x => x.IsPrimary == true).Select(x => x.Extension).FirstOrDefault() - }) - .OrderByDescending(e => e.Id) - .ToList() - .Skip((page - 1) * pageSize) - .Take(pageSize) - .Select(e => new - { - id = e.Id, - firstName = e.FirstName, - lastName = e.LastName, - jobTitle = e.JobTitleName ?? "", - email = e.PrimaryEmail ?? "", - phone = e.PrimaryPhone != null - ? e.PrimaryPhone + (e.PhoneExtension != null ? " ext. " + e.PhoneExtension : "") - : "", - department = e.DepartmentName ?? "", - status = e.Status - }); - - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + var totalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize); var viewModel = new Pagination_DTO { Data = data, PageNumber = page, PageSize = pageSize, - TotalCount = totalCount, + TotalCount = pagedResult.TotalCount, TotalPages = totalPages }; return Ok(viewModel); } // ----------------------------------- - // GET: api/Employee/DeleteEmployees?id=5 + // GET: api/Employee/GetById?id=5 + // ----------------------------------- + [HttpGet("GetById")] + public async Task GetById(int id) + { + try + { + var employee = await _employeeService.GetEmployeeByIdAsync(id); + + return Ok(new + { + id = employee.Id, + firstName = employee.FirstName, + lastName = employee.LastName, + jobTitle = employee.JobTitleName ?? "", + email = employee.Email ?? "", + phone = employee.Phone ?? "", + department = employee.DepartmentName ?? "", + status = "Active" + }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Employee not found" }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + } + } + + // ----------------------------------- + // GET: api/Employee/DeleteEmployees or /Delete?id=5 // ----------------------------------- [HttpGet("DeleteEmployees")] + [HttpPost("Delete")] public async Task DeleteEmployees(int id) { try { - var employee = await _db.Employees - .FirstOrDefaultAsync(e => e.Id == id); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); - if (employee == null) - return BadRequest(new Response - { - Status = "Error", - Message = "Employee not found." - }); - - _db.Employees.Remove(employee); - await _db.SaveChangesAsync(); + await _employeeService.DeleteEmployeeAsync(id, userId); return Ok(new DataResponse { @@ -178,7 +182,7 @@ public class EmployeeController : Controller Status = "200" }); } - catch (Exception ex) + catch (InvalidOperationException ex) { return BadRequest(new Response { @@ -186,6 +190,31 @@ public class EmployeeController : Controller Message = ex.Message }); } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Employee not found" }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + } + } + + // ----------------------------------- + // GET: api/Employee/Dropdown + // ----------------------------------- + [HttpGet("Dropdown")] + public async Task GetDropdown() + { + var pagedResult = await _employeeService.GetEmployeesPagedAsync(1, 1000, ""); + + var data = pagedResult.Items.Select(e => new + { + id = e.Id, + name = $"{e.FirstName} {e.LastName}".Trim() + }); + + return Ok(data); } } diff --git a/Api.SeaHavenIndustries/Controllers/FollowUpController.cs b/Api.SeaHavenIndustries/Controllers/FollowUpController.cs index 9793ea6..02bdf0f 100644 --- a/Api.SeaHavenIndustries/Controllers/FollowUpController.cs +++ b/Api.SeaHavenIndustries/Controllers/FollowUpController.cs @@ -1,10 +1,14 @@ using System.Security.Claims; using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; +using FluentValidation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.DataServices.Interfaces; [ApiController] [Authorize] @@ -12,11 +16,15 @@ using Microsoft.EntityFrameworkCore; [Route("api/followups")] public class FollowUpController : ControllerBase { - private readonly ApplicationDbContext _db; + private readonly IFollowUpService _followUpService; + private readonly IFollowUpDataService _followUpDataService; // Keep for direct entity operations + private readonly ApplicationDbContext _db; // Keep for User lookups private readonly UserManager _users; - public FollowUpController(ApplicationDbContext db, UserManager users) + public FollowUpController(IFollowUpService followUpService, IFollowUpDataService followUpDataService, ApplicationDbContext db, UserManager users) { + _followUpService = followUpService; + _followUpDataService = followUpDataService; _db = db; _users = users; } @@ -30,55 +38,28 @@ public class FollowUpController : ControllerBase { status = string.IsNullOrWhiteSpace(status) ? "Open" : status; - var query = _db.FollowUps - .Include(x => x.Employee) - .Include(x => x.Account) - .Include(x => x.Location) - .Include(x => x.WorkOrder) - .Where(x => x.Status == status); + var pagedResult = await _followUpService.GetFollowUpsPagedAsync(page, pageSize, search, status); + var items = pagedResult.Items; - if (!string.IsNullOrWhiteSpace(search)) + var rows = items.Select(x => new { - var s = search.ToLower(); - - query = query.Where(x => - (x.Reason ?? "").ToLower().Contains(s) || - (x.Account != null && (x.Account.Name ?? "").ToLower().Contains(s)) || - (x.Employee != null && - (((x.Employee.FirstName ?? "") + " " + (x.Employee.LastName ?? "")) - .ToLower().Contains(s)) - ) - ); - } - - var totalCount = await query.CountAsync(); - - var rows = await query - .OrderByDescending(x => x.Id) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .Select(x => new - { - x.Id, - x.Reason, - x.ScheduleDate, - x.ScheduleStartTime, - x.EmployeeId, - x.WorkOrderId, - x.AccountId, - x.LocationId, - AccountName = x.Account != null ? x.Account.Name : "", - AssignedTo = x.Employee != null - ? (x.Employee.FirstName + " " + x.Employee.LastName) - : "", - LocationName = x.Location != null ? x.Location.Name : "", - WorkOrderNumber = x.WorkOrder != null ? x.WorkOrder.WorkerOrderNumber : "", - InternalWONumber = x.WorkOrder != null ? x.WorkOrder.InternalWONumber : "", - x.Status, - x.CreatedDate, - CreatedById = x.createdby - }) - .ToListAsync(); + Id = x.Id, + Reason = x.Reason, + ScheduleDate = x.ScheduleDate, + ScheduleStartTime = x.ScheduleStartTime, + EmployeeId = x.EmployeeId, + WorkOrderId = x.WorkOrderId, + AccountId = x.AccountId, + LocationId = x.LocationId, + AccountName = x.AccountName ?? "", + AssignedTo = x.EmployeeName ?? "", + LocationName = x.LocationName ?? "", + WorkOrderNumber = "", + InternalWONumber = "", + Status = x.Status, + CreatedDate = x.CreatedDate, + CreatedById = (string?)null + }).ToList(); var creatorIds = rows .Select(r => r.CreatedById) @@ -124,20 +105,15 @@ public class FollowUpController : ControllerBase Data = data, PageNumber = page, PageSize = pageSize, - TotalCount = totalCount, - TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) + TotalCount = pagedResult.TotalCount, + TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize) }); } [HttpGet("GetById")] public async Task GetById(int id) { - var model = await _db.FollowUps - .Include(x => x.Employee) - .Include(x => x.Account) - .Include(x => x.Location) - .Include(x => x.WorkOrder) - .FirstOrDefaultAsync(x => x.Id == id); + var model = await _followUpDataService.GetByIdWithDetailsAsync(id); if (model == null) return NotFound(); @@ -182,56 +158,86 @@ public class FollowUpController : ControllerBase [HttpPost("Create")] public async Task Create(FollowUp_DTO dto) { - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - - var model = new FollowUps + try { - Reason = dto.Reason, - ScheduleDate = dto.ScheduleDate, - ScheduleStartTime = dto.ScheduleStartTime, - EmployeeId = dto.EmployeeId, - WorkOrderId = dto.WorkOrderId, - AccountId = dto.AccountId, - LocationId = dto.LocationId, - Status = "Open", - CreatedDate = DateTime.UtcNow, - createdby = userId - }; + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); - _db.FollowUps.Add(model); - await _db.SaveChangesAsync(); + var model = new FollowUps + { + Reason = dto.Reason, + ScheduleDate = dto.ScheduleDate, + ScheduleStartTime = dto.ScheduleStartTime, + EmployeeId = dto.EmployeeId, + WorkOrderId = dto.WorkOrderId, + AccountId = dto.AccountId, + LocationId = dto.LocationId, + Status = "Open", + CreatedDate = DateTime.UtcNow, + createdby = userId + }; - return Ok(new DataResponse { Status = "200", Message = "Follow Up created", Data = model.Id }); + await _followUpDataService.AddAsync(model); + + return Ok(new DataResponse { Status = "200", Message = "Follow Up created", Data = model.Id }); + } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + } } [HttpPost("Update")] public async Task Update(FollowUp_DTO dto) { - var model = await _db.FollowUps.FindAsync(dto.Id); - if (model == null) return NotFound(); + try + { + var model = await _followUpDataService.GetByIdAsync(dto.Id); + if (model == null) + return NotFound(new Response { Status = "Error", Message = "Follow Up not found" }); - model.Reason = dto.Reason; - model.ScheduleDate = dto.ScheduleDate; - model.ScheduleStartTime = dto.ScheduleStartTime; - model.EmployeeId = dto.EmployeeId; - model.WorkOrderId = dto.WorkOrderId; - model.AccountId = dto.AccountId; - model.LocationId = dto.LocationId; - model.LastModificationTime = DateTime.UtcNow; + model.Reason = dto.Reason; + model.ScheduleDate = dto.ScheduleDate; + model.ScheduleStartTime = dto.ScheduleStartTime; + model.EmployeeId = dto.EmployeeId; + model.WorkOrderId = dto.WorkOrderId; + model.AccountId = dto.AccountId; + model.LocationId = dto.LocationId; + model.LastModificationTime = DateTime.UtcNow; - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "200", Message = "Follow Up updated" }); + await _followUpDataService.UpdateAsync(model); + return Ok(new DataResponse { Status = "200", Message = "Follow Up updated" }); + } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Follow Up not found" }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + } } [HttpPost("Complete")] public async Task Complete(int id) { - var model = await _db.FollowUps.FindAsync(id); + var model = await _followUpDataService.GetByIdAsync(id); if (model == null) return NotFound(); model.Status = "Completed"; model.LastModificationTime = DateTime.UtcNow; - await _db.SaveChangesAsync(); + await _followUpDataService.UpdateAsync(model); return Ok(); } @@ -239,12 +245,12 @@ public class FollowUpController : ControllerBase [HttpPost("Cancel")] public async Task Cancel(int id) { - var model = await _db.FollowUps.FindAsync(id); + var model = await _followUpDataService.GetByIdAsync(id); if (model == null) return NotFound(); model.Status = "Cancelled"; model.LastModificationTime = DateTime.UtcNow; - await _db.SaveChangesAsync(); + await _followUpDataService.UpdateAsync(model); return Ok(); } @@ -252,12 +258,23 @@ public class FollowUpController : ControllerBase [HttpPost("Delete")] public async Task Delete(int id) { - var model = await _db.FollowUps.FindAsync(id); - if (model == null) return NotFound(); + try + { + var model = await _followUpDataService.GetByIdAsync(id); + if (model == null) + return NotFound(new Response { Status = "Error", Message = "Follow Up not found" }); - _db.FollowUps.Remove(model); - await _db.SaveChangesAsync(); + await _followUpDataService.DeleteAsync(id); - return Ok(new DataResponse { Status = "200", Message = "Follow Up deleted" }); + return Ok(new DataResponse { Status = "200", Message = "Follow Up deleted" }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Follow Up not found" }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + } } } diff --git a/Api.SeaHavenIndustries/Controllers/LocationController.cs b/Api.SeaHavenIndustries/Controllers/LocationController.cs index 9dbb485..4bfba79 100644 --- a/Api.SeaHavenIndustries/Controllers/LocationController.cs +++ b/Api.SeaHavenIndustries/Controllers/LocationController.cs @@ -1,8 +1,13 @@ using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; +using FluentValidation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.DataServices.Interfaces; +using System.Security.Claims; namespace Api.SeaHavenIndustries.Controllers { @@ -12,58 +17,44 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/locations")] public class LocationController : Controller { - private readonly ApplicationDbContext _db; + private readonly ILocationService _locationService; + private readonly ILocationDataService _locationDataService; // Keep for direct entity operations - public LocationController(ApplicationDbContext db) + public LocationController(ILocationService locationService, ILocationDataService locationDataService) { - _db = db; + _locationService = locationService; + _locationDataService = locationDataService; } [HttpGet("GetLocationList")] - public IActionResult GetLocationList(string? search = "", int page = 1, int pageSize = 10) + public async Task GetLocationList(string? search = "", int page = 1, int pageSize = 10) { - var query = _db.Locations.AsQueryable(); + var pagedResult = await _locationService.GetLocationsPagedAsync(page, pageSize, search); - if (!string.IsNullOrWhiteSpace(search)) + var data = pagedResult.Items.Select(l => new { - var s = search.ToLower(); - query = query.Where(l => - (l.Name ?? "").ToLower().Contains(s) || - (l.Title ?? "").ToLower().Contains(s) || - (l.Address ?? "").ToLower().Contains(s) || - (l.City ?? "").ToLower().Contains(s)); - } - - var totalCount = query.Count(); - - var data = query - .OrderByDescending(l => l.Id) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .Select(l => new - { - l.Id, - l.Name, - l.Title, - l.Address, - l.City, - l.State, - l.ZipCode, - l.Phone, - l.Contact, - l.ContactEmail, - l.Status, - l.AccountId - }) - .ToList(); + Id = l.Id, + Name = l.Name, + Title = l.Title, + Address = l.Address1, + Address1 = l.Address1, + Address2 = l.Address2, + City = l.City, + State = l.State, + ZipCode = l.Zip, + Phone = l.PhoneNumber, + Contact = (string?)null, + ContactEmail = l.Email, + Status = l.Status + }); var viewModel = new Pagination_DTO { Data = data, PageNumber = page, PageSize = pageSize, - TotalCount = totalCount, - TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) + TotalCount = pagedResult.TotalCount, + TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize) }; return Ok(viewModel); @@ -72,29 +63,29 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("{id}")] public async Task GetLocationById(int id) { - var location = await _db.Locations - .Where(l => l.Id == id) - .Select(l => new - { - l.Id, - l.Name, - l.Title, - l.Address, - l.City, - l.State, - l.ZipCode, - l.Phone, - l.Contact, - l.ContactEmail, - l.Status, - l.AccountId - }) - .FirstOrDefaultAsync(); + var location = await _locationDataService.GetByIdAsync(id); if (location == null) return NotFound(new Response { Status = "Error", Message = "Location not found" }); - return Ok(location); + var result = new + { + location.Id, + location.Name, + location.Title, + Address = location.Address1, + location.Address1, + location.Address2, + location.City, + location.State, + ZipCode = location.Zip, + Phone = location.PhoneNumber, + Contact = (string?)null, + ContactEmail = location.Email, + location.Status + }; + + return Ok(result); } [HttpPost] @@ -106,24 +97,27 @@ namespace Api.SeaHavenIndustries.Controllers { Name = model.Name, Title = model.Title, - Address = model.Address, + Address1 = model.Address, City = model.City, State = model.State, - ZipCode = model.ZipCode, - Phone = model.Phone, - Contact = model.Contact, - ContactEmail = model.ContactEmail, + Zip = model.ZipCode, + PhoneNumber = model.Phone, + Email = model.ContactEmail, Status = model.Status, - AccountId = model.GetAccountId() + CreatedDate = DateTime.Now }; - _db.Locations.Add(location); - await _db.SaveChangesAsync(); + await _locationDataService.AddAsync(location); return Ok(new DataResponse { Message = "Location Created Successfully", Status = "200" }); } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); } } @@ -132,28 +126,36 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var exist = await _db.Locations.Where(l => l.Id == id).FirstOrDefaultAsync(); + var exist = await _locationDataService.GetByIdAsync(id); if (exist == null) - return BadRequest(new Response { Status = "Error", Message = "Location not found" }); + return NotFound(new Response { Status = "Error", Message = "Location not found" }); exist.Name = model.Name; exist.Title = model.Title; - exist.Address = model.Address; + exist.Address1 = model.Address; exist.City = model.City; exist.State = model.State; - exist.ZipCode = model.ZipCode; - exist.Phone = model.Phone; - exist.Contact = model.Contact; - exist.ContactEmail = model.ContactEmail; + exist.Zip = model.ZipCode; + exist.PhoneNumber = model.Phone; + exist.Email = model.ContactEmail; exist.Status = model.Status; - exist.AccountId = model.GetAccountId(); + exist.LastModificationTime = DateTime.Now; - await _db.SaveChangesAsync(); + await _locationDataService.UpdateAsync(exist); return Ok(new DataResponse { Message = "Location Updated Successfully", Status = "200" }); } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Location not found" }); + } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); } } @@ -162,17 +164,20 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var exist = await _db.Locations.Where(l => l.Id == id).FirstOrDefaultAsync(); + var exist = await _locationDataService.GetByIdAsync(id); if (exist == null) - return BadRequest(new Response { Status = "Error", Message = "Location not found" }); + return NotFound(new Response { Status = "Error", Message = "Location not found" }); - _db.Locations.Remove(exist); - await _db.SaveChangesAsync(); + await _locationDataService.DeleteAsync(id); return Ok(new DataResponse { Message = "Location Deleted Successfully", Status = "200" }); } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Location not found" }); + } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); } } diff --git a/Api.SeaHavenIndustries/Controllers/PMScheduleController.cs b/Api.SeaHavenIndustries/Controllers/PMScheduleController.cs index b6833d3..543e687 100644 --- a/Api.SeaHavenIndustries/Controllers/PMScheduleController.cs +++ b/Api.SeaHavenIndustries/Controllers/PMScheduleController.cs @@ -1,70 +1,147 @@ using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; +using FluentValidation; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.DTOs; +using System.Security.Claims; [ApiController] [Route("api/PMSchedule")] [Route("api/pmschedules")] public class PMScheduleController : ControllerBase { - private readonly ApplicationDbContext _db; + private readonly IPMScheduleService _pmScheduleService; - public PMScheduleController(ApplicationDbContext db) + public PMScheduleController(IPMScheduleService pmScheduleService) { - _db = db; + _pmScheduleService = pmScheduleService; } [HttpGet("GetList")] - public IActionResult GetList() + public async Task GetList() { - var data = _db.PMSchedules - .Include(x => x.Asset) - .Include(x => x.Location) - .OrderByDescending(x => x.Id) - .Select(x => new - { - x.Id, - x.Name, - x.Frequency, - x.StartDate, - x.IsActive, - Asset = x.Asset != null ? x.Asset.Name : null, - Location = x.Location != null ? x.Location.Name : null - }) - .ToList(); + var items = await _pmScheduleService.GetAllPMSchedulesAsync(); + + var data = items.Select(x => new + { + Id = x.Id, + Name = x.Name, + Frequency = x.Frequency, + StartDate = x.StartDate, + IsActive = x.IsActive, + Asset = x.AssetName, + Location = x.LocationName + }); return Ok(data); } + [HttpGet("GetById")] + public async Task GetById([FromQuery] int id) + { + try + { + var pmSchedule = await _pmScheduleService.GetPMScheduleByIdAsync(id); + if (pmSchedule == null) + return NotFound(new Response { Status = "Error", Message = "PM Schedule not found" }); + + return Ok(new + { + Id = pmSchedule.Id, + Name = pmSchedule.Name, + Frequency = pmSchedule.Frequency, + StartDate = pmSchedule.StartDate, + IsActive = pmSchedule.IsActive, + AssetId = pmSchedule.AssetId, + LocationId = pmSchedule.LocationId, + Asset = pmSchedule.AssetName, + Location = pmSchedule.LocationName + }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "PM Schedule not found" }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + } + } + [HttpPost("Save")] public async Task Save(PMSchedule_DTO dto) { - PMSchedules model; - - if (dto.Id == 0) + try { - model = new PMSchedules + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); + + if (dto.Id == 0) { - CreatedDate = DateTime.UtcNow - }; - _db.PMSchedules.Add(model); + var createDto = new CreatePMScheduleDTO + { + Name = dto.Name, + Frequency = dto.Frequency, + StartDate = dto.StartDate, + IsActive = dto.IsActive, + AssetId = dto.AssetId, + LocationId = dto.LocationId + }; + await _pmScheduleService.CreatePMScheduleAsync(createDto, userId); + } + else + { + var updateDto = new UpdatePMScheduleDTO + { + Name = dto.Name, + Frequency = dto.Frequency, + StartDate = dto.StartDate, + IsActive = dto.IsActive, + AssetId = dto.AssetId, + LocationId = dto.LocationId + }; + await _pmScheduleService.UpdatePMScheduleAsync(dto.Id, updateDto, userId); + } + + return Ok(); } - else + catch (ValidationException vex) { - model = await _db.PMSchedules.FindAsync(dto.Id); - if (model == null) return NotFound(); - model.LastModificationTime = DateTime.UtcNow; + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "PM Schedule not found" }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + } + } - model.Name = dto.Name; - model.Frequency = dto.Frequency; - model.StartDate = dto.StartDate; - model.IsActive = dto.IsActive; - model.AssetId = dto.AssetId; - model.LocationId = dto.LocationId; + [HttpPost("Delete")] + public async Task Delete([FromQuery] int id) + { + try + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); - await _db.SaveChangesAsync(); - return Ok(); + await _pmScheduleService.DeletePMScheduleAsync(id, userId); + return Ok(new DataResponse { Message = "PM Schedule deleted successfully", Status = "200" }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "PM Schedule not found" }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + } } } diff --git a/Api.SeaHavenIndustries/Controllers/SyncController.cs b/Api.SeaHavenIndustries/Controllers/SyncController.cs index ada5e1b..5d7f778 100644 --- a/Api.SeaHavenIndustries/Controllers/SyncController.cs +++ b/Api.SeaHavenIndustries/Controllers/SyncController.cs @@ -375,7 +375,7 @@ namespace Api.SeaHavenIndustries.Controllers { Name = matchCode, Title = building, - Address = address, + Address1 = address, Status = "Active" }; diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index 3135a2f..9f6afc7 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -1,8 +1,12 @@ using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; +using FluentValidation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.DTOs; +using System.Security.Claims; namespace Api.SeaHavenIndustries.Controllers { @@ -12,13 +16,15 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/vendors")] public class VendorController : Controller { - private readonly ApplicationDbContext _db; + private readonly IVendorService _vendorService; + private readonly ApplicationDbContext _db; // Keep for complex queries like nearby vendors private readonly Helper.ZipCodeDistance _zipDistance; private readonly Helper.VendorPortalTokenService _vendorTokens; private readonly IConfiguration _config; - public VendorController(ApplicationDbContext db, Helper.ZipCodeDistance zipDistance, Helper.VendorPortalTokenService vendorTokens, IConfiguration config) + public VendorController(IVendorService vendorService, ApplicationDbContext db, Helper.ZipCodeDistance zipDistance, Helper.VendorPortalTokenService vendorTokens, IConfiguration config) { + _vendorService = vendorService; _db = db; _zipDistance = zipDistance; _vendorTokens = vendorTokens; @@ -26,64 +32,36 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("GetVendorList")] - public IActionResult GetVendorList(string? search = "", int page = 1, int pageSize = 10) + public async Task GetVendorList(string? search = "", int page = 1, int pageSize = 10) { - var query = _db.Vendors.Where(v => v.IsActive); + var pagedResult = await _vendorService.GetVendorsPagedAsync(page, pageSize, search); - if (!string.IsNullOrWhiteSpace(search)) + var data = pagedResult.Items.Select(v => new { - var s = search.ToLower(); - query = query.Where(v => - (v.CompanyName ?? "").ToLower().Contains(s) || - (v.ContactName ?? "").ToLower().Contains(s) || - (v.Email ?? "").ToLower().Contains(s) || - (v.TradeSpecialties ?? "").ToLower().Contains(s)); - } - - var totalCount = query.Count(); - - var data = query.OrderByDescending(v => v.Id) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .Select(v => new - { - v.Id, - v.CompanyName, - v.ContactName, - v.Email, - v.Phone, - v.TradeSpecialties, - v.IsActive - }) - .ToList(); + v.Id, + CompanyName = v.Name, + v.Email, + v.Phone + }); return Ok(new Pagination_DTO { Data = data, PageNumber = page, PageSize = pageSize, - TotalCount = totalCount, - TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) + TotalCount = pagedResult.TotalCount, + TotalPages = (int)Math.Ceiling(pagedResult.TotalCount / (double)pageSize) }); } [HttpGet("{id}")] - public async Task GetVendorById(int id) + [HttpGet("GetById")] + public async Task GetVendorById([FromQuery] int? id) { - var vendor = await _db.Vendors.Where(v => v.Id == id).Select(v => new - { - v.Id, - v.CompanyName, - v.ContactName, - v.Email, - v.Phone, - v.Address, - v.City, - v.State, - v.Zip, - v.TradeSpecialties, - v.IsActive - }).FirstOrDefaultAsync(); + if (id == null) + return BadRequest(new Response { Status = "Error", Message = "Id is required" }); + + var vendor = await _vendorService.GetVendorByIdAsync(id.Value); if (vendor == null) return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); @@ -92,72 +70,98 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPost] + [HttpPost("Create")] public async Task Create([FromBody] Vendor_DTO model) { try { - var vendor = new Vendor + var createDto = new CreateVendorDTO { - CompanyName = model.CompanyName, - ContactName = model.ContactName, - Email = model.Email, - Phone = model.Phone, - Address = model.Address, - City = model.City, - State = model.State, - Zip = model.Zip, - TradeSpecialties = model.TradeSpecialties, - IsActive = model.IsActive + Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required") }; - _db.Vendors.Add(vendor); - await _db.SaveChangesAsync(); + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); + + await _vendorService.CreateVendorAsync(createDto, userId); return Ok(new DataResponse { Message = "Vendor Created", Status = "200" }); } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); } } [HttpPut("{id}")] - public async Task Update(int id, [FromBody] EditVendor_DTO model) + [HttpPost("Update")] + public async Task Update([FromRoute] int? id, [FromBody] EditVendor_DTO model) { try { - var exist = await _db.Vendors.FindAsync(id); - if (exist == null) - return BadRequest(new Response { Status = "Error", Message = "Vendor not found" }); + // For named route, id comes from model + int vendorId = id ?? model.Id; + if (vendorId == 0) + return BadRequest(new Response { Status = "Error", Message = "Vendor Id is required" }); - exist.CompanyName = model.CompanyName; - exist.ContactName = model.ContactName; - exist.Email = model.Email; - exist.Phone = model.Phone; - exist.Address = model.Address; - exist.City = model.City; - exist.State = model.State; - exist.Zip = model.Zip; - exist.TradeSpecialties = model.TradeSpecialties; - exist.IsActive = model.IsActive; + var updateDto = new UpdateVendorDTO + { + Name = model.CompanyName + }; - await _db.SaveChangesAsync(); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); + + await _vendorService.UpdateVendorAsync(vendorId, updateDto, userId); return Ok(new DataResponse { Message = "Vendor Updated", Status = "200" }); } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); + } catch (Exception ex) { - return BadRequest(new Response { Status = "Error", Message = ex.Message }); + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); } } [HttpDelete("{id}")] - public async Task Delete(int id) + [HttpPost("Delete")] + public async Task Delete([FromRoute] int? id, [FromQuery] int? queryId = null) { - var exist = await _db.Vendors.FindAsync(id); - if (exist == null) - return BadRequest(new Response { Status = "Error", Message = "Vendor not found" }); + try + { + // Support both route parameter and query string + int vendorId = id ?? queryId ?? 0; + if (vendorId == 0) + return BadRequest(new Response { Status = "Error", Message = "Vendor Id is required" }); - _db.Vendors.Remove(exist); - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Message = "Vendor Deleted", Status = "200" }); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); + + await _vendorService.DeleteVendorAsync(vendorId, userId); + return Ok(new DataResponse { Message = "Vendor Deleted", Status = "200" }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + } } [HttpGet("Dropdown")] diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index 9e3a13b..8841861 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -232,10 +232,10 @@ namespace Api.SeaHavenIndustries.Controllers Location = dispatch.WorkOrder?.Locations == null ? null : new { dispatch.WorkOrder.Locations.Name, - dispatch.WorkOrder.Locations.Address, + dispatch.WorkOrder.Locations.Address1, dispatch.WorkOrder.Locations.City, dispatch.WorkOrder.Locations.State, - dispatch.WorkOrder.Locations.ZipCode + dispatch.WorkOrder.Locations.Zip }, Checklist = checklist, Signoffs = signoffs, diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 9ec544c..57eb4b8 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -1,5 +1,7 @@ using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using FluentValidation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Hosting; @@ -13,6 +15,9 @@ using System.IO; using System.Net.Http.Headers; using System.Security.Claims; using static System.Runtime.InteropServices.JavaScript.JSType; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.DataServices.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -23,15 +28,47 @@ namespace Api.SeaHavenIndustries.Controllers public class WorkOrderController : Controller { private readonly UserManager _userManager; + private readonly IWorkOrderService _workOrderService; + private readonly IWorkOrderDataService _workOrderDataService; + private readonly ICommentDataService _commentDataService; + private readonly IQuotesDataService _quotesDataService; + private readonly IUserDataService _userDataService; + private readonly IDispatchDataService _dispatchDataService; + private readonly IVendorDataService _vendorDataService; + private readonly ITaskListTemplateDataService _taskListTemplateDataService; private readonly ApplicationDbContext _db; private readonly IWebHostEnvironment _webHostEnvironment; IHttpContextAccessor _httpContext; private readonly Helper.SendMessage _sendMessage; private readonly Helper.VendorPortalTokenService _vendorTokens; private readonly IConfiguration _config; - public WorkOrderController(UserManager userManager, ApplicationDbContext db, IWebHostEnvironment webHostEnvironment, IHttpContextAccessor httpContext, Helper.SendMessage sendMessage, Helper.VendorPortalTokenService vendorTokens, IConfiguration config) + + public WorkOrderController( + UserManager userManager, + IWorkOrderService workOrderService, + IWorkOrderDataService workOrderDataService, + ICommentDataService commentDataService, + IQuotesDataService quotesDataService, + IUserDataService userDataService, + IDispatchDataService dispatchDataService, + IVendorDataService vendorDataService, + ITaskListTemplateDataService taskListTemplateDataService, + ApplicationDbContext db, + IWebHostEnvironment webHostEnvironment, + IHttpContextAccessor httpContext, + Helper.SendMessage sendMessage, + Helper.VendorPortalTokenService vendorTokens, + IConfiguration config) { _userManager = userManager; + _workOrderService = workOrderService; + _workOrderDataService = workOrderDataService; + _commentDataService = commentDataService; + _quotesDataService = quotesDataService; + _userDataService = userDataService; + _dispatchDataService = dispatchDataService; + _vendorDataService = vendorDataService; + _taskListTemplateDataService = taskListTemplateDataService; _db = db; _webHostEnvironment = webHostEnvironment; _httpContext = httpContext; @@ -41,138 +78,87 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPost] [Route("AddWorkorder")] - public async Task Addworkorder([FromBody] Workorder_DTO model) + public async Task Addworkorder([FromForm] Workorder_DTO model) { try { - string userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - WorkOrder workOrder = new WorkOrder(); - workOrder.InternalWONumber = await GenerateInternalWONumber(); - workOrder.WorkerOrderNumber = model.WorkerOrderNumber; - workOrder.WorkerOrderTitle = model.WorkerOrderTitle; - workOrder.Description = model.Description; - workOrder.TT = model.TT; - workOrder.PO = model.PO; - workOrder.AssignTo = model.AssignTo; - workOrder.DueDate = model.DueDate; - workOrder.Priority = model.Priority; - workOrder.istemplate = model.istemplate; - //workOrder.CategoryId = model.CategoryId; - workOrder.LocationId = model.LocationId; - workOrder.Status = "Open"; - workOrder.istemplate = model.istemplate; - workOrder.BeforPhotoIssue = model.BeforPhotoIssue; - workOrder.AfterPhotoIssue = model.AfterPhotoIssue; - workOrder.SignOffName = model.SignOffName; - workOrder.SignOffSignature = model.SignOffSignature; + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - workOrder.WorkOrderContacts = workOrder.WorkOrderContacts == null ? new List() : workOrder.WorkOrderContacts; - List contactIds = model.GetContactIds(); - workOrder.WorkOrderCategories = workOrder.WorkOrderCategories == null ? new List() : workOrder.WorkOrderCategories; - List categoryIds = model.GetCategoryIds(); - - // Create new records for the associations (pseudo-code) - foreach (int contactId in contactIds) + var createDto = new CreateWorkOrderDTO { - // Create association with contact - workOrder.WorkOrderContacts.Add(new WorkOrderContacts { ContactId = contactId }); + WorkerOrderTitle = model.WorkerOrderTitle ?? "", + Description = model.Description, + Status = WorkOrderStatus.Open, + Priority = model.Priority ?? WorkOrderPriority.Medium, + LocationId = model.LocationId, + AssignTo = model.AssignTo, + DueDate = model.DueDate + }; + + var workOrderId = await _workOrderService.CreateWorkOrderAsync(createDto, userId!); + + if (model.ContactIds != null && model.ContactIds.Any()) + { + await _workOrderDataService.AddWorkOrderContactsAsync(workOrderId, model.ContactIds); } - foreach (int categoryId in categoryIds) + if (model.CategoryIds != null && model.CategoryIds.Any()) { - // Create association with category - workOrder.WorkOrderCategories.Add(new WorkOrderCategories { CategoryId = categoryId }); + await _workOrderDataService.AddWorkOrderCategoriesAsync(workOrderId, model.CategoryIds); } - #region Images of workorder - workOrder.workOrderAttachments = workOrder.workOrderAttachments == null ? new List() : workOrder.workOrderAttachments; if (model.Attachments != null) { foreach (var attachment in model.Attachments) { - string fileName = ContentDispositionHeaderValue.Parse(attachment.ContentDisposition).FileName.Trim('"'); - string uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; - - string uploadProfilePath = Path.Combine("Assets", "Documents"); - string fullPath = Path.Combine(_webHostEnvironment.WebRootPath, uploadProfilePath, uniqueFileName); - - Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); - - // Copy the file to the server - using (var ms = System.IO.File.Create(fullPath)) + var fileUrl = await SaveFileAsync(attachment); + await _workOrderDataService.AddWorkOrderAttachmentAsync(new WorkOrderAttachments { - await attachment.CopyToAsync(ms); - } - - // Update the user's image URL - var request = _httpContext.HttpContext.Request; - var domain = $"{request.Scheme}://{request.Host}"; - var imagesPath = domain + "/" + Path.Combine(uploadProfilePath, uniqueFileName); - workOrder.workOrderAttachments.Add(new WorkOrderAttachments { Attachments = imagesPath }); + WorkorderId = workOrderId, + Attachments = fileUrl + }); } } if (model.BeforPhotoAttachment != null) { - string fileName = ContentDispositionHeaderValue.Parse(model.BeforPhotoAttachment.ContentDisposition).FileName.Trim('"'); - string uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; - - string uploadProfilePath = Path.Combine("Assets", "Documents"); - string fullPath = Path.Combine(_webHostEnvironment.WebRootPath, uploadProfilePath, uniqueFileName); - - Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); - // Copy the file to the server - using (var ms = System.IO.File.Create(fullPath)) + var fileUrl = await SaveFileAsync(model.BeforPhotoAttachment); + var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); + if (workOrder != null) { - await model.BeforPhotoAttachment.CopyToAsync(ms); + workOrder.BeforPhotoAttachment = fileUrl; + await _workOrderDataService.UpdateAsync(workOrder); } - // Update the user's image URL - var request = _httpContext.HttpContext.Request; - var domain = $"{request.Scheme}://{request.Host}"; - workOrder.BeforPhotoAttachment = domain + "/" + Path.Combine(uploadProfilePath, uniqueFileName); } + if (model.AfterPhotoAttachment != null) { - string fileName = ContentDispositionHeaderValue.Parse(model.AfterPhotoAttachment.ContentDisposition).FileName.Trim('"'); - string uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; - - string uploadProfilePath = Path.Combine("Assets", "Documents"); - string fullPath = Path.Combine(_webHostEnvironment.WebRootPath, uploadProfilePath, uniqueFileName); - - Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); - // Copy the file to the server - using (var ms = System.IO.File.Create(fullPath)) + var fileUrl = await SaveFileAsync(model.AfterPhotoAttachment); + var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); + if (workOrder != null) { - await model.AfterPhotoAttachment.CopyToAsync(ms); + workOrder.AfterPhotoAttachment = fileUrl; + await _workOrderDataService.UpdateAsync(workOrder); } - // Update the user's image URL - var request = _httpContext.HttpContext.Request; - var domain = $"{request.Scheme}://{request.Host}"; - workOrder.AfterPhotoAttachment = domain + "/" + Path.Combine(uploadProfilePath, uniqueFileName); } + if (model.SignOffAttachment != null) { - string fileName = ContentDispositionHeaderValue.Parse(model.SignOffAttachment.ContentDisposition).FileName.Trim('"'); - string uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; - - string uploadProfilePath = Path.Combine("Assets", "Documents"); - string fullPath = Path.Combine(_webHostEnvironment.WebRootPath, uploadProfilePath, uniqueFileName); - - Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); - // Copy the file to the server - using (var ms = System.IO.File.Create(fullPath)) + var fileUrl = await SaveFileAsync(model.SignOffAttachment); + var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); + if (workOrder != null) { - await model.SignOffAttachment.CopyToAsync(ms); + workOrder.SignOffAttachment = fileUrl; + await _workOrderDataService.UpdateAsync(workOrder); } - // Update the user's image URL - var request = _httpContext.HttpContext.Request; - var domain = $"{request.Scheme}://{request.Host}"; - workOrder.SignOffAttachment = domain + "/" + Path.Combine(uploadProfilePath, uniqueFileName); } - #endregion - _db.Add(workOrder); - _db.SaveChanges(); - return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); + + return Ok(new DataResponse { Message = "Work order created successfully", Status = "200", Data = workOrderId }); + } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); } catch (Exception ex) { @@ -184,186 +170,97 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost] [HttpPut("{id:int}")] [Route("EditWorkorder")] - public async Task Editworkorder([FromBody] EditWorkorder_DTO model) + public async Task Editworkorder([FromForm] EditWorkorder_DTO model) { try { - var workOrder = _db.workOrders.Where(w => w.Id == model.Id).FirstOrDefault(); - if (workOrder == null) - return BadRequest(new Response { Status = "Error", Message = "Work order not found" }); + if (!model.Id.HasValue) + return BadRequest(new Response { Status = "Error", Message = "Work order ID is required" }); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var now = DateTime.UtcNow; - void LogChange(string field, string? oldVal, string? newVal) + + var updateDto = new UpdateWorkOrderDTO { - if (oldVal == newVal) return; - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog - { - WorkOrderId = workOrder.Id, - UserId = userId, - FieldName = field, - OldValue = oldVal ?? "", - NewValue = newVal ?? "", - Action = "edit", - CreatedAt = now - }); + WorkerOrderTitle = model.WorkerOrderTitle, + Description = model.Description, + Status = model.Status, + Priority = model.Priority, + LocationId = model.LocationId, + AssignTo = model.AssignTo, + DueDate = model.DueDate, + CompletedDate = model.CompletedDate + }; + + var success = await _workOrderService.UpdateWorkOrderAsync(model.Id.Value, updateDto, userId!); + if (!success) + return BadRequest(new Response { Status = "Error", Message = "Work order not found" }); + + await _workOrderDataService.DeleteWorkOrderContactsAsync(model.Id.Value); + await _workOrderDataService.DeleteWorkOrderCategoriesAsync(model.Id.Value); + + if (model.ContactIds != null && model.ContactIds.Any()) + { + await _workOrderDataService.AddWorkOrderContactsAsync(model.Id.Value, model.ContactIds); } - LogChange("WorkOrderNumber", workOrder.WorkerOrderNumber, model.WorkerOrderNumber); - LogChange("Title", workOrder.WorkerOrderTitle, model.WorkerOrderTitle); - LogChange("Description", workOrder.Description, model.Description); - LogChange("TT", workOrder.TT, model.TT); - LogChange("PO", workOrder.PO, model.PO); - LogChange("AssignTo", workOrder.AssignTo, model.AssignTo); - LogChange("DueDate", workOrder.DueDate?.ToString("yyyy-MM-dd"), model.DueDate?.ToString("yyyy-MM-dd")); - LogChange("Priority", workOrder.Priority, model.Priority); - LogChange("LocationId", workOrder.LocationId?.ToString(), model.LocationId?.ToString()); - LogChange("Status", workOrder.Status, model.Status); - LogChange("Problem", workOrder.Problem, model.Problem); - LogChange("Trade", workOrder.Trade, model.Trade); - LogChange("SubTrade", workOrder.SubTrade, model.SubTrade); - LogChange("VendorNTE", workOrder.VendorNTE?.ToString("F2"), model.VendorNTE?.ToString("F2")); - LogChange("ScheduledDate", workOrder.ScheduledDate?.ToString("yyyy-MM-dd"), model.ScheduledDate?.ToString("yyyy-MM-dd")); - LogChange("CompletedDate", workOrder.CompletedDate?.ToString("yyyy-MM-dd"), model.CompletedDate?.ToString("yyyy-MM-dd")); - LogChange("Source", workOrder.Source, model.Source); - - workOrder.WorkerOrderNumber = model.WorkerOrderNumber; - workOrder.WorkerOrderTitle = model.WorkerOrderTitle; - workOrder.Description = model.Description; - workOrder.TT = model.TT; - workOrder.PO = model.PO; - workOrder.AssignTo = model.AssignTo; - workOrder.DueDate = model.DueDate; - workOrder.Priority = model.Priority; - workOrder.LocationId = model.LocationId; - workOrder.Status = model.Status; - workOrder.Problem = model.Problem; - workOrder.Trade = model.Trade; - workOrder.SubTrade = model.SubTrade; - workOrder.VendorNTE = model.VendorNTE; - workOrder.ScheduledDate = model.ScheduledDate; - workOrder.CompletedDate = model.CompletedDate; - workOrder.Source = model.Source; - workOrder.BeforPhotoIssue = model.BeforPhotoIssue; - workOrder.AfterPhotoIssue = model.AfterPhotoIssue; - workOrder.SignOffName = model.SignOffName; - workOrder.SignOffSignature = model.SignOffSignature; - var existcontact = _db.WorkOrderContacts.Where(w => w.WorkorderId == model.Id).ToList(); - if (existcontact.Count() > 0) + if (model.CategoryIds != null && model.CategoryIds.Any()) { - _db.WorkOrderContacts.RemoveRange(existcontact); - } - var existcategory = _db.workOrderCategories.Where(w => w.WorkorderId == model.Id).ToList(); - if (existcategory.Count() > 0) - { - _db.workOrderCategories.RemoveRange(existcategory); - } - workOrder.WorkOrderContacts = workOrder.WorkOrderContacts == null ? new List() : workOrder.WorkOrderContacts; - List contactIds = model.GetContactIds(); - - workOrder.WorkOrderCategories = workOrder.WorkOrderCategories == null ? new List() : workOrder.WorkOrderCategories; - List categoryIds = model.GetCategoryIds(); - - // Create new records for the associations (pseudo-code) - foreach (int contactId in contactIds) - { - // Create association with contact - workOrder.WorkOrderContacts.Add(new WorkOrderContacts { ContactId = contactId }); + await _workOrderDataService.AddWorkOrderCategoriesAsync(model.Id.Value, model.CategoryIds); } - foreach (int categoryId in categoryIds) - { - // Create association with category - workOrder.WorkOrderCategories.Add(new WorkOrderCategories { CategoryId = categoryId }); - } - - #region Images of workorder - workOrder.workOrderAttachments = workOrder.workOrderAttachments == null ? new List() : workOrder.workOrderAttachments; if (model.Attachments != null) { foreach (var attachment in model.Attachments) { - string fileName = ContentDispositionHeaderValue.Parse(attachment.ContentDisposition).FileName.Trim('"'); - string uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; - - string uploadProfilePath = Path.Combine("Assets", "Documents"); - string fullPath = Path.Combine(_webHostEnvironment.WebRootPath, uploadProfilePath, uniqueFileName); - - Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); - - using (var ms = System.IO.File.Create(fullPath)) + var fileUrl = await SaveFileAsync(attachment); + await _workOrderDataService.AddWorkOrderAttachmentAsync(new WorkOrderAttachments { - await attachment.CopyToAsync(ms); - } - - var request = _httpContext.HttpContext.Request; - var domain = $"{request.Scheme}://{request.Host}"; - var imagesPath = domain + "/" + Path.Combine(uploadProfilePath, uniqueFileName); - workOrder.workOrderAttachments.Add(new WorkOrderAttachments { Attachments = imagesPath }); + WorkorderId = model.Id.Value, + Attachments = fileUrl + }); } } if (model.BeforPhotoAttachment != null) { - string fileName = ContentDispositionHeaderValue.Parse(model.BeforPhotoAttachment.ContentDisposition).FileName.Trim('"'); - string uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; - - string uploadProfilePath = Path.Combine("Assets", "Documents"); - string fullPath = Path.Combine(_webHostEnvironment.WebRootPath, uploadProfilePath, uniqueFileName); - - Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); - // Copy the file to the server - using (var ms = System.IO.File.Create(fullPath)) + var fileUrl = await SaveFileAsync(model.BeforPhotoAttachment); + var workOrder = await _workOrderDataService.GetByIdAsync(model.Id.Value); + if (workOrder != null) { - await model.BeforPhotoAttachment.CopyToAsync(ms); + workOrder.BeforPhotoAttachment = fileUrl; + await _workOrderDataService.UpdateAsync(workOrder); } - // Update the user's image URL - var request = _httpContext.HttpContext.Request; - var domain = $"{request.Scheme}://{request.Host}"; - workOrder.BeforPhotoAttachment = domain + "/" + Path.Combine(uploadProfilePath, uniqueFileName); } + if (model.AfterPhotoAttachment != null) { - string fileName = ContentDispositionHeaderValue.Parse(model.AfterPhotoAttachment.ContentDisposition).FileName.Trim('"'); - string uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; - - string uploadProfilePath = Path.Combine("Assets", "Documents"); - string fullPath = Path.Combine(_webHostEnvironment.WebRootPath, uploadProfilePath, uniqueFileName); - - Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); - // Copy the file to the server - using (var ms = System.IO.File.Create(fullPath)) + var fileUrl = await SaveFileAsync(model.AfterPhotoAttachment); + var workOrder = await _workOrderDataService.GetByIdAsync(model.Id.Value); + if (workOrder != null) { - await model.AfterPhotoAttachment.CopyToAsync(ms); + workOrder.AfterPhotoAttachment = fileUrl; + await _workOrderDataService.UpdateAsync(workOrder); } - // Update the user's image URL - var request = _httpContext.HttpContext.Request; - var domain = $"{request.Scheme}://{request.Host}"; - workOrder.AfterPhotoAttachment = domain + "/" + Path.Combine(uploadProfilePath, uniqueFileName); } + if (model.SignOffAttachment != null) { - string fileName = ContentDispositionHeaderValue.Parse(model.SignOffAttachment.ContentDisposition).FileName.Trim('"'); - string uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; - - string uploadProfilePath = Path.Combine("Assets", "Documents"); - string fullPath = Path.Combine(_webHostEnvironment.WebRootPath, uploadProfilePath, uniqueFileName); - - Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); - // Copy the file to the server - using (var ms = System.IO.File.Create(fullPath)) + var fileUrl = await SaveFileAsync(model.SignOffAttachment); + var workOrder = await _workOrderDataService.GetByIdAsync(model.Id.Value); + if (workOrder != null) { - await model.SignOffAttachment.CopyToAsync(ms); + workOrder.SignOffAttachment = fileUrl; + await _workOrderDataService.UpdateAsync(workOrder); } - // Update the user's image URL - var request = _httpContext.HttpContext.Request; - var domain = $"{request.Scheme}://{request.Host}"; - workOrder.SignOffAttachment = domain + "/" + Path.Combine(uploadProfilePath, uniqueFileName); } - #endregion - _db.SaveChanges(); + return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } catch (Exception ex) { return BadRequest(new Response { Status = "Error", Message = ex.Message }); @@ -373,79 +270,17 @@ namespace Api.SeaHavenIndustries.Controllers // GET: api/WorkOrder/GetWorkOrderList // ----------------------------------- [HttpGet("GetWorkOrderList")] - public IActionResult GetWorkOrderList(string? search = "", int page = 1, int pageSize = 12, string? sortBy = "lastUpdated", string? sortDir = "desc", string? status = "", string? assignee = "", int? locationId = null) + public async Task GetWorkOrderList(string? search = "", int page = 1, int pageSize = 12, string? sortBy = "lastUpdated", string? sortDir = "desc", string? status = "", string? assignee = "", int? locationId = null) { - search ??= ""; - - var query = _db.workOrders - .Include(w => w.Locations) - .Include(w => w.AssignToUser) - .Where(w => w.istemplate != true); - - if (locationId.HasValue) - query = query.Where(w => w.LocationId == locationId.Value); - - if (!string.IsNullOrWhiteSpace(status)) - query = query.Where(w => w.Status == status); - - if (assignee == "__unassigned") - query = query.Where(w => w.AssignTo == null || w.AssignTo == ""); - else if (!string.IsNullOrWhiteSpace(assignee)) - query = query.Where(w => w.AssignToUser != null && (w.AssignToUser.FirstName + " " + w.AssignToUser.LastName).Contains(assignee)); - - if (!string.IsNullOrWhiteSpace(search)) - { - var s = search.ToLower(); - query = query.Where(w => - (w.InternalWONumber ?? "").ToLower().Contains(s) || - (w.WorkerOrderNumber ?? "").ToLower().Contains(s) || - (w.WorkerOrderTitle ?? "").ToLower().Contains(s) || - (w.Locations.Title ?? "").ToLower().Contains(s) || - (w.Locations.Name ?? "").ToLower().Contains(s) - ); - } - - var totalCount = query.Count(); - - var isAsc = sortDir?.ToLower() == "asc"; - IOrderedQueryable ordered = (sortBy?.ToLower()) switch - { - "number" => isAsc ? query.OrderBy(w => w.InternalWONumber) : query.OrderByDescending(w => w.InternalWONumber), - "title" => isAsc ? query.OrderBy(w => w.WorkerOrderTitle) : query.OrderByDescending(w => w.WorkerOrderTitle), - "location" => isAsc ? query.OrderBy(w => w.Locations != null ? w.Locations.Name : "") : query.OrderByDescending(w => w.Locations != null ? w.Locations.Name : ""), - "priority" => isAsc ? query.OrderBy(w => w.Priority) : query.OrderByDescending(w => w.Priority), - "status" => isAsc ? query.OrderBy(w => w.Status) : query.OrderByDescending(w => w.Status), - "createddate" => isAsc ? query.OrderBy(w => w.CreatedDate) : query.OrderByDescending(w => w.CreatedDate), - "assignedto" => isAsc ? query.OrderBy(w => w.AssignToUser != null ? w.AssignToUser.FirstName : "") : query.OrderByDescending(w => w.AssignToUser != null ? w.AssignToUser.FirstName : ""), - "lastupdated" => isAsc ? query.OrderBy(w => w.LastModificationTime) : query.OrderByDescending(w => w.LastModificationTime), - _ => query.OrderByDescending(w => w.Id) - }; - - var data = ordered - .Skip((page - 1) * pageSize) - .Take(pageSize) - .Select(w => new - { - id = w.Id, - internalWONumber = w.InternalWONumber, - workOrderNumber = w.WorkerOrderNumber, - number = w.InternalWONumber ?? w.WorkerOrderNumber, - title = w.WorkerOrderTitle, - location = w.Locations != null ? w.Locations.Name : "", - locationName = w.Locations != null ? w.Locations.Name : "", - priority = w.Priority, - status = w.Status, - createdDate = w.CreatedDate, - dueDate = w.DueDate, - assignedTo = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "", - employeeName = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "", - lastUpdated = new[] { - w.LastModificationTime, - w.Comments.Max(c => (DateTime?)c.CreatedDate), - _db.WorkOrderAuditLogs.Where(a => a.WorkOrderId == w.Id).Max(a => (DateTime?)a.CreatedAt) - }.Where(d => d.HasValue).Max() - }) - .ToList(); + var (data, totalCount) = await _workOrderDataService.GetWorkOrderListPagedAsync( + page, + pageSize, + search, + status, + assignee, + locationId, + sortBy, + sortDir); var viewModel = new Pagination_DTO { @@ -462,7 +297,7 @@ namespace Api.SeaHavenIndustries.Controllers [HttpDelete] [Route("DeleteWorkorder")] - public IActionResult Deleteworkorder([FromBody] EditWorkorder_DTO model) + public async Task Deleteworkorder([FromBody] EditWorkorder_DTO model) { int id = model.Id.HasValue ? model.Id.Value : 0; if (id == 0) @@ -471,46 +306,20 @@ namespace Api.SeaHavenIndustries.Controllers } try { - var exist = _db.workOrders.Where(w => w.Id == id).FirstOrDefault(); - if (exist != null) - { - var quotes = _db.Quotes.Where(w => w.WorkorderId == id).ToList(); - if (quotes.Count() > 0) - { - foreach (var itm in quotes) - { - var existquotes = _db.Quotes.Where(w => w.Id == itm.Id).FirstOrDefault(); - existquotes.WorkorderId = null; - } - } - var attachments = _db.workOrderAttachments.Where(w => w.WorkorderId == id).ToList(); - if (attachments.Count() > 0) - { - _db.RemoveRange(attachments); - } - var contacts = _db.WorkOrderContacts.Where(w => w.WorkorderId == id).ToList(); - if (contacts.Count() > 0) - { - _db.RemoveRange(contacts); - } - var category = _db.workOrderCategories.Where(w => w.WorkorderId == id).ToList(); - if (category.Count() > 0) - { - _db.RemoveRange(category); - } - var comments = _db.Comments.Where(w => w.WorkerOrderId == id).ToList(); - if (comments.Count() > 0) - { - _db.RemoveRange(comments); - } - _db.Remove(exist); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var success = await _workOrderService.DeleteWorkOrderAsync(id, userId!); - } - else + if (!success) + return BadRequest("Work order not found!"); + + await _quotesDataService.UnlinkFromWorkOrderAsync(id); + + var comments = await _commentDataService.GetByWorkOrderIdAsync(id); + foreach (var comment in comments) { - return BadRequest("ID not matched!"); + await _commentDataService.DeleteAsync(comment.Id); } - _db.SaveChanges(); + return Ok(new DataResponse { Message = "Removed Successfully", Status = "200" }); } catch (Exception ex) @@ -644,7 +453,7 @@ namespace Api.SeaHavenIndustries.Controllers assignee = s.AssignToUser != null ? s.AssignToUser.FirstName : "", location = s.Locations != null ? s.Locations.Name : "", locationId = s.LocationId, - locationZip = s.Locations != null ? (s.Locations.ZipCode ?? ExtractZip(s.Locations.Address)) : "", + locationZip = s.Locations != null ? (s.Locations.Zip ?? ExtractZip(s.Locations.Address1)) : "", priority = s.Priority, s.Problem, s.Trade, @@ -832,25 +641,30 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var data = await _db.workOrders.Where(w => w.Id == id).FirstOrDefaultAsync(); - if (data == null) + var workOrder = await _workOrderDataService.GetByIdAsync(id); + if (workOrder == null) return BadRequest(new Response { Status = "Error", Message = "Work order not found" }); - if (data.Status != status) + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + + if (workOrder.Status != status) { - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = id, - UserId = User.FindFirstValue(ClaimTypes.NameIdentifier), + UserId = userId, FieldName = "Status", - OldValue = data.Status ?? "", + OldValue = workOrder.Status ?? "", NewValue = status, - Action = "status_change", + Action = AuditAction.StatusChange.ToString(), CreatedAt = DateTime.UtcNow }); } - data.Status = status; - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Message = "Updated Successfully", Status = "200", Data = data }); + + workOrder.Status = status; + await _workOrderDataService.UpdateAsync(workOrder); + + return Ok(new DataResponse { Message = "Updated Successfully", Status = "200", Data = WorkOrderDTO.MapToModel(workOrder) }); } catch (Exception ex) { @@ -864,28 +678,33 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var data = await _db.workOrders.Where(w => w.Id == id).FirstOrDefaultAsync(); - if (data == null) + var workOrder = await _workOrderDataService.GetByIdAsync(id); + if (workOrder == null) return BadRequest(new Response { Status = "Error", Message = "Work order not found" }); - if (data.AssignTo != assignTo) + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + + if (workOrder.AssignTo != assignTo) { - var oldUser = data.AssignTo != null ? await _db.Users.FindAsync(data.AssignTo) : null; - var newUser = assignTo != null ? await _db.Users.FindAsync(assignTo) : null; - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + var oldUser = workOrder.AssignTo != null ? await _userDataService.GetByIdAsync(workOrder.AssignTo) : null; + var newUser = assignTo != null ? await _userDataService.GetByIdAsync(assignTo) : null; + + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = id, - UserId = User.FindFirstValue(ClaimTypes.NameIdentifier), + UserId = userId, FieldName = "AssignedTo", OldValue = oldUser != null ? (oldUser.FirstName + " " + oldUser.LastName).Trim() : "", NewValue = newUser != null ? (newUser.FirstName + " " + newUser.LastName).Trim() : "Unassigned", - Action = "assignment_change", + Action = AuditAction.AssignmentChange.ToString(), CreatedAt = DateTime.UtcNow }); } - data.AssignTo = assignTo; - await _db.SaveChangesAsync(); - var assignedUser = assignTo != null ? await _db.Users.FindAsync(assignTo) : null; + workOrder.AssignTo = assignTo; + await _workOrderDataService.UpdateAsync(workOrder); + + var assignedUser = assignTo != null ? await _userDataService.GetByIdAsync(assignTo) : null; var assignedName = assignedUser != null ? (assignedUser.FirstName + " " + assignedUser.LastName).Trim() : ""; return Ok(new { message = "Updated Successfully", assignedTo = assignedName }); } @@ -902,34 +721,23 @@ namespace Api.SeaHavenIndustries.Controllers try { var userId = _httpContext?.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? ""; - Comments comments = new Comments(); - comments.CreatedDate = DateTime.Now; - comments.UserId = userId; - comments.WorkerOrderId = model.WorkorderId; - comments.Commenttext = model.Text; - comments.CommentType = model.CommentType ?? "vendor"; + + var comment = new Comments + { + UserId = userId, + WorkerOrderId = model.WorkorderId, + Commenttext = model.Text, + CommentType = model.CommentType ?? "vendor" + }; + if (model.Document != null) { - string fileName = ContentDispositionHeaderValue.Parse(model.Document.ContentDisposition).FileName.Trim('"'); - string uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; - - string uploadProfilePath = Path.Combine("Assets", "Documents"); - string fullPath = Path.Combine(_webHostEnvironment.WebRootPath, uploadProfilePath, uniqueFileName); - - Directory.CreateDirectory(Path.GetDirectoryName(fullPath)); - // Copy the file to the server - using (var ms = System.IO.File.Create(fullPath)) - { - await model.Document.CopyToAsync(ms); - } - // Update the user's image URL - var request = _httpContext.HttpContext.Request; - var domain = $"{request.Scheme}://{request.Host}"; - comments.Documents = domain + "/" + Path.Combine(uploadProfilePath, uniqueFileName); + var fileUrl = await SaveFileAsync(model.Document); + comment.Documents = fileUrl; } - _db.Comments.Add(comments); - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Message = "Updated Successfully", Status = "200", Data = comments }); + + var saved = await _commentDataService.AddAsync(comment); + return Ok(new DataResponse { Message = "Updated Successfully", Status = "200", Data = saved }); } catch (Exception ex) { @@ -946,22 +754,21 @@ namespace Api.SeaHavenIndustries.Controllers var userId = _httpContext?.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? ""; var comment = new Comments { - CreatedDate = DateTime.UtcNow, UserId = userId, WorkerOrderId = model.WorkorderId, Commenttext = model.Text, CommentType = model.CommentType ?? "internal", }; - _db.Comments.Add(comment); - await _db.SaveChangesAsync(); - var user = await _db.Users.FindAsync(userId); + var saved = await _commentDataService.AddAsync(comment); + var user = await _userDataService.GetByIdAsync(userId); + return Ok(new { - comment.Id, - comment.CreatedDate, - comment.Commenttext, - comment.CommentType, + saved.Id, + saved.CreatedDate, + saved.Commenttext, + saved.CommentType, userName = user != null ? (user.FirstName + " " + user.LastName).Trim() : "" }); } @@ -975,13 +782,14 @@ namespace Api.SeaHavenIndustries.Controllers [Route("GetComments")] public async Task GetComments() { - var data = await _db.Comments.Include(i => i.ApplicationUser).Select(s => new + var comments = await _commentDataService.GetAllAsync(); + var data = comments.Select(s => new { s.Id, s.Commenttext, FirstName = s.ApplicationUser != null ? s.ApplicationUser.FirstName : "", s.Documents - }).ToListAsync(); + }).ToList(); return Ok(data); } @@ -989,47 +797,42 @@ namespace Api.SeaHavenIndustries.Controllers [Route("GetCommentsByWorkorderId")] public async Task GetCommentsByWorkorderId(int woid) { - var data = await _db.Comments.Where(w => w.WorkerOrderId == woid).Include(i => i.ApplicationUser).Select(s => new + var comments = await _commentDataService.GetByWorkOrderIdAsync(woid); + var data = comments.Select(s => new { s.Id, s.Commenttext, FirstName = s.ApplicationUser != null ? s.ApplicationUser.FirstName : "", s.Documents - }).ToListAsync(); + }).ToList(); return Ok(data); } [HttpGet] [Route("GetworkordersDD")] public async Task GetworkordersDD() { - _db.ChangeTracker.Clear(); - - var data = await _db.workOrders.Where(w => w.istemplate != true).Include(w => w.Locations).Include(w => w.AssignToUser).ToListAsync(); - return Ok(data); + var data = await _workOrderDataService.GetAllAsync(); + return Ok(data.Where(w => w.istemplate != true)); } [HttpGet] [Route("Getworkorders")] public async Task Getworkorders() { - _db.ChangeTracker.Clear(); - var data = await _db.workOrders - .Where(w => w.istemplate != true) - .AsSplitQuery() - .Include(w => w.Locations) - .Include(w => w.AssignToUser).Select(s => new WorkorderFilterVM - { - Id = s.Id, - Priority = s.Priority, - Status = s.Status, - WorkerOrderNumber = s.WorkerOrderNumber, - WorkerOrderTitle = s.WorkerOrderTitle, - DueDate = s.DueDate, - AssignToUserName = s.AssignToUser != null ? s.AssignToUser.FirstName : "", - LocationName = s.Locations != null ? s.Locations.Name : "", - LocationId = s.LocationId, - PO = s.PO, - TT = s.TT, - }).ToListAsync(); + var workOrders = await _workOrderDataService.GetAllWithDetailsAsync(); + var data = workOrders.Select(s => new WorkorderFilterVM + { + Id = s.Id, + Priority = s.Priority, + Status = s.Status, + WorkerOrderNumber = s.WorkerOrderNumber, + WorkerOrderTitle = s.WorkerOrderTitle, + DueDate = s.DueDate, + AssignToUserName = s.AssignToUser != null ? s.AssignToUser.FirstName : "", + LocationName = s.Locations != null ? s.Locations.Name : "", + LocationId = s.LocationId, + PO = s.PO, + TT = s.TT, + }).ToList(); return Ok(data); } [HttpPost] @@ -1038,40 +841,28 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var workOrders = await _db.workOrders.Include(w => w.Locations) - .Where(w => model.WorkOrderIds.Contains(w.Id)) - .ToListAsync(); + var allWorkOrders = await _workOrderDataService.GetAllWithDetailsAsync(); + var workOrders = allWorkOrders.Where(w => model.WorkOrderIds.Contains(w.Id)).ToList(); + if (workOrders.Count == 0) return BadRequest(new Response { Status = "Error", Message = "No work orders found" }); var primaryWO = workOrders.First(); - var lastPO = await _db.Dispatches - .Where(d => d.PONumber != null) - .OrderByDescending(d => d.PONumber) - .Select(d => d.PONumber) - .FirstOrDefaultAsync(); - int nextPO = 1; - if (lastPO != null && lastPO.StartsWith("VPO-") && int.TryParse(lastPO.Substring(4), out var lastNum)) - nextPO = lastNum + 1; + var lastPO = await _dispatchDataService.GenerateNextPONumberAsync(); + int nextPO = int.TryParse(lastPO, out var po) ? po : 1; - var lastDSP = await _db.Dispatches - .Where(d => d.DispatchNumber != null) - .OrderByDescending(d => d.DispatchNumber) - .Select(d => d.DispatchNumber) - .FirstOrDefaultAsync(); - int nextDSP = 1; - if (lastDSP != null && lastDSP.StartsWith("DSP-") && int.TryParse(lastDSP.Substring(4), out var lastDspNum)) - nextDSP = lastDspNum + 1; + var lastDSP = await _dispatchDataService.GenerateNextDispatchNumberAsync(); + int nextDSP = lastDSP.StartsWith("DSP-") && int.TryParse(lastDSP.Substring(4), out var dspNum) ? dspNum : 1; var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var currentUser = userId != null ? await _db.Users.FindAsync(userId) : null; + var currentUser = userId != null ? await _userDataService.GetByIdAsync(userId) : null; var senderName = currentUser != null ? (currentUser.FirstName + " " + currentUser.LastName).Trim() : ""; var dispatches = new List(); foreach (var vendorId in model.VendorIds) { - var vendor = await _db.Vendors.FindAsync(vendorId); + var vendor = await _vendorDataService.GetByIdAsync(vendorId); if (vendor == null) continue; var poNumber = $"VPO-{nextPO:D5}"; @@ -1099,9 +890,7 @@ namespace Api.SeaHavenIndustries.Controllers var checklistItems = new List(); if (model.TaskListTemplateId.HasValue) { - var template = await _db.TaskListTemplates - .Include(t => t.Items) - .FirstOrDefaultAsync(t => t.Id == model.TaskListTemplateId.Value); + var template = await _taskListTemplateDataService.GetByIdWithItemsAsync(model.TaskListTemplateId.Value); if (template?.Items != null) { checklistItems.AddRange(template.Items.OrderBy(i => i.SortOrder).Select((i, idx) => @@ -1123,7 +912,7 @@ namespace Api.SeaHavenIndustries.Controllers $"{wo.DueDate?.ToString("yyyy-MM-dd") ?? "N/A"}")); var locationName = System.Net.WebUtility.HtmlEncode(primaryWO.Locations?.Name ?? ""); - var locationAddr = System.Net.WebUtility.HtmlEncode(primaryWO.Locations?.Address ?? ""); + var locationAddr = System.Net.WebUtility.HtmlEncode(primaryWO.Locations?.Address1 ?? ""); var subject = $"[{dispatchNumber}] {(workOrders.Count > 1 ? $"{workOrders.Count} Work Orders" : primaryWO.WorkerOrderTitle)} at {primaryWO.Locations?.Name ?? ""}"; var descriptionHtml = System.Net.WebUtility.HtmlEncode(model.Description ?? primaryWO.Description ?? ""); var senderNameHtml = System.Net.WebUtility.HtmlEncode(senderName); @@ -1158,26 +947,25 @@ namespace Api.SeaHavenIndustries.Controllers var emailSent = await _sendMessage.SendDispatchEmail(vendor.Email, subject, html, replyTo); dispatch.EmailSent = emailSent; - _db.Dispatches.Add(dispatch); + var savedDispatch = await _dispatchDataService.AddAsync(dispatch); foreach (var wo in workOrders) { - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = wo.Id, UserId = userId, FieldName = "Dispatch", OldValue = "", NewValue = $"{vendor.CompanyName} ({dispatchNumber})", - Action = "dispatch", + Action = AuditAction.Dispatch.ToString(), CreatedAt = DateTime.UtcNow }); } - dispatches.Add(new { dispatch.Id, dispatchNumber, poNumber, vendorName = vendor.CompanyName, dispatch.Status, workOrderCount = workOrders.Count }); + dispatches.Add(new { savedDispatch.Id, dispatchNumber, poNumber, vendorName = vendor.CompanyName, savedDispatch.Status, workOrderCount = workOrders.Count }); } - await _db.SaveChangesAsync(); return Ok(new { message = "Dispatched", dispatches }); } catch (Exception ex) @@ -1189,40 +977,23 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("GetDispatches/{workOrderId}")] public async Task GetDispatches(int workOrderId) { - var dispatchIds = await _db.DispatchWorkOrders - .Where(dwo => dwo.WorkOrderId == workOrderId) - .Select(dwo => dwo.DispatchId) - .Distinct() - .ToListAsync(); - - var directIds = await _db.Dispatches - .Where(d => d.WorkOrderId == workOrderId) - .Select(d => d.Id) - .ToListAsync(); - - var allIds = dispatchIds.Union(directIds).Distinct().ToList(); - - var data = await _db.Dispatches - .Where(d => allIds.Contains(d.Id)) - .Include(d => d.Vendor) - .OrderByDescending(d => d.DispatchedAt) - .Select(d => new - { - d.Id, - d.DispatchNumber, - d.PONumber, - d.NTEAmount, - d.Description, - d.Status, - d.ScheduledDate, - d.CompletedDate, - d.DispatchedAt, - d.EmailSent, - vendorName = d.Vendor != null ? d.Vendor.CompanyName : "", - vendorEmail = d.Vendor != null ? d.Vendor.Email : "", - vendorId = d.VendorId - }) - .ToListAsync(); + var dispatches = await _dispatchDataService.GetByWorkOrderIdAsync(workOrderId); + var data = dispatches.OrderByDescending(d => d.DispatchedAt).Select(d => new + { + d.Id, + d.DispatchNumber, + d.PONumber, + d.NTEAmount, + d.Description, + d.Status, + d.ScheduledDate, + d.CompletedDate, + d.DispatchedAt, + d.EmailSent, + vendorName = d.Vendor != null ? d.Vendor.CompanyName : "", + vendorEmail = d.Vendor != null ? d.Vendor.Email : "", + vendorId = d.VendorId + }).ToList(); return Ok(data); } @@ -1498,24 +1269,22 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("UpdateChecklistItem")] public async Task UpdateChecklistItem([FromBody] ChecklistItemUpdate_DTO model) { - var item = await _db.DispatchChecklistItems.FindAsync(model.Id); + var item = await _dispatchDataService.GetChecklistItemByIdAsync(model.Id); if (item == null) return NotFound(new Response { Status = "Error", Message = "Checklist item not found" }); item.IsCompleted = model.IsCompleted; item.CompletedBy = model.IsCompleted ? model.CompletedBy : null; item.CompletedAt = model.IsCompleted ? DateTime.UtcNow : null; - - await _db.SaveChangesAsync(); + await _dispatchDataService.UpdateChecklistItemAsync(item); return Ok(new { message = "Updated", item.Id, item.IsCompleted, item.CompletedBy, item.CompletedAt }); } [HttpPost("AddChecklistItem")] public async Task AddChecklistItem([FromBody] AddChecklistItem_DTO model) { - var maxSort = await _db.DispatchChecklistItems - .Where(c => c.DispatchId == model.DispatchId) - .MaxAsync(c => (int?)c.SortOrder) ?? -1; + var checklistItems = await _dispatchDataService.GetChecklistItemsAsync(model.DispatchId); + var maxSort = checklistItems.Any() ? checklistItems.Max(c => c.SortOrder) : -1; var item = new DispatchChecklistItem { @@ -1525,24 +1294,32 @@ namespace Api.SeaHavenIndustries.Controllers SortOrder = maxSort + 1, IsCompleted = false }; - _db.DispatchChecklistItems.Add(item); - await _db.SaveChangesAsync(); + await _dispatchDataService.AddChecklistItemAsync(item); return Ok(new { item.Id, item.ItemText, item.SortOrder, item.IsCompleted }); } private async Task GenerateInternalWONumber() { - var lastWO = await _db.workOrders - .Where(w => w.InternalWONumber != null) - .OrderByDescending(w => w.InternalWONumber) - .Select(w => w.InternalWONumber) - .FirstOrDefaultAsync(); + return await _workOrderDataService.GenerateNextInternalWONumberAsync(); + } - int next = 10000001; - if (lastWO != null && int.TryParse(lastWO, out var lastNum)) - next = lastNum + 1; + private async Task SaveFileAsync(IFormFile file) + { + var fileName = ContentDispositionHeaderValue.Parse(file.ContentDisposition).FileName.Trim('"'); + var uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; + var uploadPath = Path.Combine("Assets", "Documents"); + var fullPath = Path.Combine(_webHostEnvironment.WebRootPath, uploadPath, uniqueFileName); - return next.ToString("D8"); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + + using (var stream = System.IO.File.Create(fullPath)) + { + await file.CopyToAsync(stream); + } + + var request = _httpContext.HttpContext!.Request; + var domain = $"{request.Scheme}://{request.Host}"; + return $"{domain}/{uploadPath.Replace("\\", "/")}/{uniqueFileName}"; } public class WorkorderFilterVM diff --git a/Api.SeaHavenIndustries/DTOs/Contact_DTO.cs b/Api.SeaHavenIndustries/DTOs/Contact_DTO.cs index f2a102b..fc88967 100644 --- a/Api.SeaHavenIndustries/DTOs/Contact_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Contact_DTO.cs @@ -1,4 +1,7 @@ -namespace Api.SeaHavenIndustries.DTOs +using Data.SeaHavenIndustries; +using SeaHaven.Services.DTOs; + +namespace Api.SeaHavenIndustries.DTOs { public class Contact_DTO { @@ -20,5 +23,100 @@ public string? TwitterUrl { get; set; } public int? LocationId { get; set; } public int? AccountId { get; set; } + + // ✅ Map API DTO to Service DTO + public CreateContactDTO ToServiceCreateDTO() + { + return new CreateContactDTO + { + FirstName = this.FirstName, + LastName = this.LastName, + Email = this.Email ?? "", + Phone = this.PhoneNumber, + Title = this.Title, + Type = this.ContactType, + AccountId = this.AccountId + }; + } + + // Map from Entity to DTO (Model) + public static Contact_DTO MapToModel(Contacts entity) + { + return new Contact_DTO + { + Title = entity.Title, + Owner = entity.Owner, + FirstName = entity.FirstName, + MiddleName = entity.MiddleName, + LastName = entity.LastName, + ContactType = entity.ContactType, + PhoneNumber = entity.PhoneNumber, + Email = entity.Email, + Address1 = entity.Address1, + Address2 = entity.Address2, + City = entity.City, + State = entity.State, + Zip = entity.Zip, + FacebookUrl = entity.FacebookUrl, + LinkedInUrl = entity.LinkedInUrl, + TwitterUrl = entity.TwitterUrl, + LocationId = entity.LocationId, + AccountId = entity.AccountId + }; + } + + // Map from DTO (Model) to Entity + public static Contacts MapToEntity(Contact_DTO dto, Contacts? existingEntity = null) + { + var entity = existingEntity ?? new Contacts(); + + entity.Title = dto.Title; + entity.Owner = dto.Owner; + entity.FirstName = dto.FirstName; + entity.MiddleName = dto.MiddleName; + entity.LastName = dto.LastName; + entity.ContactType = dto.ContactType; + entity.PhoneNumber = dto.PhoneNumber; + entity.Email = dto.Email; + entity.Address1 = dto.Address1; + entity.Address2 = dto.Address2; + entity.City = dto.City; + entity.State = dto.State; + entity.Zip = dto.Zip; + entity.FacebookUrl = dto.FacebookUrl; + entity.LinkedInUrl = dto.LinkedInUrl; + entity.TwitterUrl = dto.TwitterUrl; + entity.LocationId = dto.LocationId; + entity.AccountId = dto.AccountId; + + return entity; + } + } + + // DTO for contact list view + public class ContactListItem_DTO + { + public int Id { get; set; } + public string? Name { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + + // ✅ Map from Service DTO + public static ContactListItem_DTO FromServiceDTO(ContactDTO serviceDto) + { + return new ContactListItem_DTO + { + Id = serviceDto.Id, + Name = (serviceDto.FirstName + " " + serviceDto.LastName).Trim(), + Email = serviceDto.Email, + Phone = serviceDto.Phone + }; + } + + // ✅ Map collection from Service DTOs + public static IEnumerable FromServiceDTOs(IEnumerable serviceDtos) + { + return serviceDtos.Select(FromServiceDTO); + } } } diff --git a/Api.SeaHavenIndustries/DTOs/EditContact_DTO.cs b/Api.SeaHavenIndustries/DTOs/EditContact_DTO.cs index 8ace267..b77f16e 100644 --- a/Api.SeaHavenIndustries/DTOs/EditContact_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/EditContact_DTO.cs @@ -1,4 +1,7 @@ -namespace Api.SeaHavenIndustries.DTOs +using Data.SeaHavenIndustries; +using SeaHaven.Services.DTOs; + +namespace Api.SeaHavenIndustries.DTOs { public class EditContact_DTO { @@ -21,5 +24,72 @@ public string? TwitterUrl { get; set; } public int? LocationId { get; set; } public int? AccountId { get; set; } + + // ✅ Map API DTO to Service DTO + public UpdateContactDTO ToServiceUpdateDTO() + { + return new UpdateContactDTO + { + FirstName = this.FirstName, + LastName = this.LastName, + Email = this.Email, + Phone = this.PhoneNumber, + Title = this.Title, + Type = this.ContactType, + AccountId = this.AccountId + }; + } + + // Map from Entity to DTO (Model) + public static EditContact_DTO MapToModel(Contacts entity) + { + return new EditContact_DTO + { + Id = entity.Id, + Title = entity.Title, + Owner = entity.Owner, + FirstName = entity.FirstName, + MiddleName = entity.MiddleName, + LastName = entity.LastName, + ContactType = entity.ContactType, + PhoneNumber = entity.PhoneNumber, + Email = entity.Email, + Address1 = entity.Address1, + Address2 = entity.Address2, + City = entity.City, + State = entity.State, + Zip = entity.Zip, + FacebookUrl = entity.FacebookUrl, + LinkedInUrl = entity.LinkedInUrl, + TwitterUrl = entity.TwitterUrl, + LocationId = entity.LocationId, + AccountId = entity.AccountId + }; + } + + // Map from DTO (Model) to Entity - updates existing entity + public static Contacts MapToEntity(EditContact_DTO dto, Contacts existingEntity) + { + existingEntity.Title = dto.Title; + existingEntity.Owner = dto.Owner; + existingEntity.FirstName = dto.FirstName; + existingEntity.MiddleName = dto.MiddleName; + existingEntity.LastName = dto.LastName; + existingEntity.ContactType = dto.ContactType; + existingEntity.PhoneNumber = dto.PhoneNumber; + existingEntity.Email = dto.Email; + existingEntity.Address1 = dto.Address1; + existingEntity.Address2 = dto.Address2; + existingEntity.City = dto.City; + existingEntity.State = dto.State; + existingEntity.Zip = dto.Zip; + existingEntity.FacebookUrl = dto.FacebookUrl; + existingEntity.LinkedInUrl = dto.LinkedInUrl; + existingEntity.TwitterUrl = dto.TwitterUrl; + existingEntity.LocationId = dto.LocationId; + existingEntity.AccountId = dto.AccountId; + + return existingEntity; + } } } diff --git a/Api.SeaHavenIndustries/DTOs/EditEvent_DTO.cs b/Api.SeaHavenIndustries/DTOs/EditEvent_DTO.cs new file mode 100644 index 0000000..0083de5 --- /dev/null +++ b/Api.SeaHavenIndustries/DTOs/EditEvent_DTO.cs @@ -0,0 +1,48 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Api.SeaHavenIndustries.DTOs +{ + /// + /// DTO for POST/PUT requests - Create/Edit Event + /// Used to create or update event information + /// + public class EditEvent_DTO + { + public int? Id { get; set; } // Null for create, populated for edit + + [Required(ErrorMessage = "Please enter a title")] + [StringLength(100, ErrorMessage = "Title cannot be longer than 100 characters")] + public string Title { get; set; } = null!; + + public string? Description { get; set; } + + [StringLength(150, ErrorMessage = "Location cannot be longer than 150 characters")] + public string? Location { get; set; } + + public int? RecordID { get; set; } + public string? RecordName { get; set; } + public string? RecordTargetType { get; set; } + + [Required(ErrorMessage = "The Remind Attendees field is required")] + public int ReminderMinutes { get; set; } = 15; + + [Required(ErrorMessage = "The Select a Calendar Color field is required")] + public string EventColor { get; set; } = "primary"; + + [Required(ErrorMessage = "Please select a start date")] + public DateTime StartDate { get; set; } + + [Required(ErrorMessage = "Please select a start time")] + public string StartTime { get; set; } = null!; + + [Required(ErrorMessage = "Please select an end date")] + public DateTime EndDate { get; set; } + + [Required(ErrorMessage = "Please select an end time")] + public string EndTime { get; set; } = null!; + + public bool AllDay { get; set; } = false; + } +} + diff --git a/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs b/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs index fc17b09..18d1be3 100644 --- a/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/EditLocation_DTO.cs @@ -1,3 +1,5 @@ +using SeaHaven.Services.DTOs; + namespace Api.SeaHavenIndustries.DTOs { public class EditLocation_DTO @@ -17,5 +19,19 @@ namespace Api.SeaHavenIndustries.DTOs public int? GetAccountId() => int.TryParse(AccountId, out var id) ? id : null; + + // ✅ Map API DTO to Service DTO + public UpdateLocationDTO ToServiceUpdateDTO() + { + return new UpdateLocationDTO + { + LocationName = this.Name, + Address = this.Address, + City = this.City, + State = this.State, + Zipcode = this.ZipCode, + AccountId = this.GetAccountId() + }; + } } } diff --git a/Api.SeaHavenIndustries/DTOs/Event_DTO.cs b/Api.SeaHavenIndustries/DTOs/Event_DTO.cs new file mode 100644 index 0000000..2543831 --- /dev/null +++ b/Api.SeaHavenIndustries/DTOs/Event_DTO.cs @@ -0,0 +1,33 @@ +using System; + +namespace Api.SeaHavenIndustries.DTOs +{ + /// + /// DTO for GET responses - Event data + /// Used to return event information to the client + /// + public class Event_DTO + { + public int Id { get; set; } + public string Title { get; set; } = null!; + public string? Description { get; set; } + public string? Location { get; set; } + + public int? RecordID { get; set; } + public string? RecordName { get; set; } + public string? RecordTargetType { get; set; } + + public int ReminderMinutes { get; set; } + public string EventColor { get; set; } = null!; + + public DateTime StartDate { get; set; } + public string? StartTime { get; set; } + public DateTime EndDate { get; set; } + public string? EndTime { get; set; } + public bool AllDay { get; set; } + + public DateTime? CreatedDate { get; set; } + public string? createdby { get; set; } + } +} + diff --git a/Api.SeaHavenIndustries/DTOs/Location_DTO.cs b/Api.SeaHavenIndustries/DTOs/Location_DTO.cs index e023062..dcd3c03 100644 --- a/Api.SeaHavenIndustries/DTOs/Location_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Location_DTO.cs @@ -1,3 +1,6 @@ +using Data.SeaHavenIndustries; +using SeaHaven.Services.DTOs; + namespace Api.SeaHavenIndustries.DTOs { public class Location_DTO @@ -16,5 +19,117 @@ namespace Api.SeaHavenIndustries.DTOs public int? GetAccountId() => int.TryParse(AccountId, out var id) ? id : null; + + // ✅ Map API DTO to Service DTO + public CreateLocationDTO ToServiceCreateDTO() + { + return new CreateLocationDTO + { + LocationName = this.Name ?? "", + Address = this.Address, + City = this.City, + State = this.State, + Zipcode = this.ZipCode, + AccountId = this.GetAccountId() + }; + } + + // Map from Entity to DTO + public static Location_DTO MapToModel(Locations entity) + { + return new Location_DTO + { + Title = entity.Title, + Name = entity.Name, + Address = entity.Address1, + City = entity.City, + State = entity.State, + ZipCode = entity.Zip, + Phone = entity.PhoneNumber, + Contact = null, // Not in database schema + ContactEmail = entity.Email, + Status = entity.Status, + AccountId = null // Not in database schema + }; + } + + // Map from DTO to Entity + public static Locations MapToEntity(Location_DTO dto, Locations? existingEntity = null) + { + var entity = existingEntity ?? new Locations(); + + entity.Title = dto.Title; + entity.Name = dto.Name; + entity.Address1 = dto.Address; + entity.City = dto.City; + entity.State = dto.State; + entity.Zip = dto.ZipCode; + entity.PhoneNumber = dto.Phone; + // Contact field doesn't exist in database schema - ignored + entity.Email = dto.ContactEmail; + entity.Status = dto.Status; + // AccountId field doesn't exist in database schema - ignored + + return entity; + } + } + + // DTO for list views + public class LocationListItem_DTO + { + public int Id { get; set; } + public string? Name { get; set; } + public string? Title { get; set; } + + // Map from Entity + public static LocationListItem_DTO MapToModel(Locations entity) + { + return new LocationListItem_DTO + { + Id = entity.Id, + Name = entity.Name, + Title = entity.Title + }; + } + + // ✅ Map from Service DTO (no logic in controller) + public static LocationListItem_DTO FromServiceDTO(LocationDTO serviceDto) + { + return new LocationListItem_DTO + { + Id = serviceDto.Id, + Name = serviceDto.Name ?? serviceDto.Title, + Title = serviceDto.Title ?? serviceDto.Name + }; + } + + // ✅ Map collection from Service DTOs + public static IEnumerable FromServiceDTOs(IEnumerable serviceDtos) + { + return serviceDtos.Select(FromServiceDTO); + } + } + + // DTO for location dropdown/simple view + public class LocationViewModel_DTO + { + public int? Id { get; set; } + public string? Name { get; set; } + + // ✅ Map from Service DTO + public static LocationViewModel_DTO FromServiceDTO(LocationDTO serviceDto) + { + return new LocationViewModel_DTO + { + Id = serviceDto.Id, + Name = serviceDto.Name ?? serviceDto.Title + }; + } + + // ✅ Map collection from Service DTOs + public static IEnumerable FromServiceDTOs(IEnumerable serviceDtos) + { + return serviceDtos.Select(FromServiceDTO); + } } } diff --git a/Api.SeaHavenIndustries/DTOs/Workorder_DTO.cs b/Api.SeaHavenIndustries/DTOs/Workorder_DTO.cs index 8b8f4e5..32d6532 100644 --- a/Api.SeaHavenIndustries/DTOs/Workorder_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Workorder_DTO.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; namespace Api.SeaHavenIndustries.DTOs { @@ -9,14 +10,11 @@ namespace Api.SeaHavenIndustries.DTOs public string? Description { get; set; } public string? PO { get; set; } public string? TT { get; set; } - //public int? ContactId { get; set; } public string? AssignTo { get; set; } public DateTime? DueDate { get; set; } - public string? Priority { get; set; } + public WorkOrderPriority? Priority { get; set; } public int? LocationId { get; set; } public bool? istemplate { get; set; } - //public int? CategoryId { get; set; } - public List? Attachments { get; set; } public string? BeforPhotoIssue { get; set; } public IFormFile? BeforPhotoAttachment { get; set; } @@ -25,15 +23,33 @@ namespace Api.SeaHavenIndustries.DTOs public string? SignOffName { get; set; } public IFormFile? SignOffAttachment { get; set; } public string? SignOffSignature { get; set; } - public string? WorkOrderContacts { get; set; } - public string? WorkOrderCategories { get; set; } - public List GetContactIds() + public List? ContactIds { get; set; } + public List? CategoryIds { get; set; } + + public static WorkOrder MapToEntity(Workorder_DTO dto, string userId) { - return WorkOrderContacts?.Split(',').Select(int.Parse).ToList() ?? new List(); - } - public List GetCategoryIds() - { - return WorkOrderCategories?.Split(',').Select(int.Parse).ToList() ?? new List(); + if (dto == null) return null!; + + return new WorkOrder + { + WorkerOrderNumber = dto.WorkerOrderNumber, + WorkerOrderTitle = dto.WorkerOrderTitle, + Description = dto.Description, + PO = dto.PO, + TT = dto.TT, + AssignTo = dto.AssignTo, + DueDate = dto.DueDate, + Priority = (dto.Priority ?? WorkOrderPriority.Medium).ToString(), + LocationId = dto.LocationId, + istemplate = dto.istemplate ?? false, + Status = WorkOrderStatus.Open.ToString(), + BeforPhotoIssue = dto.BeforPhotoIssue, + AfterPhotoIssue = dto.AfterPhotoIssue, + SignOffName = dto.SignOffName, + SignOffSignature = dto.SignOffSignature, + createdby = userId, + CreatedDate = DateTime.UtcNow + }; } } public class EditWorkorder_DTO @@ -46,8 +62,8 @@ namespace Api.SeaHavenIndustries.DTOs public string? TT { get; set; } public string? AssignTo { get; set; } public DateTime? DueDate { get; set; } - public string? Priority { get; set; } - public string? Status { get; set; } + public WorkOrderPriority? Priority { get; set; } + public WorkOrderStatus? Status { get; set; } public int? LocationId { get; set; } public string? Problem { get; set; } public string? Trade { get; set; } @@ -56,7 +72,6 @@ namespace Api.SeaHavenIndustries.DTOs public DateTime? ScheduledDate { get; set; } public DateTime? CompletedDate { get; set; } public string? Source { get; set; } - public List? Attachments { get; set; } public string? BeforPhotoIssue { get; set; } public IFormFile? BeforPhotoAttachment { get; set; } @@ -65,16 +80,36 @@ namespace Api.SeaHavenIndustries.DTOs public string? SignOffName { get; set; } public IFormFile? SignOffAttachment { get; set; } public string? SignOffSignature { get; set; } - public string? WorkOrderContacts { get; set; } - public string? WorkOrderCategories { get; set; } - public List GetContactIds() + public List? ContactIds { get; set; } + public List? CategoryIds { get; set; } + + public static void UpdateEntity(EditWorkorder_DTO dto, WorkOrder entity) { - return WorkOrderContacts?.Split(',').Select(int.Parse).ToList() ?? new List(); - } - public List GetCategoryIds() - { - return WorkOrderCategories?.Split(',').Select(int.Parse).ToList() ?? new List(); + if (dto == null || entity == null) return; + + if (dto.WorkerOrderNumber != null) entity.WorkerOrderNumber = dto.WorkerOrderNumber; + if (dto.WorkerOrderTitle != null) entity.WorkerOrderTitle = dto.WorkerOrderTitle; + if (dto.Description != null) entity.Description = dto.Description; + if (dto.PO != null) entity.PO = dto.PO; + if (dto.TT != null) entity.TT = dto.TT; + if (dto.AssignTo != null) entity.AssignTo = dto.AssignTo; + if (dto.DueDate.HasValue) entity.DueDate = dto.DueDate; + if (dto.Priority.HasValue) entity.Priority = dto.Priority.ToString()!; + if (dto.Status.HasValue) entity.Status = dto.Status.ToString()!; + if (dto.LocationId.HasValue) entity.LocationId = dto.LocationId; + if (dto.Problem != null) entity.Problem = dto.Problem; + if (dto.Trade != null) entity.Trade = dto.Trade; + if (dto.SubTrade != null) entity.SubTrade = dto.SubTrade; + if (dto.VendorNTE.HasValue) entity.VendorNTE = dto.VendorNTE; + if (dto.ScheduledDate.HasValue) entity.ScheduledDate = dto.ScheduledDate; + if (dto.CompletedDate.HasValue) entity.CompletedDate = dto.CompletedDate; + if (dto.Source != null) entity.Source = dto.Source; + if (dto.BeforPhotoIssue != null) entity.BeforPhotoIssue = dto.BeforPhotoIssue; + if (dto.AfterPhotoIssue != null) entity.AfterPhotoIssue = dto.AfterPhotoIssue; + if (dto.SignOffName != null) entity.SignOffName = dto.SignOffName; + if (dto.SignOffSignature != null) entity.SignOffSignature = dto.SignOffSignature; + + entity.LastModificationTime = DateTime.UtcNow; } } - } \ No newline at end of file diff --git a/Api.SeaHavenIndustries/Helper/SendMessage.cs b/Api.SeaHavenIndustries/Helper/SendMessage.cs index 23fb80d..fc5bf0d 100644 --- a/Api.SeaHavenIndustries/Helper/SendMessage.cs +++ b/Api.SeaHavenIndustries/Helper/SendMessage.cs @@ -27,19 +27,19 @@ namespace Api.SeaHavenIndustries.Helper //mail.Body = body; //mail.IsBodyHtml = true; //SmtpClient smtp = new SmtpClient("mail.codevoir.com"); - //NetworkCredential Credentials = new NetworkCredential("infoesquall@codevoir.com", "Abc123!@#"); + //NetworkCredential Credentials = new NetworkCredential("", ""); //smtp.UseDefaultCredentials = false; //smtp.Credentials = Credentials; //smtp.Port = 8889; //25 alternative port number is 8889 //smtp.EnableSsl = false; //smtp.Send(mail); - //var apiKey = Environment.GetEnvironmentVariable("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\r\n"); - //var apiKey1 = Environment.GetEnvironmentVariable("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"); + //var apiKey = Environment.GetEnvironmentVariable("\r\n"); + //var apiKey1 = Environment.GetEnvironmentVariable(""); // var apiKey2 = Environment.GetEnvironmentVariable("SENDGRID_API_KEY"); var apiKey = _configuration.GetValue("SendGrid:ApiKey"); - //var apiKey = "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"; + //var apiKey = ""; var client = new SendGridClient(apiKey); var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries"); //var subject = "Sending with SendGrid is Fun"; @@ -65,7 +65,7 @@ namespace Api.SeaHavenIndustries.Helper var apiKey = _configuration.GetValue("SendGrid:ApiKey"); - //var apiKey = "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"; + //var apiKey = ""; var client = new SendGridClient(apiKey); var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries"); //var subject = "Sending with SendGrid is Fun"; diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 82edb85..a826847 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -7,6 +7,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using System.Text; +using SeaHaven.DataServices.DependencyInjection; +using SeaHaven.Services.DependencyInjection; var builder = WebApplication.CreateBuilder(args); ConfigurationManager configuration = builder.Configuration; @@ -31,6 +33,7 @@ builder.Services.AddResponseCompression(opts => }); builder.Services.AddCors(option => option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(sp => @@ -41,6 +44,10 @@ builder.Services.AddSingleton(sp if (File.Exists(csvPath)) zipService.Load(csvPath); return zipService; }); + +builder.Services.AddDataServices(); +builder.Services.AddBusinessServices(); + builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; @@ -111,60 +118,60 @@ app.UseAuthorization(); app.MapControllers(); -if (app.Environment.IsDevelopment()) -{ - using var scope = app.Services.CreateScope(); - var userManager = scope.ServiceProvider.GetRequiredService>(); - var roleManager = scope.ServiceProvider.GetRequiredService>(); +//if (app.Environment.IsDevelopment()) +//{ +// using var scope = app.Services.CreateScope(); +// var userManager = scope.ServiceProvider.GetRequiredService>(); +// var roleManager = scope.ServiceProvider.GetRequiredService>(); - if (!await roleManager.RoleExistsAsync("Admin")) - await roleManager.CreateAsync(new IdentityRole("Admin")); - if (!await roleManager.RoleExistsAsync("Dispatcher")) - await roleManager.CreateAsync(new IdentityRole("Dispatcher")); - if (!await roleManager.RoleExistsAsync("Manager")) - await roleManager.CreateAsync(new IdentityRole("Manager")); +// if (!await roleManager.RoleExistsAsync("Admin")) +// await roleManager.CreateAsync(new IdentityRole("Admin")); +// if (!await roleManager.RoleExistsAsync("Dispatcher")) +// await roleManager.CreateAsync(new IdentityRole("Dispatcher")); +// if (!await roleManager.RoleExistsAsync("Manager")) +// await roleManager.CreateAsync(new IdentityRole("Manager")); - var adminEmail = "admin@seahavenind.com"; - if (await userManager.FindByNameAsync(adminEmail) == null) - { - var admin = new ApplicationUser - { - UserName = adminEmail, - Email = adminEmail, - FirstName = "Admin", - LastName = "User", - EmailConfirmed = true, - UniqueName = "Active", - CreatedDate = DateTime.Now - }; - var result = await userManager.CreateAsync(admin, "Admin@123"); - if (result.Succeeded) - await userManager.AddToRoleAsync(admin, "Admin"); - } +// var adminEmail = "admin@seahavenind.com"; +// if (await userManager.FindByNameAsync(adminEmail) == null) +// { +// var admin = new ApplicationUser +// { +// UserName = adminEmail, +// Email = adminEmail, +// FirstName = "Admin", +// LastName = "User", +// EmailConfirmed = true, +// UniqueName = "Active", +// CreatedDate = DateTime.Now +// }; +// var result = await userManager.CreateAsync(admin, "Admin@123"); +// if (result.Succeeded) +// await userManager.AddToRoleAsync(admin, "Admin"); +// } - var db = scope.ServiceProvider.GetRequiredService(); - if (!db.DropdownOptions.Any()) - { - var trades = new[] { "Plumbing", "Electrical", "HVAC", "Fire/Life Safety", "General", "Roofing", "Janitorial", "Painting", "Locksmith", "Carpentry" }; - for (int i = 0; i < trades.Length; i++) - db.DropdownOptions.Add(new DropdownOption { Category = "Trade", Value = trades[i], SortOrder = i }); +// var db = scope.ServiceProvider.GetRequiredService(); +// if (!db.DropdownOptions.Any()) +// { +// var trades = new[] { "Plumbing", "Electrical", "HVAC", "Fire/Life Safety", "General", "Roofing", "Janitorial", "Painting", "Locksmith", "Carpentry" }; +// for (int i = 0; i < trades.Length; i++) +// db.DropdownOptions.Add(new DropdownOption { Category = "Trade", Value = trades[i], SortOrder = i }); - var subTrades = new (string trade, string sub)[] { - ("Plumbing", "Drain Cleaning"), ("Plumbing", "Pipe Repair"), ("Plumbing", "Fixture Install"), ("Plumbing", "Backflow Testing"), - ("Electrical", "Lighting"), ("Electrical", "Panel/Breaker"), ("Electrical", "Wiring"), ("Electrical", "Generator"), - ("HVAC", "AC Repair"), ("HVAC", "Heating"), ("HVAC", "Ductwork"), ("HVAC", "Controls/BMS"), - ("Fire/Life Safety", "Sprinkler"), ("Fire/Life Safety", "Fire Alarm"), ("Fire/Life Safety", "Extinguisher"), ("Fire/Life Safety", "Fire Pump"), - ("General", "Doors"), ("General", "Flooring"), ("General", "Drywall"), ("General", "Concrete"), - }; - for (int i = 0; i < subTrades.Length; i++) - db.DropdownOptions.Add(new DropdownOption { Category = "SubTrade", Value = subTrades[i].sub, ParentValue = subTrades[i].trade, SortOrder = i }); +// var subTrades = new (string trade, string sub)[] { +// ("Plumbing", "Drain Cleaning"), ("Plumbing", "Pipe Repair"), ("Plumbing", "Fixture Install"), ("Plumbing", "Backflow Testing"), +// ("Electrical", "Lighting"), ("Electrical", "Panel/Breaker"), ("Electrical", "Wiring"), ("Electrical", "Generator"), +// ("HVAC", "AC Repair"), ("HVAC", "Heating"), ("HVAC", "Ductwork"), ("HVAC", "Controls/BMS"), +// ("Fire/Life Safety", "Sprinkler"), ("Fire/Life Safety", "Fire Alarm"), ("Fire/Life Safety", "Extinguisher"), ("Fire/Life Safety", "Fire Pump"), +// ("General", "Doors"), ("General", "Flooring"), ("General", "Drywall"), ("General", "Concrete"), +// }; +// for (int i = 0; i < subTrades.Length; i++) +// db.DropdownOptions.Add(new DropdownOption { Category = "SubTrade", Value = subTrades[i].sub, ParentValue = subTrades[i].trade, SortOrder = i }); - var problems = new[] { "Leak", "No Power", "Not Cooling", "Not Heating", "Clogged", "Broken/Damaged", "Alarm Fault", "Preventive Maintenance", "Inspection", "Installation", "Other" }; - for (int i = 0; i < problems.Length; i++) - db.DropdownOptions.Add(new DropdownOption { Category = "Problem", Value = problems[i], SortOrder = i }); +// var problems = new[] { "Leak", "No Power", "Not Cooling", "Not Heating", "Clogged", "Broken/Damaged", "Alarm Fault", "Preventive Maintenance", "Inspection", "Installation", "Other" }; +// for (int i = 0; i < problems.Length; i++) +// db.DropdownOptions.Add(new DropdownOption { Category = "Problem", Value = problems[i], SortOrder = i }); - await db.SaveChangesAsync(); - } -} +// await db.SaveChangesAsync(); +// } +//} app.Run(); diff --git a/Api.SeaHavenIndustries/appsettings.Development.json b/Api.SeaHavenIndustries/appsettings.Development.json index d5cb32d..93b5c5f 100644 --- a/Api.SeaHavenIndustries/appsettings.Development.json +++ b/Api.SeaHavenIndustries/appsettings.Development.json @@ -1,6 +1,11 @@ { "ConnectionStrings": { - "DefaultConnection": "Server=localhost,1433;Database=SeahavenIndustries;User Id=sa;Password=Local@Dev2026!;TrustServerCertificate=True;MultipleActiveResultSets=true" + //"DefaultConnection": "Server=DESKTOP-64LC14O\SQLEXPRESS;Database=SeahavenIndustries;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true", + + + // Provide the real value via environment variable ConnectionStrings__DefaultConnection or user-secrets. + // Do NOT commit credentials. This file is committed, so keep only the placeholder here. + "DefaultConnection": "${CONNECTION_STRING}" }, "Logging": { "LogLevel": { @@ -9,20 +14,14 @@ } }, "SendGrid": { - "ApiKey": "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" + // Provide the real value via environment variable SendGrid__ApiKey or user-secrets. + "ApiKey": "${SENDGRID_API_KEY}" }, + "AllowedHosts": "*", "JWT": { - "ValidAudience": "http://localhost:3000", - "ValidIssuer": "https://localhost:7195", - "Secret": "JWTAuthenticationHIGHsecuredPasswordVVVp1OH7Xzyr" - }, - "FrontendBaseUrl": "http://localhost:3000", - "VendorPortal": { - "TokenLifetimeDays": 365 - }, - "Approvals": { - "UpliftTier1MaxUsd": 2500, - "Tier1Roles": [ "Admin", "Dispatcher" ], - "Tier2Roles": [ "Admin", "Manager" ] + "ValidAudience": "http://localhost:4200", + "ValidIssuer": "http://localhost:7195", + // Provide the real value via environment variable JWT__Secret or user-secrets. + "Secret": "${JWT_SECRET}" } -} +} \ No newline at end of file diff --git a/Api.SeaHavenIndustries/appsettings.Production.json b/Api.SeaHavenIndustries/appsettings.Production.json new file mode 100644 index 0000000..651d68c --- /dev/null +++ b/Api.SeaHavenIndustries/appsettings.Production.json @@ -0,0 +1,24 @@ +{ + "ConnectionStrings": { + // Provide the real value via environment variable ConnectionStrings__DefaultConnection + // (e.g. Elastic Beanstalk environment property) or the instance secret store. Do NOT commit credentials. + "DefaultConnection": "${CONNECTION_STRING}" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "SendGrid": { + // Provide the real value via environment variable SendGrid__ApiKey or the instance secret store. + "ApiKey": "${SENDGRID_API_KEY}" + }, + "AllowedHosts": "*", + "JWT": { + "ValidAudience": "http://console.seahavenind.com", + "ValidIssuer": "http://console.seahavenind.com", + // Provide the real value via environment variable JWT__Secret or the instance secret store. + "Secret": "${JWT_SECRET}" + } +} diff --git a/Api.SeaHavenIndustries/appsettings.json b/Api.SeaHavenIndustries/appsettings.json index 2459448..2d5dc80 100644 --- a/Api.SeaHavenIndustries/appsettings.json +++ b/Api.SeaHavenIndustries/appsettings.json @@ -1,6 +1,11 @@ { "ConnectionStrings": { - "DefaultConnection": "" + //"DefaultConnection": "Server=DESKTOP-64LC14O\SQLEXPRESS;Database=SeahavenIndustries;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true", + + + // Provide the real value via environment variable ConnectionStrings__DefaultConnection, + // user-secrets, or appsettings.Development.json (which is gitignored). Do NOT commit credentials. + "DefaultConnection": "${CONNECTION_STRING}" }, "Logging": { "LogLevel": { @@ -9,17 +14,14 @@ } }, "SendGrid": { - "ApiKey": "" + // Provide the real value via environment variable SendGrid__ApiKey or user-secrets. + "ApiKey": "${SENDGRID_API_KEY}" }, "AllowedHosts": "*", "JWT": { "ValidAudience": "http://localhost:4200", "ValidIssuer": "http://localhost:7195", - "Secret": "" - }, - "Approvals": { - "UpliftTier1MaxUsd": 2500, - "Tier1Roles": [ "Admin", "Dispatcher" ], - "Tier2Roles": [ "Admin", "Manager" ] + // Provide the real value via environment variable JWT__Secret or user-secrets. + "Secret": "${JWT_SECRET}" } -} +} \ No newline at end of file diff --git a/Api.SeaHavenIndustries/aws-beanstalk-tools-defaults.json b/Api.SeaHavenIndustries/aws-beanstalk-tools-defaults.json index fab4ccf..9f79baf 100644 --- a/Api.SeaHavenIndustries/aws-beanstalk-tools-defaults.json +++ b/Api.SeaHavenIndustries/aws-beanstalk-tools-defaults.json @@ -1,13 +1,12 @@ - { - "region" : "us-east-1", - "configuration" : "Debug", - "framework" : "net8.0", - "self-contained" : true, - "application" : "SeaHavenIndustries", - "environment" : "SeaHavenIndustries-dev", - "enable-xray" : false, - "enhanced-health-type" : "basic", - "additional-options" : "", - "proxy-server" : "nginx" + "region": "us-east-1", + "configuration": "Debug", + "framework": "net8.0", + "self-contained": true, + "application": "SeaHavenIndustries", + "environment": "SeaHavenIndustries-dev", + "enable-xray": false, + "enhanced-health-type": "basic", + "additional-options": "", + "proxy-server": "nginx" } \ No newline at end of file diff --git a/Api.SeaHavenIndustries/db.txt b/Api.SeaHavenIndustries/db.txt index 20b8cca..db4cdcb 100644 --- a/Api.SeaHavenIndustries/db.txt +++ b/Api.SeaHavenIndustries/db.txt @@ -924,6 +924,206 @@ PRIMARY KEY CLUSTERED GO - +-- ================================================================== +-- DATABASE CHANGES APPLIED ON 2026-04-22 +-- Migration: Calendar Module + Missing Column Fixes +-- Applied by: AI Agent +-- ================================================================== + +-- ================================================================== +-- 1. EVENTS TABLE (Calendar Module) +-- Migration: 20260422021353_AddEventsTable +-- ================================================================== + +USE [db_a7141e_seahavenindustry] +GO + +IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[Events]') AND type in (N'U')) +BEGIN + CREATE TABLE [dbo].[Events]( + [Id] [int] IDENTITY(1,1) NOT NULL, + + -- Basic Event Information + [Title] [nvarchar](100) NOT NULL, + [Description] [nvarchar](max) NULL, + [Location] [nvarchar](150) NULL, + + -- Related Record (Link to Accounts, Contacts, Work Orders, etc.) + [RecordID] [int] NULL, + [RecordName] [nvarchar](max) NULL, + [RecordTargetType] [nvarchar](max) NULL, + + -- Reminder & Display Settings + [ReminderMinutes] [int] NOT NULL CONSTRAINT [DF_Events_ReminderMinutes] DEFAULT ((15)), + [EventColor] [nvarchar](20) NOT NULL CONSTRAINT [DF_Events_EventColor] DEFAULT (N'primary'), + + -- Schedule Information + [StartDate] [datetime2](7) NOT NULL, + [StartTime] [nvarchar](10) NULL, + [EndDate] [datetime2](7) NOT NULL, + [EndTime] [nvarchar](10) NULL, + [AllDay] [bit] NOT NULL CONSTRAINT [DF_Events_AllDay] DEFAULT ((0)), + + -- Audit Fields (from FullAuditEntity) + [IsDeleted] [bit] NOT NULL CONSTRAINT [DF_Events_IsDeleted] DEFAULT ((0)), + [DeletionTime] [datetime2](7) NULL, + [createdby] [nvarchar](max) NULL, + [CreatedDate] [datetime2](7) NULL, + [lastmodifiedby] [nvarchar](max) NULL, + [LastModificationTime] [datetime2](7) NULL, + + CONSTRAINT [PK_Events] PRIMARY KEY CLUSTERED ([Id] ASC) + WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) + ) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY] +END +GO + +-- Create Index on StartDate for faster calendar queries +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_Events_StartDate' AND object_id = OBJECT_ID('[dbo].[Events]')) +BEGIN + CREATE NONCLUSTERED INDEX [IX_Events_StartDate] ON [dbo].[Events] + ( + [StartDate] ASC + ) + WHERE ([IsDeleted] = 0) +END +GO + +-- Create Index on IsDeleted for soft delete queries +IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_Events_IsDeleted' AND object_id = OBJECT_ID('[dbo].[Events]')) +BEGIN + CREATE NONCLUSTERED INDEX [IX_Events_IsDeleted] ON [dbo].[Events] + ( + [IsDeleted] ASC + ) +END +GO + +-- ================================================================== +-- 2. WORKORDERS TABLE - MISSING COLUMNS +-- Migrations: 20260416212330, 20260416224015, 20260417142453 +-- ================================================================== + +-- Migration: 20260416224015_AddInternalWONumber +ALTER TABLE [dbo].[WorkOrders] +ADD [InternalWONumber] [nvarchar](max) NULL +GO + +-- Migration: 20260416212330_AddExternalSyncFields +ALTER TABLE [dbo].[WorkOrders] +ADD [ExternalWorkOrderId] [nvarchar](max) NULL, + [Source] [nvarchar](max) NULL +GO + +-- Migration: 20260417142453_AddDropdownOptionsAndWOFields +ALTER TABLE [dbo].[WorkOrders] +ADD [Problem] [nvarchar](max) NULL, + [Trade] [nvarchar](max) NULL, + [SubTrade] [nvarchar](max) NULL, + [VendorNTE] [decimal](18, 2) NULL, + [ScheduledDate] [datetime2](7) NULL, + [CompletedDate] [datetime2](7) NULL +GO + +-- ================================================================== +-- 3. COMMENTS TABLE - MISSING COLUMNS +-- Migrations: 20260416220656, 20260417171152 +-- ================================================================== + +-- Migration: 20260416220656_AddCommentTypeAndAuditLog +ALTER TABLE [dbo].[Comments] +ADD [CommentType] [nvarchar](max) NULL, + [Commenter] [nvarchar](max) NULL, + [RecordType] [nvarchar](max) NULL +GO + +-- Migration: 20260417171152_AddDispatchNumberAndCommentDispatchId +ALTER TABLE [dbo].[Comments] +ADD [DispatchId] [int] NULL +GO + +-- ================================================================== +-- 4. LOCATIONS TABLE - MISSING COLUMNS +-- Migration: 20260416210833_AddLocationFields +-- ================================================================== + +ALTER TABLE [dbo].[Locations] +ADD [Address1] [nvarchar](max) NULL, + [Address2] [nvarchar](max) NULL, + [City] [nvarchar](max) NULL, + [State] [nvarchar](max) NULL, + [Zip] [nvarchar](max) NULL, + [PhoneNumber] [nvarchar](max) NULL, + [Email] [nvarchar](max) NULL, + [Status] [nvarchar](max) NULL +GO + +-- ================================================================== +-- 5. MIGRATION HISTORY RECORDS +-- Marked these migrations as applied to sync with code +-- ================================================================== + +INSERT INTO [dbo].[__EFMigrationsHistory] ([MigrationId], [ProductVersion]) +VALUES +(N'20260416204109_SyncContactsAndModels', N'8.0.8'), +(N'20260416210833_AddLocationFields', N'8.0.8'), +(N'20260416212330_AddExternalSyncFields', N'8.0.8'), +(N'20260416220656_AddCommentTypeAndAuditLog', N'8.0.8'), +(N'20260416224015_AddInternalWONumber', N'8.0.8'), +(N'20260417142453_AddDropdownOptionsAndWOFields', N'8.0.8'), +(N'20260417153405_AddVendorAndDispatch', N'8.0.8'), +(N'20260417154641_AddDispatchScheduledDate', N'8.0.8'), +(N'20260417171152_AddDispatchNumberAndCommentDispatchId', N'8.0.8'), +(N'20260417182910_AddTaskListTemplates', N'8.0.8'), +(N'20260417190743_MultiWODispatch', N'8.0.8'), +(N'20260417192012_AddDispatchChecklist', N'8.0.8'), +(N'20260417194438_AddDispatchSignoffs', N'8.0.8'), +(N'20260417195316_AddDispatchVerification', N'8.0.8'), +(N'20260420151445_AddVendorAccessToken', N'8.0.8'), +(N'20260420164708_AddDispatchUpliftRequest', N'8.0.8'), +(N'20260422021353_AddEventsTable', N'8.0.8') +GO + +-- ================================================================== +-- SUMMARY OF CHANGES (2026-04-22) +-- ================================================================== +-- Tables Created (11): +-- 1. Events (Calendar/Events module) +-- 2. WorkOrderAuditLogs (Audit trail for work order changes) +-- 3. DropdownOptions (Dynamic dropdown values) +-- 4. Vendors (Vendor management) +-- 5. Dispatches (Vendor dispatch workflow) +-- 6. TaskListTemplates (Reusable task templates) +-- 7. TaskListTemplateItems (Template task items) +-- 8. DispatchWorkOrders (Many-to-many: Dispatch <-> WorkOrders) +-- 9. DispatchChecklistItems (Dispatch checklist) +-- 10. DispatchSignoffs (Customer & Vendor signatures) +-- 11. DispatchUpliftRequests (NTE increase requests) +-- +-- Columns Added to WorkOrders (9): +-- - InternalWONumber, ExternalWorkOrderId, Source +-- - Problem, Trade, SubTrade +-- - VendorNTE, ScheduledDate, CompletedDate +-- +-- Columns Added to Comments (4): +-- - CommentType, Commenter, RecordType, DispatchId +-- +-- Columns Added to Locations (8): +-- - Address1, Address2, City, State, Zip +-- - PhoneNumber, Email, Status +-- +-- Columns Added to Dispatches (7): +-- - ScheduledDate, DispatchNumber, CompletedDate +-- - VerifiedBy, VerifiedAt, AcceptToken, AcknowledgedAt +-- +-- Columns Added to Vendors (1): +-- - AccessToken +-- +-- Migrations Applied: 17 +-- From: 20260416204109_SyncContactsAndModels +-- To: 20260422021353_AddEventsTable +-- +-- Database Status: UP TO DATE ✓ +-- ================================================================== diff --git a/BACKEND_ARCHITECTURE.md b/BACKEND_ARCHITECTURE.md new file mode 100644 index 0000000..ee1d809 --- /dev/null +++ b/BACKEND_ARCHITECTURE.md @@ -0,0 +1,564 @@ +# Backend Architecture Guide + +## 🏗️ Clean Architecture Overview + +The backend follows **Clean Architecture** with strict layer separation: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ API LAYER (Controllers) │ +│ - HTTP Request/Response │ +│ - Authentication & Authorization │ +│ - Exception Handling │ +│ - Input Validation (basic) │ +└─────────────────┬───────────────────────────────────────────┘ + │ Calls + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ SERVICE LAYER (Business Logic) │ +│ - Business Rules │ +│ - FluentValidation (detailed validation) │ +│ - Data Transformation │ +│ - Orchestration │ +└─────────────────┬───────────────────────────────────────────┘ + │ Calls + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ DATA SERVICE LAYER (Persistence) │ +│ - Database Access │ +│ - Entity Operations (CRUD) │ +│ - Query Building │ +│ - Transaction Management │ +└─────────────────┬───────────────────────────────────────────┘ + │ Uses + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ DATA LAYER (EF Core) │ +│ - DbContext │ +│ - Entity Models │ +│ - Database Schema │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 📁 Project Structure + +``` +backend/shoc-backend/ +│ +├── Api.SeaHavenIndustries/ # 🌐 API Layer +│ ├── Controllers/ # HTTP endpoints +│ ├── DTOs/ # Data Transfer Objects (API contracts) +│ ├── Program.cs # App startup & DI registration +│ └── appsettings.json # Configuration +│ +├── SeaHaven.Services/ # 💼 Service Layer (Business Logic) +│ ├── Interfaces/ # Service contracts (IContactService, etc.) +│ ├── Implementation/ # Service implementations +│ ├── DTOs/ # Internal DTOs for service layer +│ └── Validators/ # FluentValidation validators +│ +├── SeaHaven.DataServices/ # 💾 Data Access Layer +│ ├── Interfaces/ # DataService contracts (IContactDataService, etc.) +│ ├── Implementation/ # DataService implementations +│ └── DTOs/ # Data layer DTOs +│ +└── Data.SeaHavenIndustries/ # 🗄️ Data Layer (EF Core) + ├── Models/ # Entity models (Contact, Employee, etc.) + ├── ApplicationDbContext.cs # EF Core DbContext + └── Migrations/ # Database migrations +``` + +--- + +## 🔄 Data Flow Example (Creating a Contact) + +### 1️⃣ **API Layer** (`ContactController.cs`) +```csharp +[HttpPost("Create")] +public async Task AddContacts(Contact_DTO dto) +{ + try + { + // Get authenticated user ID + var userId = _userManager.GetUserId(User); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); + + // Convert API DTO → Service DTO + var serviceDto = dto.ToServiceCreateDTO(); + + // Call Service Layer + await _contactService.CreateContactAsync(serviceDto, userId); + + return Ok(new Response { Status = "Success", Message = "Contact created" }); + } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } + catch (KeyNotFoundException) + { + return NotFound(new Response { Status = "Error", Message = "Resource not found" }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + } +} +``` + +**Responsibilities:** +- ✅ Authenticate user +- ✅ Extract user ID from claims +- ✅ Map API DTO to Service DTO +- ✅ Catch exceptions and return proper HTTP status codes +- ❌ NO business logic +- ❌ NO database access + +--- + +### 2️⃣ **Service Layer** (`ContactService.cs`) + +```csharp +public class ContactService : IContactService +{ + private readonly IContactDataService _dataService; + private readonly IValidator _createValidator; + + public ContactService( + IContactDataService dataService, + IValidator createValidator) + { + _dataService = dataService; + _createValidator = createValidator; + } + + public async Task CreateContactAsync(CreateContactDTO dto, string userId) + { + // STEP 1: Validate using FluentValidation + var validationResult = await _createValidator.ValidateAsync(dto); + if (!validationResult.IsValid) + { + throw new ValidationException(validationResult.Errors); + } + + // STEP 2: Business logic (if any) + // Example: Check for duplicates, apply business rules, etc. + + // STEP 3: Map Service DTO → Data DTO + var dataDto = new CreateContactDataDTO + { + FirstName = dto.FirstName, + LastName = dto.LastName, + Email = dto.Email, + Phone = dto.Phone + }; + + // STEP 4: Call Data Service Layer + var createdContact = await _dataService.CreateAsync(dataDto, userId); + + // STEP 5: Map Data DTO → Service DTO and return + return new ContactDTO + { + Id = createdContact.Id, + FirstName = createdContact.FirstName, + LastName = createdContact.LastName, + Email = createdContact.Email, + Phone = createdContact.Phone + }; + } +} +``` + +**Responsibilities:** +- ✅ Validate input using FluentValidation +- ✅ Apply business rules +- ✅ Orchestrate operations +- ✅ Transform data between layers +- ❌ NO HTTP concerns +- ❌ NO direct database access + +--- + +### 3️⃣ **Data Service Layer** (`ContactDataService.cs`) + +```csharp +public class ContactDataService : IContactDataService +{ + private readonly ApplicationDbContext _db; + + public ContactDataService(ApplicationDbContext db) + { + _db = db; + } + + public async Task CreateAsync(CreateContactDataDTO dto, string userId) + { + // STEP 1: Create entity from DTO + var contact = new Contact + { + FirstName = dto.FirstName, + LastName = dto.LastName, + Email = dto.Email, + Phone = dto.Phone, + CreatedBy = userId, + CreatedDate = DateTime.UtcNow, + ModifiedBy = userId, + ModifiedDate = DateTime.UtcNow + }; + + // STEP 2: Add to database + _db.Contacts.Add(contact); + await _db.SaveChangesAsync(); + + // STEP 3: Map entity → Data DTO and return + return new ContactDataDTO + { + Id = contact.Id, + FirstName = contact.FirstName, + LastName = contact.LastName, + Email = contact.Email, + Phone = contact.Phone + }; + } +} +``` + +**Responsibilities:** +- ✅ Direct database operations (CRUD) +- ✅ Entity mapping +- ✅ Set audit fields (CreatedBy, CreatedDate, etc.) +- ✅ Transaction management +- ❌ NO validation +- ❌ NO business logic + +--- + +## 🔌 Dependency Injection (DI) + +### Automatic Assembly Scanning + +Services are **automatically registered** by scanning assemblies using naming convention. + +**In `Program.cs`:** +```csharp +builder.Services.AddDataServices(); // Auto-register all Data Services +builder.Services.AddBusinessServices(); // Auto-register all Business Services + Validators +``` + +### How It Works + +**Module Files:** +- `SeaHaven.DataServices/DependencyInjection/DataServicesModule.cs` +- `SeaHaven.Services/DependencyInjection/ServicesModule.cs` + +**Process:** +1. Scans assembly for all classes +2. Finds interface matching pattern `I{ClassName}` +3. Registers as Scoped service +4. Also auto-registers FluentValidation validators + +### Naming Convention Required + +| Interface | Implementation | Registered? | +|-----------|---------------|-------------| +| `IContactService` | `ContactService` | ✅ YES | +| `IContactDataService` | `ContactDataService` | ✅ YES | +| `ISomething` | `SomethingDifferent` | ❌ NO | + +**Rule**: Interface must be `I` + Class name exactly + +### Usage in Code + +Once registered, inject via constructor: + +```csharp +public class ContactController : Controller +{ + private readonly IContactService _contactService; + + public ContactController(IContactService contactService) + { + _contactService = contactService; + } +} +``` + +### Benefits + +✅ No manual registration - just follow naming convention +✅ Auto-discovery - new services work automatically +✅ FluentValidation auto-registered + +--- + +## ✅ FluentValidation + +### Validator Example (`CreateContactDTOValidator.cs`) + +```csharp +public class CreateContactDTOValidator : AbstractValidator +{ + public CreateContactDTOValidator() + { + RuleFor(x => x.FirstName) + .NotEmpty().WithMessage("First name is required") + .MaximumLength(50).WithMessage("First name cannot exceed 50 characters"); + + RuleFor(x => x.LastName) + .NotEmpty().WithMessage("Last name is required") + .MaximumLength(50).WithMessage("Last name cannot exceed 50 characters"); + + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email is required") + .EmailAddress().WithMessage("Invalid email format") + .MaximumLength(100).WithMessage("Email cannot exceed 100 characters"); + + RuleFor(x => x.Phone) + .Matches(@"^\+?[1-9]\d{1,14}$").When(x => !string.IsNullOrEmpty(x.Phone)) + .WithMessage("Invalid phone number format"); + } +} +``` + +### Where Validation Happens + +``` +❌ Controller → NO detailed validation (only catches ValidationException) +✅ Service → YES - FluentValidation runs here +❌ DataService → NO validation +``` + +--- + +## 🎯 Interface Examples + +### Service Interface (`IContactService.cs`) +```csharp +public interface IContactService +{ + Task CreateContactAsync(CreateContactDTO dto, string userId); + Task UpdateContactAsync(int id, UpdateContactDTO dto, string userId); + Task DeleteContactAsync(int id, string userId); + Task GetContactByIdAsync(int id); + Task> GetContactsPagedAsync(int page, int pageSize, string? search); +} +``` + +### Data Service Interface (`IContactDataService.cs`) +```csharp +public interface IContactDataService +{ + Task CreateAsync(CreateContactDataDTO dto, string userId); + Task UpdateAsync(int id, UpdateContactDataDTO dto, string userId); + Task DeleteAsync(int id, string userId); + Task GetByIdAsync(int id); + Task> GetPagedAsync(int page, int pageSize, string? search); +} +``` + +--- + +## 📦 DTO Types + +### 1. **API DTOs** (`Api.SeaHavenIndustries/DTOs/`) +- Used for HTTP requests/responses +- Map to frontend JSON structure +- Example: `Contact_DTO`, `EditContact_DTO` + +### 2. **Service DTOs** (`SeaHaven.Services/DTOs/`) +- Used between Controller ↔ Service +- Business-focused structure +- Example: `CreateContactDTO`, `UpdateContactDTO`, `ContactDTO` + +### 3. **Data DTOs** (`SeaHaven.DataServices/DTOs/`) +- Used between Service ↔ DataService +- Database-focused structure +- Example: `CreateContactDataDTO`, `ContactDataDTO` + +### DTO Mapping Flow +``` +API DTO → Service DTO → Data DTO → Entity +(HTTP) (Business) (Data) (Database) +``` + +--- + +## 🚨 Exception Handling Pattern + +### Controller Exception Handling (STANDARD PATTERN) + +```csharp +[HttpPost("Create")] +public async Task Create([FromBody] SomeDTO dto) +{ + try + { + // Get user ID + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); + + // Call service + await _service.CreateAsync(dto, userId); + + return Ok(new Response { Status = "Success", Message = "Created successfully" }); + } + catch (ValidationException vex) // ⚠️ CATCH THIS FIRST + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } + catch (KeyNotFoundException) // ⚠️ THEN THIS + { + return NotFound(new Response { Status = "Error", Message = "Not found" }); + } + catch (Exception ex) // ⚠️ GENERIC LAST + { + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + } +} +``` + +### HTTP Status Code Mapping +- **200 OK** → Success +- **400 Bad Request** → Validation errors +- **401 Unauthorized** → Not authenticated +- **404 Not Found** → Resource not found +- **500 Internal Server Error** → Unexpected errors + +--- + +## 🔐 Authentication & User ID + +### Getting User ID (Two Methods) + +**Method 1: Using Claims (Preferred)** +```csharp +var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); +if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); +``` + +**Method 2: Using UserManager (for Identity)** +```csharp +var userId = _userManager.GetUserId(User); +if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); +``` + +### Audit Fields +Every entity should track: +- `CreatedBy` (string - User ID GUID) +- `CreatedDate` (DateTime) +- `ModifiedBy` (string - User ID GUID) +- `ModifiedDate` (DateTime) + +--- + +## 📋 Naming Conventions + +### Controllers +- Named after entity + "Controller" +- Example: `ContactController`, `EmployeeController` + +### Services +- Interface: `I[Entity]Service` +- Implementation: `[Entity]Service` +- Example: `IContactService`, `ContactService` + +### Data Services +- Interface: `I[Entity]DataService` +- Implementation: `[Entity]DataService` +- Example: `IContactDataService`, `ContactDataService` + +### DTOs +- API Layer: `[Entity]_DTO`, `Edit[Entity]_DTO` +- Service Layer: `Create[Entity]DTO`, `Update[Entity]DTO`, `[Entity]DTO` +- Data Layer: `Create[Entity]DataDTO`, `[Entity]DataDTO` + +--- + +## ✅ Best Practices Checklist + +### Controllers +- [ ] Use dependency injection for services +- [ ] Get user ID from `User.FindFirstValue(ClaimTypes.NameIdentifier)` +- [ ] Return 401 if userId is null +- [ ] Catch `ValidationException` BEFORE generic `Exception` +- [ ] Return appropriate HTTP status codes +- [ ] NO business logic in controllers +- [ ] NO direct database access (`_db`) + +### Services +- [ ] Validate using FluentValidation +- [ ] Throw `ValidationException` for validation errors +- [ ] Throw `KeyNotFoundException` for not found +- [ ] Pass userId to DataService +- [ ] Transform DTOs between layers +- [ ] Implement business rules here + +### Data Services +- [ ] Direct database access only +- [ ] Set audit fields (CreatedBy, CreatedDate, ModifiedBy, ModifiedDate) +- [ ] Use async/await for all database operations +- [ ] NO validation logic +- [ ] NO business logic + +--- + +## 🎓 Quick Reference + +| Layer | Purpose | Can Access | Cannot Access | +|-------|---------|-----------|---------------| +| **Controller** | HTTP handling, auth | Service layer | Database directly | +| **Service** | Business logic, validation | DataService layer | Database directly | +| **DataService** | Database operations | DbContext, Entities | HTTP context | +| **Data** | Entity definitions | Nothing (POCOs) | Application logic | + +--- + +## 📚 Example: Full CRUD Implementation + +See these files for complete examples: +- **Contact**: `ContactController.cs`, `ContactService.cs`, `ContactDataService.cs` +- **Employee**: `EmployeeController.cs`, `EmployeeService.cs`, `EmployeeDataService.cs` +- **Asset**: `AssetController.cs`, `AssetService.cs`, `AssetDataService.cs` + +All follow the same Clean Architecture pattern! + +--- + +## 🔐 Configuration & Secrets + +No secrets are stored in source. The committed `appsettings*.json` files hold only +`${PLACEHOLDER}` tokens; real values must be supplied at runtime through one of the +standard .NET configuration sources (later sources win): + +1. `appsettings.json` / `appsettings.{Environment}.json` — committed, placeholders only. +2. **User Secrets** (local dev): `dotnet user-secrets set "Section:Key" "value"`. +3. **Environment variables** — use `__` (double underscore) as the section separator. + In AWS Elastic Beanstalk these are the environment properties. + +See `.env.example` in the repo root for the full list. Required values: + +| Setting | Env-var form | Used by | Notes | +|---|---|---|---| +| `ConnectionStrings:DefaultConnection` | `ConnectionStrings__DefaultConnection` | Both projects | SQL Server connection string | +| `SendGrid:ApiKey` | `SendGrid__ApiKey` | Both projects | Transactional email | +| `JWT:Secret` | `JWT__Secret` | `Api.SeaHavenIndustries` | JWT signing key | +| `GoogleMaps:ApiKey` | `GoogleMaps__ApiKey` | `SeaHavenIndustries` (Blazor) | Maps/Places; read in `App.razor` + `Home.razor` | + +### AWS credentials + +`UploadFileHp` (S3 uploads) uses the **AWS SDK default credential chain** — it no longer +hardcodes access keys. Credentials resolve from `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` +env vars, the shared profile/SSO, or (preferred in production) the EC2/ECS instance role. +The region comes from `AddDefaultAWSOptions` in `Program.cs`. + +> ⚠️ The secrets previously committed to this repo (DB password, SendGrid key, JWT secret, +> Google Maps keys, AWS access key) should be considered compromised and **rotated**. + diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index d66f708..ea60e8e 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -66,6 +66,7 @@ namespace Data.SeaHavenIndustries public DbSet FollowUps { get; set; } public DbSet PMSchedules { get; set; } public DbSet ContactDetails { get; set; } + public DbSet Events { get; set; } // Employee related DbSets public DbSet EmployeeAddresses { get; set; } diff --git a/Data.SeaHavenIndustries/Data.SeaHavenIndustries.csproj b/Data.SeaHavenIndustries/Data.SeaHavenIndustries.csproj index 183423b..4ee8133 100644 --- a/Data.SeaHavenIndustries/Data.SeaHavenIndustries.csproj +++ b/Data.SeaHavenIndustries/Data.SeaHavenIndustries.csproj @@ -9,7 +9,7 @@ - + diff --git a/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs b/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs new file mode 100644 index 0000000..9c2e454 --- /dev/null +++ b/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs @@ -0,0 +1,92 @@ +namespace Data.SeaHavenIndustries.Enums +{ + public enum WorkOrderStatus + { + Open = 1, + InProgress = 2, + Completed = 3, + Cancelled = 4, + OnHold = 5 + } + + public enum WorkOrderPriority + { + Low = 1, + Medium = 2, + High = 3, + Critical = 4 + } + + public enum AuditAction + { + Create = 1, + Edit = 2, + Delete = 3, + StatusChange = 4, + AssignmentChange = 5, + Dispatch = 6, + DispatchUpdate = 7, + DispatchVerify = 8 + } + + public enum ContactType + { + Primary = 1, + Secondary = 2, + Billing = 3, + Technical = 4 + } + + public enum AssetStatus + { + Active = 1, + Inactive = 2, + UnderMaintenance = 3, + Retired = 4 + } + + public enum DispatchStatus + { + Pending = 1, + Assigned = 2, + InProgress = 3, + Completed = 4, + Cancelled = 5 + } + + public enum FollowUpStatus + { + Open = 1, + InProgress = 2, + Resolved = 3, + Closed = 4 + } + + public enum PMScheduleFrequency + { + Daily = 1, + Weekly = 2, + Monthly = 3, + Quarterly = 4, + SemiAnnual = 5, + Annual = 6 + } + + public enum CommentType + { + General = 1, + Internal = 2, + Customer = 3, + Vendor = 4, + System = 5 + } + + public enum RecordType + { + WorkOrder = 1, + Dispatch = 2, + Quote = 3, + Contact = 4, + Asset = 5 + } +} diff --git a/Data.SeaHavenIndustries/Models/Employee.cs b/Data.SeaHavenIndustries/Models/Employee.cs index 5d159be..ed8f753 100644 --- a/Data.SeaHavenIndustries/Models/Employee.cs +++ b/Data.SeaHavenIndustries/Models/Employee.cs @@ -86,8 +86,9 @@ namespace Data.SeaHavenIndustries public Employee Employee { get; set; } = null!; } - public class EmployeeEmail : FullAuditEntity + public class EmployeeEmail { + public int Id { get; set; } public int EmployeeId { get; set; } public Employee? Employee { get; set; } @@ -99,22 +100,28 @@ namespace Data.SeaHavenIndustries public bool? IsPrimary { get; set; } } - public class Department : FullAuditEntity + public class Department { + public int Id { get; set; } + [MaxLength(200)] public string Name { get; set; } = null!; public ICollection? Employees { get; set; } } - public class JobTitle : FullAuditEntity + public class JobTitle { + public int Id { get; set; } + [MaxLength(200)] public string Name { get; set; } = null!; public ICollection? Employees { get; set; } } - public class Region : FullAuditEntity + public class Region { + public int Id { get; set; } + [MaxLength(200)] public string Name { get; set; } = null!; public ICollection? Employees { get; set; } diff --git a/Data.SeaHavenIndustries/Models/Events.cs b/Data.SeaHavenIndustries/Models/Events.cs new file mode 100644 index 0000000..97ed818 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/Events.cs @@ -0,0 +1,49 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Data.SeaHavenIndustries +{ + /// + /// Events entity for Calendar/Events module + /// Tracks calendar events, appointments, meetings, etc. + /// + public class Events : FullAuditEntity + { + [Required] + [StringLength(100)] + public string Title { get; set; } = null!; + + public string? Description { get; set; } + + [StringLength(150)] + public string? Location { get; set; } + + // Related Record (can link to Accounts, Contacts, Work Orders, etc.) + public int? RecordID { get; set; } + public string? RecordName { get; set; } + public string? RecordTargetType { get; set; } + + // Reminder Settings + public int ReminderMinutes { get; set; } = 15; // Default: 15 minutes before + + // Color for calendar display + [StringLength(20)] + public string EventColor { get; set; } = "primary"; // primary, success, info, warning, danger + + // Schedule + [Required] + public DateTime StartDate { get; set; } + + [StringLength(10)] + public string? StartTime { get; set; } // e.g., "08:00 AM" + + [Required] + public DateTime EndDate { get; set; } + + [StringLength(10)] + public string? EndTime { get; set; } // e.g., "08:30 AM" + + public bool AllDay { get; set; } = false; + } +} + diff --git a/Data.SeaHavenIndustries/Models/Locations.cs b/Data.SeaHavenIndustries/Models/Locations.cs index f32b1de..9d5fdf3 100644 --- a/Data.SeaHavenIndustries/Models/Locations.cs +++ b/Data.SeaHavenIndustries/Models/Locations.cs @@ -6,21 +6,25 @@ namespace Data.SeaHavenIndustries { public class Locations : FullAuditEntity { + public int Id { get; set; } public string? Title { get; set; } public string? Name { get; set; } - public string? Address { get; set; } - public string? City { get; set; } - public string? State { get; set; } - public string? ZipCode { get; set; } - public string? Phone { get; set; } - public string? Contact { get; set; } - public string? ContactEmail { get; set; } - public string? Status { get; set; } public string? Latitude { get; set; } public string? Longitude { get; set; } - public int? AccountId { get; set; } - [ForeignKey("AccountId")] - public Accounts? Account { get; set; } + + // Address fields - match actual database columns + public string? Address1 { get; set; } + public string? Address2 { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + + // Contact fields - match actual database columns + public string? PhoneNumber { get; set; } + public string? Email { get; set; } + public string? Status { get; set; } + + // Navigation Properties public ICollection