Compare commits

..

No commits in common. "d6175f84f3e92a36c1f239eb6dad554931dfeb2d" and "7ad24a5dfebd137795044867ec79e9327a7f6485" have entirely different histories.

306 changed files with 9589 additions and 28601 deletions

View file

@ -1,29 +0,0 @@
name: Architecture and changed-file quality
on:
pull_request:
permissions:
contents: read
jobs:
architecture:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
# CI and local run the same complete repository gate.
- name: Repository quality gate
shell: bash
env:
BASE_REF: ${{ github.event.pull_request.base.sha }}
HEAD_REF: ${{ github.event.pull_request.head.sha }}
run: bash scripts/governance-check.sh

View file

@ -1,70 +0,0 @@
# AGENTS.md — SeaHaven backend (repo-specific delta)
This file is the **repo-specific delta** for the Seahaven backend. It layers on
top of the operator/workspace agent baseline and does not repeat it. This file
may add stricter backend rules but may never weaken the workspace baseline.
## Canonical governance documents (precedence)
1. `ARCHITECTURE_AND_CODE_QUALITY.md` — *what* the rules mean (canonical;
supersedes the older `BACKEND_ARCHITECTURE.md`, which is retained only as
historical reference).
2. `QUALITY_GATES.md` — *how* rules are enforced (commands + CI mapping).
3. `REVIEW_AND_PR_FRAMEWORK.md` — *who* reviews, in what order, with what
evidence.
When these conflict with each other, the one that *owns* the topic wins
(meaning → architecture doc; execution → quality gates; process → review
framework). When unsure, ask; do not silently pick.
## Architecture in one line
```text
Controller -> I{Feature}Service -> I{Feature}DataService -> ApplicationDbContext
```
- Controllers depend on `I{Feature}Service` only — never `DbContext`/EF/concrete
services/data services.
- Business services depend on feature-specific data/query/command interfaces —
never `DbContext`, never `IConfiguration`, never a generic repository.
- Data services own EF; EF/`DbContext` lives only on the documented
infrastructure allowlist (data services, `ApplicationDbContext`, migrations,
Identity/composition, transactions inside data services).
- One commit convention: `SaveChangesAsync(CancellationToken)` inside the owning
data service is the atomic boundary; explicit transactions stay inside data
services.
- Tenant scope is server-derived; authorization is enforced at service entry.
- No HTTP response leaks exception/stack/SQL/credential/crypto detail; secrets
are never logged.
Full detail: `ARCHITECTURE_AND_CODE_QUALITY.md`.
## Running the complete gate (local == CI)
```bash
bash scripts/governance-check.sh
```
The command restores, verifies architecture and changed-file formatting, builds
Release, and runs the full test suite. CI (`architecture-quality` workflow)
calls the same script. See `QUALITY_GATES.md`.
## Hard rules (deviation needs an ADR; no wildcard suppressions)
- EF allowlist, dependency direction, no client exception disclosure,
server-derived tenant scope, one commit convention, cancellation forwarding
(verified, not just signed).
- Suppress a single diagnostic with a cited ADR only — never global sweeps.
- See `ARCHITECTURE_AND_CODE_QUALITY.md` §10 for the ADR exception process.
## Do not touch without explicit instruction
- Product behavior, database **migrations**, secrets, dependency versions, and
files unrelated to the current task.
- The `.ai-config-kit-sidecar-write-scope` marker file is a transient
authorization artifact — **never commit it**.
## Reporting
State every changed file with a one-line summary. Report each gate as
pass / fail / skipped / not-run with evidence; never infer a pass from silence.

View file

@ -1,273 +0,0 @@
# Architecture and Code Quality (canonical)
> **Status: canonical.** This document owns the *meaning* of the backend's
> architecture and code-quality rules. On any conflict with the older
> `BACKEND_ARCHITECTURE.md`, **this document governs**; that file is retained
> only as historical reference pending removal.
>
> Companion documents:
> - `QUALITY_GATES.md` — *how* each rule is enforced (commands + CI mapping).
> - `REVIEW_AND_PR_FRAMEWORK.md` — *who* reviews, in what order, with what
> evidence, and how exceptions are recorded.
> - `AGENTS.md` — precedence and repo-specific agent delta.
## 1. Layering model
The backend is a feature-oriented service boundary over Entity Framework Core:
```text
HTTP controller -> I{Feature}Service -> I{Feature}DataService -> ApplicationDbContext
```
The interfaces are **architectural seams**, not a mandate to wrap every class or
every EF method. Add an interface when it separates HTTP, business,
persistence, or external I/O responsibility *and* enables behavior-focused
testing through the public contract.
| Layer | May depend on | May not depend on |
|-------|---------------|-------------------|
| API controller | `I{Feature}Service`, `ILogger`, framework types | `DbContext`, concrete services, concrete data services, raw SQL, business workflow code |
| Business service (`SeaHaven.Services`) | `I{Feature}DataService`, `IOptions<T>`, `ILogger`, pure ports (`IServicePorts`, `IDispatchEmailPort`, …) | `DbContext`, `IConfiguration`, concrete data services, a generic repository |
| Data service (`SeaHaven.DataServices`) | `ApplicationDbContext`, EF Core | Business decisions, HTTP concerns |
| Composition / infrastructure | `Program.cs`, `*Module.cs`, migrations, Identity | Being depended on by controllers or business services |
### 1.1 Controllers
- Parse HTTP input, authorize the caller, invoke a business-service interface,
and map the result to the established HTTP contract.
- Depend **only on `I{Feature}Service` interfaces** — never on `DbContext`, EF,
concrete service implementations, or concrete data-service implementations.
- Contain no persistence queries and no business workflows.
- Map known failures explicitly; let the centralized exception boundary handle
unexpected failures (see §5).
### 1.2 Business services
- Own validation, business decisions, orchestration, and **transaction intent**
(the *what* of semantic atomicity — see §4).
- Depend on **feature-specific data/query/command interfaces**, never on a
generic repository and never on `DbContext`.
- Use `IOptions<T>` for typed configuration. Never inject `IConfiguration`.
- Keep feature responsibilities cohesive. Split a service when it has
independently changing business reasons — **not** because it crossed an
arbitrary line count (see §9, "no godfile threshold theater").
### 1.3 Data services
- Own EF Core query shape, persistence, batching, and **transaction mechanics**
(the *how* of commits — see §4).
- Accept `CancellationToken` on new I/O methods and propagate it (see §6).
- Use `AsNoTracking()` for read-only queries.
- Project only required columns for list/read models; include full graphs only
when the caller needs them.
- Page unbounded collections at the database.
- Avoid query-in-loop / save-in-loop patterns; prefer set-based reads and
batched writes **while preserving the workflow's failure and transaction
semantics**.
- Do **not** introduce a generic repository over EF Core. Feature data services
expose intent-revealing operations.
### 1.4 Infrastructure ownership and the EF allowlist
EF Core / `DbContext` is owned by approved infrastructure only. The explicit
allowlist of places that may touch EF directly:
- `SeaHaven.DataServices.Implementation.*` (feature data services).
- `Data.SeaHavenIndustries` — the `ApplicationDbContext`, entity configuration,
and **migrations** (see §7).
- `SeaHaven.DataServices.DependencyInjection.DataServicesModule` — context
registration and lifetime.
- Identity / authentication composition in `Program.cs` and the DI modules
(`AddDbContext`, `AddIdentity`, token/options wiring).
- Transaction mechanics inside data services when semantic atomicity requires
it (see §4).
Anything outside this allowlist that references EF / `DbContext` is an
architecture violation. New allowlist entries require an ADR (see §10).
## 2. Tenant scope and authorization
- **Tenant scope is server-derived.** Filtering by tenant/customer/owner is
applied in the data access layer from authenticated principal claims, never
from client-supplied request bodies or query strings.
- **Authorization remains at service entry.** A business service's public method
is the trust boundary: it authorizes the caller and resolves the effective
tenant scope before doing work. Controllers express HTTP-level authorization
(`[Authorize]`, route/policy) and then delegate; they do not re-implement
service-level authorization.
- Data services receive an already-resolved, server-derived scope; they must not
invent authorization rules of their own.
## 3. Dependency-direction enforcement (mechanical)
`ArchitectureTests` (in `Api.SeaHavenIndustries.Tests`) enforces mechanical
dependency direction by reflection over constructor parameters:
1. Controllers do not inject `DbContext`, concrete data services, or concrete
service implementations.
2. Business services do not inject `DbContext` or `IConfiguration`.
3. Business services depend on interfaces for their data/persistence
dependencies, not on concrete data-service or service implementations.
4. **Discovery must not silently pass on an empty set.** Each invariant asserts
the discovered type set is non-empty, so the gate **fails** if reflection
ever returns zero controllers/services (e.g., after a namespace move). A
passing run therefore means "real types were checked," not "nothing was
found."
These tests cover *dependency direction*. Behavior lives in behavior tests
through public interfaces. Do **not** add tests merely to prove a linter or
analyzer itself works — test the product behavior the rule protects. Do not
claim full runtime properties from constructor reflection; it proves wiring
shape, not execution behavior.
## 4. Transaction semantics and the one commit convention
- **Services own semantic atomicity.** A business service decides which set of
operations constitutes a unit of work and expresses that intent.
- **Scoped infrastructure owns transaction mechanics.** The actual commit
boundary is `DbContext.SaveChangesAsync(...)` and lives **inside a data
service**. A service must not sprinkle independent `SaveChanges` calls across
data-service boundaries in a way that breaks the semantic unit.
- **The repo documents one commit convention:** a single
`SaveChangesAsync(CancellationToken)` inside the owning data service is the
atomic commit boundary. This is the current convention observed across the
data-service layer.
- When a unit of work genuinely requires multiple statements to be all-or-
nothing, use EF Core's transaction (`IDbContextTransaction`) **inside the data
service**. Explicit transactions are infrastructure (EF allowlist), owned by
data services, never by controllers or business services.
- Do not "optimize" atomicity away: collapsing several semantically independent
saves into one final save changes partial-success behavior and is a business
decision, not mechanical cleanup.
## 5. Error disclosure and structured logging (security)
- **No HTTP response may expose** arbitrary exception messages, stack traces,
or SQL/provider/path/credential/crypto detail. Known failures are mapped to
explicit public messages; unexpected failures are funneled through the
centralized boundary (`SanitizedErrors`), which returns a stable message plus
a correlation reference and never echoes internal exception text.
- **Correlated structured internal logs** record the original exception at
`Error` level with a correlation id, so incidents are traceable without
leaking detail to the client.
- **Secrets are never logged.** Configuration values, tokens, connection
strings, and credentials are treated as redacted; structured log templates
must not interpolate secret-bearing fields.
- The `SanitizedErrorsTests` guard the public-facing contract (stable message,
correlation id present, original exception text absent, original logged at
`Error`).
## 6. Cancellation
- New async I/O methods accept a `CancellationToken` where the public contract
permits and **propagate it** to EF Core / downstream I/O.
- **Do not falsely claim propagation.** A `CancellationToken` parameter that is
ignored, swallowed, or replaced with `default`/`CancellationToken.None` at the
call site is a defect, not a courtesy. The signature is a contract.
- Enforcement combines **behavior tests** (assert the token reaches the data
service / EF call for changed I/O paths) and **analyzers** where available.
Either is acceptable evidence; silence is not. For changed paths, review must
confirm forwarding, not merely the presence of the parameter.
## 7. Database migrations
Migration gates require **actual-provider** evidence, not reflection or
assumptions:
- **Actual-provider discovery** — the migration provider is the configured
database provider (e.g., SQL Server / Npgsql), discovered from the runtime
configuration, not inferred from package references.
- **Snapshot consistency** — `ModelSnapshot` must be consistent with the model
after the migration set (`dotnet ef ... --output` / script diff with no
phantom operations).
- **Empty-db apply** — applying the full migration history to an empty database
must reproduce the current model with no errors.
- **Supported-prior upgrade** — upgrading from each still-supported prior state
applies cleanly.
- **No untested rollback claims.** Do not assert "down/rollback is supported"
unless a downgrade is actually exercised against the real provider; otherwise
state rollback as **not guaranteed**. EF "EnsureDeleted"/re-create is not a
migration rollback.
## 8. Query performance and complexity
For each changed hot path, document and test:
- input-size variables;
- CPU and memory complexity (Big-O) for any in-memory reconstruction;
- database round trips;
- whether filtering, ordering, and paging execute **in SQL** vs. in memory;
- whether tracking is necessary;
- whether repeated work can be hoisted out of loops;
- the partial-failure and transaction behavior.
Distinguish two classes of finding:
- **Analyzable anti-patterns** — query-in-loop / save-in-loop, unbounded load
followed by in-memory `Where`, repeated `First`/`Single` scans over a result
(prefer dictionary/hash-set reconstruction, normally `O(n)` vs `O(n²)`). These
can be flagged by inspection.
- **SQL-backed evidence** — actual paging/filtering/sort location, query count,
and plan shape must be backed by provider query logs, `ToQueryString()`,
store-generated SQL, or a plan/count measurement. Do **not** assert "executes
in SQL" or "single round trip" from LINQ shape alone.
Do not optimize by weakening correctness: batching all writes into one final
save can change partial-success behavior; adding a transaction can change
locking/retry semantics. Both are business decisions.
## 9. Size, complexity, and "no godfile theater"
- We do **not** enforce arbitrary line-count or file-size thresholds as quality
gates. A number is not an architecture.
- A file is too large when it has **independently changing responsibilities**
(low cohesion) or **untestable coupling**, not when it crosses a count.
Split on seams, not on a counter; describe the seam in the PR.
- Gates that operate on size must name the *consequence* (e.g., "two unrelated
features in one service") and be backed by review, not by a magic threshold.
## 10. Exceptions and ADRs
Some rules here are hard (EF allowlist, no client exception leakage, server-
derived tenant scope). A deviation from a hard rule requires an **Architecture
Decision Record** under `docs/adr/` (create the path if absent):
- A short `NNNN-title.md` stating context, decision, consequences, and the
specific rule being excepted.
- The excepted rule and an expiry/review date.
- Reference from the PR that relies on it.
Without an ADR, the rule stands as written. Explicit, documented exceptions
only — **no wildcard suppressions** (no global `[SuppressMessage]`, no
`.editorconfig` `dotnet_diagnostic.*.severity = none` sweeps, no
`#pragma` swaths). A suppression must name the single diagnostic and cite the
ADR/justification.
## 11. Configuration
Configuration is bound once during composition in
`SeaHaven.Services/DependencyInjection/ServicesModule.cs`. Business services
receive typed options (`FrontendOptions`, `JwtOptions`, `ApprovalsOptions`,
`VendorPortalOptions`, …). Defaults must preserve current behavior. Startup
validation is introduced only when every deployed environment is known to
provide the required value.
## 12. Review checklist (quick reference)
- Controller depends only on `I{Feature}Service` abstractions.
- Business logic is in a cohesive feature service.
- EF operations are behind a feature data-service abstraction.
- No raw `IConfiguration` in a business service.
- No unbounded load + in-memory filtering.
- No query/save loop where a set-based operation preserves semantics.
- Read-only EF queries use no tracking.
- New async I/O accepts and **propagates** cancellation where the contract
permits (verified, not just signed).
- Errors do not leak internal exception details; secrets are not logged.
- Tenant scope is server-derived; authorization enforced at service entry.
- Commit convention respected (single `SaveChangesAsync` in the owning data
service; transactions only in data services when required).
- Tests cover behavior, authorization, data contracts, ordering, duplicates,
missing records, and relevant failure boundaries.
- Changed behavior checked against the authoritative ticket board (see
`REVIEW_AND_PR_FRAMEWORK.md`). Missing board access blocks readiness/merge.

View file

@ -1,97 +0,0 @@
using System.Reflection;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class ArchitectureTests
{
private static readonly Assembly ApiAssembly = typeof(VendorPortalController).Assembly;
private static readonly Assembly ServicesAssembly = typeof(VendorService).Assembly;
private static readonly Assembly DataServicesAssembly = typeof(VendorDataService).Assembly;
private const string ServiceImplementationNamespace = "SeaHaven.Services.Implementation";
private const string DataServiceImplementationNamespace = "SeaHaven.DataServices.Implementation";
private static IEnumerable<Type> ApiControllers =>
ApiAssembly.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract && typeof(ControllerBase).IsAssignableFrom(t));
private static IEnumerable<Type> BusinessServiceImplementations =>
ServicesAssembly.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract && !t.IsGenericType)
.Where(t => t.GetInterfaces().Any(i => i.Name == "I" + t.Name));
private static bool IsConcrete(Type type) => type.IsClass && !type.IsAbstract && !type.IsInterface;
private static bool IsDbContext(Type type) => typeof(DbContext).IsAssignableFrom(type);
private static bool IsConcreteServiceImplementation(Type type) =>
type.Namespace == ServiceImplementationNamespace && IsConcrete(type);
private static bool IsConcreteDataService(Type type) =>
type.Namespace == DataServiceImplementationNamespace && IsConcrete(type);
private static IEnumerable<ParameterInfo> ParametersOf(IEnumerable<Type> types) =>
types.SelectMany(t => t.GetConstructors().SelectMany(c => c.GetParameters()));
[Fact]
public void Controllers_DoNotInject_DbContext_ConcreteDataServices_Or_ConcreteServiceImplementations()
{
ApiControllers.Should().NotBeEmpty("the API must expose controllers for this invariant to be meaningful");
var violations = ParametersOf(ApiControllers)
.Where(p => IsDbContext(p.ParameterType)
|| IsConcreteDataService(p.ParameterType)
|| IsConcreteServiceImplementation(p.ParameterType))
.Select(p => $"{p.Member.DeclaringType!.Name} depends on {p.ParameterType.FullName} ({p.Name})")
.ToList();
violations.Should().BeEmpty(
"controllers must depend only on interfaces/abstractions, never on DbContext, concrete data services, or concrete service implementations");
}
[Fact]
public void BusinessServices_DoNotInject_DbContext_Or_IConfiguration()
{
BusinessServiceImplementations.Should().NotBeEmpty(
"business services must be discovered for this invariant to be meaningful");
var violations = ParametersOf(BusinessServiceImplementations)
.Where(p => IsDbContext(p.ParameterType)
|| typeof(IConfiguration).IsAssignableFrom(p.ParameterType))
.Select(p => $"{p.Member.DeclaringType!.Name} depends on {p.ParameterType.FullName} ({p.Name})")
.ToList();
violations.Should().BeEmpty(
"business services must depend on data-service interfaces and typed options, never on DbContext or IConfiguration");
}
[Fact]
public void BusinessServices_DependOnInterfaces_NotConcreteServiceOrDataServiceImplementations()
{
// Guards the feature-specific interface boundary: a business service must
// depend on I{Feature}DataService / I{Feature}Service abstractions, never on
// concrete service or data-service implementations (no generic-repository
// theater and no coupling to persistence implementations).
BusinessServiceImplementations.Should().NotBeEmpty(
"business services must be discovered for this invariant to be meaningful");
var violations = ParametersOf(BusinessServiceImplementations)
.Where(p => IsConcreteServiceImplementation(p.ParameterType)
|| IsConcreteDataService(p.ParameterType))
.Select(p => $"{p.Member.DeclaringType!.Name} depends on {p.ParameterType.FullName} ({p.Name})")
.ToList();
violations.Should().BeEmpty(
"business services must depend on interfaces (I{Feature}Service / I{Feature}DataService), "
+ "never on concrete service or data-service implementations");
}
}

View file

@ -1,261 +0,0 @@
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using System.Text.Json;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class AuthenticationControllerTests
{
private static string Json(object? value) =>
JsonSerializer.Serialize(value, value?.GetType() ?? typeof(object));
private static AuthenticationController NewController(Mock<IAuthenticationService> service, string? userId = null)
{
var controller = new AuthenticationController(service.Object, Mock.Of<ILogger<AuthenticationController>>());
if (userId != null)
{
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, userId)
}, "Test"))
}
};
}
return controller;
}
[Fact]
public async Task Login_WithSuccessfulResult_MapsLowercaseTokenPayload()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.LoginAsync("alice", "pw", It.IsAny<CancellationToken>()))
.ReturnsAsync(new LoginResultDTO
{
Token = "tok",
Expiration = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc),
Email = "alice@example.com",
UserRole = "Admin",
PhoneNumber = "555",
Fullname = "Alice Q",
Id = "user-1"
});
var controller = NewController(service);
var result = await controller.Login(new LoginModel { Username = "alice", Password = "pw" }, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var json = Json(ok.Value);
json.Should().Contain("\"token\":\"tok\"");
json.Should().Contain("\"expiration\":\"");
json.Should().Contain("\"email\":\"alice@example.com\"");
json.Should().Contain("\"userRoles\":\"Admin\"");
json.Should().Contain("\"phoneNumber\":\"555\"");
json.Should().Contain("\"fullname\":\"Alice Q\"");
json.Should().Contain("\"id\":\"user-1\"");
}
[Fact]
public async Task Login_WithNullResult_ReturnsUnauthorized()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.LoginAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((LoginResultDTO?)null);
var controller = NewController(service);
var result = await controller.Login(new LoginModel(), CancellationToken.None);
result.Should().BeOfType<UnauthorizedResult>();
}
[Fact]
public async Task Login_WhenServiceThrows_ReturnsSanitized500AndLogsInternally()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.LoginAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("SECRET-internal-stack-detail"));
var logger = new Mock<ILogger<AuthenticationController>>();
logger.Setup(x => x.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
var controller = new AuthenticationController(service.Object, logger.Object);
var result = await controller.Login(new LoginModel { Username = "x", Password = "y" }, CancellationToken.None);
var status = result.Should().BeOfType<ObjectResult>().Subject;
status.StatusCode.Should().Be(500);
var message = (string)status.Value!;
message.Should().NotContain("SECRET");
message.Should().NotContain("internal-stack-detail");
message.Should().Contain("reference");
logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.Is<Exception>(ex => ex.Message.Contains("SECRET")),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task ChangePassword_Success_ReturnsSuccessMessageWithTrailingSpace()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync("42", "old", "new", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var controller = NewController(service, "42");
var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Confirmpassword = "new" }, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Success ");
response.Message.Should().Be("Password successfully changed");
}
[Fact]
public async Task ChangePassword_Failure_ReturnsOldPasswordIncorrectStatus()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var controller = NewController(service, "42");
var result = await controller.ChangePassword(new ChangePasswords(), CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Old Password is incorrect");
}
[Fact]
public async Task UpdateProfile_MapsProfileDataIntoDataResponse()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.UpdateProfileAsync("42", It.IsAny<UpdateProfileRequestDTO>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new UserProfileDTO { FirstName = "Bob", Email = "bob@x.com", Contact = "111" });
var controller = NewController(service, "42");
var form = new User_DTO { Name = "Bob", Email = "bob@x.com", Contact = "111" };
var result = await controller.UserProfileUpdate(form, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
response.Status.Should().Be("200");
response.Message.Should().Be("Introduction Updated Successfully");
var json = Json((object?)response.Data);
json.Should().Contain("\"FirstName\":\"Bob\"");
json.Should().Contain("\"Email\":\"bob@x.com\"");
json.Should().Contain("\"Contact\":\"111\"");
}
[Fact]
public async Task ForgetPassword_Found_ReturnsCheckEmailMessage()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ForgetPasswordAsync("a@b.com", It.IsAny<CancellationToken>())).ReturnsAsync(true);
var controller = NewController(service);
var result = await controller.ForgetPassword("a@b.com", CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Success ");
response.Message.Should().Be("Please check your email for code");
}
[Fact]
public async Task ForgetPassword_NotFound_ReturnsNoSuchEmailMessage()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ForgetPasswordAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
var controller = NewController(service);
var result = await controller.ForgetPassword("x@y.com", CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Error");
response.Message.Should().Be("No such email is registered");
}
[Theory]
[InlineData(true, "Success ", "Code Matched")]
[InlineData(false, "Error", "Code Not Matched")]
public async Task VerificationCode_MapsServiceResult(bool matched, string expectedStatus, string expectedMessage)
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.VerifyCodeAsync("123456", It.IsAny<CancellationToken>())).ReturnsAsync(matched);
var controller = NewController(service);
var result = await controller.VerificationCode("123456", CancellationToken.None);
if (matched)
{
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var r = ok.Value.Should().BeOfType<Response>().Subject;
r.Status.Should().Be(expectedStatus);
r.Message.Should().Be(expectedMessage);
}
else
{
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var r = bad.Value.Should().BeOfType<Response>().Subject;
r.Status.Should().Be(expectedStatus);
r.Message.Should().Be(expectedMessage);
}
}
[Fact]
public async Task ResetPassword_Matched_ReturnsPasswordChangedMessage()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ResetPasswordAsync("a@b.com", "123", "new", It.IsAny<CancellationToken>())).ReturnsAsync(true);
var controller = NewController(service);
var result = await controller.ResetPassword(new ForgetPassword_Dto { Email = "a@b.com", Code = "123", Password = "new" }, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Success ");
response.Message.Should().Be("password changed");
}
[Fact]
public async Task ResetPassword_NotMatched_ReturnsEmailOrCodeNotFoundMessage()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ResetPasswordAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var controller = NewController(service);
var result = await controller.ResetPassword(new ForgetPassword_Dto { Email = "a@b.com" }, CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be("Your email or code not found please check");
}
}

View file

@ -1,171 +0,0 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class AuthenticationServiceTests
{
private static AuthenticationService NewService(
Mock<IUserDataService> userData,
Mock<IForgetPasswordDataService> forget,
Mock<IEmailSender> email,
out Mock<IUserRoleStore<ApplicationUser>> store,
out Mock<IPasswordHasher<ApplicationUser>> hasher)
{
var (manager, s, h) = IdentityTestHelpers.CreateUserManager();
store = s;
hasher = h;
return new AuthenticationService(manager, Microsoft.Extensions.Options.Options.Create(JwtOptions), userData.Object, forget.Object, email.Object);
}
private static JwtOptions JwtOptions => new()
{
Secret = new string('x', 64),
ValidIssuer = "issuer",
ValidAudience = "audience"
};
[Fact]
public async Task Login_UnknownUser_ReturnsNull()
{
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out _);
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
var result = await service.LoginAsync("nobody", "pw", CancellationToken.None);
result.Should().BeNull();
}
[Fact]
public async Task Login_DeletedUser_RejectedBeforePasswordCheck()
{
var deletedUser = IdentityTestHelpers.User(isDeleted: true);
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out var hasher);
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(deletedUser);
store.Setup(s => s.GetRolesAsync(deletedUser, It.IsAny<CancellationToken>())).ReturnsAsync(new List<string>());
var result = await service.LoginAsync("alice", "pw", CancellationToken.None);
result.Should().BeNull();
hasher.Verify(h => h.VerifyHashedPassword(It.IsAny<ApplicationUser>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task Login_ValidUser_ReturnsTokenFirstRoleAndIdentity()
{
var user = IdentityTestHelpers.User();
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out var hasher);
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
store.Setup(s => s.GetRolesAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync(new List<string> { "Admin", "Manager" });
store.As<Microsoft.AspNetCore.Identity.IUserPasswordStore<ApplicationUser>>()
.Setup(s => s.GetPasswordHashAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync("hash");
hasher.Setup(h => h.VerifyHashedPassword(user, "hash", "pw")).Returns(Microsoft.AspNetCore.Identity.PasswordVerificationResult.Success);
var result = await service.LoginAsync("alice", "pw", CancellationToken.None);
result.Should().NotBeNull();
result!.Id.Should().Be(user.Id);
result.Email.Should().Be(user.Email);
result.PhoneNumber.Should().Be(user.PhoneNumber);
result.Fullname.Should().Be("Alice Q");
result.UserRole.Should().Be("Admin");
result.Token.Should().NotBeNullOrEmpty();
result.Expiration.Should().BeAfter(DateTime.Now.AddDays(9));
}
[Fact]
public async Task Login_BadPassword_ReturnsNull()
{
var user = IdentityTestHelpers.User();
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out var hasher);
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
store.As<Microsoft.AspNetCore.Identity.IUserPasswordStore<ApplicationUser>>()
.Setup(s => s.GetPasswordHashAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync("hash");
hasher.Setup(h => h.VerifyHashedPassword(user, "hash", "wrong")).Returns(Microsoft.AspNetCore.Identity.PasswordVerificationResult.Failed);
var result = await service.LoginAsync("alice", "wrong", CancellationToken.None);
result.Should().BeNull();
}
[Fact]
public async Task ForgetPassword_RegisteredEmail_ReplacesCodeAndSendsEmail()
{
var user = IdentityTestHelpers.User();
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny<CancellationToken>())).ReturnsAsync(user);
var forget = new Mock<IForgetPasswordDataService>();
var email = new Mock<IEmailSender>();
var service = NewService(userData, forget, email, out _, out _);
var found = await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
found.Should().BeTrue();
forget.Verify(
f => f.ReplaceCodeAsync(
user.Email!,
user.Id,
It.Is<string>(code => code.Length == 6 && code.All(char.IsDigit)),
It.IsAny<CancellationToken>()),
Times.Once);
email.Verify(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.Is<string>(b => b.Contains("Your Password Reset Code is:"))), Times.Once);
}
[Fact]
public async Task ForgetPassword_UnknownEmail_DoesNotEmailOrStoreCode()
{
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
var forget = new Mock<IForgetPasswordDataService>();
var email = new Mock<IEmailSender>();
var service = NewService(userData, forget, email, out _, out _);
var found = await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None);
found.Should().BeFalse();
forget.Verify(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
email.Verify(e => e.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task VerifyCode_ForwardsDataServiceResult(bool exists)
{
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.CodeExistsAsync("abc", It.IsAny<CancellationToken>())).ReturnsAsync(exists);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), out _, out _);
var result = await service.VerifyCodeAsync("abc", CancellationToken.None);
result.Should().Be(exists);
}
[Fact]
public async Task ResetPassword_NoMatchingCode_ReturnsFalseWithoutReset()
{
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.ExistsByEmailAndCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), out var store, out _);
var result = await service.ResetPasswordAsync("a@b.com", "999", "new", CancellationToken.None);
result.Should().BeFalse();
store.Verify(s => s.FindByIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
forget.Verify(f => f.RemoveByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
}

View file

@ -3,17 +3,34 @@ using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class CalendarControllerTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static UserManager<ApplicationUser> MockUserManager()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
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,
@ -26,181 +43,164 @@ public class CalendarControllerTests
EndTime = "08:30 AM"
};
private static CalendarController NewController(Mock<ICalendarService> service) =>
new(service.Object, Mock.Of<ILogger<CalendarController>>());
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_WhenServiceSucceeds_ReturnsOkWithCreatedMessage()
public async Task AddEvent_WhenValid_PersistsEventAndReturnsOk()
{
var service = new Mock<ICalendarService>();
var result = await NewController(service).AddEvent(ValidDto(), CancellationToken.None);
using var ctx = NewContext();
var result = await NewController(ctx).AddEvent(ValidDto());
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
response.Status.Should().Be("200");
response.Message.Should().Be("Event Created Successfully");
service.Verify(s => s.CreateEventAsync(It.IsAny<EditEventInput>(), It.IsAny<CancellationToken>()), Times.Once);
ok.Value.Should().BeOfType<DataResponse>().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 AddEvent_WhenServiceThrows_ReturnsSanitizedMessageAndLogsInternally()
public async Task EditEvent_WhenIdMissing_ReturnsBadRequest()
{
var service = new Mock<ICalendarService>();
service.Setup(s => s.CreateEventAsync(It.IsAny<EditEventInput>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("SECRET-internal-db-connection-leak"));
using var ctx = NewContext();
var result = await NewController(ctx).EditEvent(ValidDto(id: null));
var logger = new Mock<ILogger<CalendarController>>();
logger.Setup(x => x.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
var controller = new CalendarController(service.Object, logger.Object);
var result = await controller.AddEvent(ValidDto(), CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Error");
response.Message.Should().NotContain("SECRET");
response.Message.Should().NotContain("internal-db-connection-leak");
response.Message.Should().Contain("reference");
logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.Is<Exception>(ex => ex.Message.Contains("SECRET")),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task EditEvent_WhenIdMissing_ReturnsBadRequestWithoutCallingService()
public async Task EditEvent_WhenEventNotFound_ReturnsNotFound()
{
var service = new Mock<ICalendarService>();
var result = await NewController(service).EditEvent(ValidDto(id: null), CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be("Event ID is required for editing");
service.Verify(s => s.UpdateEventAsync(It.IsAny<EditEventInput>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task EditEvent_WhenServiceReturnsFalse_ReturnsNotFound()
{
var service = new Mock<ICalendarService>();
service.Setup(s => s.UpdateEventAsync(It.IsAny<EditEventInput>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var result = await NewController(service).EditEvent(ValidDto(id: 5), CancellationToken.None);
var nf = result.Should().BeOfType<NotFoundObjectResult>().Subject;
var response = nf.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be("Event not found");
}
[Fact]
public async Task EditEvent_WhenServiceReturnsTrue_ReturnsOkWithUpdatedMessage()
{
var service = new Mock<ICalendarService>();
service.Setup(s => s.UpdateEventAsync(It.IsAny<EditEventInput>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var result = await NewController(service).EditEvent(ValidDto(id: 5, title: "New"), CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeOfType<DataResponse>().Which.Message.Should().Be("Event Updated Successfully");
}
[Fact]
public async Task GetEventById_WhenServiceReturnsNull_ReturnsNotFound()
{
var service = new Mock<ICalendarService>();
service.Setup(s => s.GetEventByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync((EventDTO?)null);
var result = await NewController(service).GetEventById(7, CancellationToken.None);
using var ctx = NewContext();
var result = await NewController(ctx).EditEvent(ValidDto(id: 999));
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task GetEventById_WhenServiceReturnsDto_ReturnsOkWithEventDto()
public async Task EditEvent_WhenEventExists_UpdatesFieldsAndStampsModified()
{
var service = new Mock<ICalendarService>();
service.Setup(s => s.GetEventByIdAsync(7, It.IsAny<CancellationToken>()))
.ReturnsAsync(new EventDTO { Id = 7, Title = "Review", createdby = "abc" });
using var ctx = NewContext();
var existing = SeedEvent(ctx, "Old", new DateTime(2026, 6, 1));
var result = await NewController(service).GetEventById(7, CancellationToken.None);
var result = await NewController(ctx).EditEvent(ValidDto(id: existing.Id, title: "New"));
result.Should().BeOfType<OkObjectResult>();
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<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
var dto = response.Data as Event_DTO;
dto.Should().NotBeNull();
var dto = ok.Value.Should().BeOfType<DataResponse>().Subject.Data as Event_DTO;
dto!.Title.Should().Be("Review");
dto!.createdby.Should().Be("abc");
}
[Fact]
public async Task DeleteEvent_WhenServiceReturnsFalse_ReturnsNotFound()
public async Task GetEventById_WhenSoftDeleted_ReturnsNotFound()
{
var service = new Mock<ICalendarService>();
service.Setup(s => s.DeleteEventAsync(9, It.IsAny<CancellationToken>())).ReturnsAsync(false);
using var ctx = NewContext();
var deleted = SeedEvent(ctx, "Gone", new DateTime(2026, 6, 1), isDeleted: true);
var result = await NewController(service).DeleteEvent(9, CancellationToken.None);
var result = await NewController(ctx).GetEventById(deleted.Id);
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task DeleteEvent_WhenServiceReturnsTrue_ReturnsOkWithDeletedMessage()
public async Task DeleteEvent_WhenExists_SoftDeletesAndKeepsRow()
{
var service = new Mock<ICalendarService>();
service.Setup(s => s.DeleteEventAsync(9, It.IsAny<CancellationToken>())).ReturnsAsync(true);
using var ctx = NewContext();
var existing = SeedEvent(ctx, "Cancel", new DateTime(2026, 6, 1));
var result = await NewController(service).DeleteEvent(9, CancellationToken.None);
var result = await NewController(ctx).DeleteEvent(existing.Id);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeOfType<DataResponse>().Which.Message.Should().Be("Event Deleted Successfully");
result.Should().BeOfType<OkObjectResult>();
var row = ctx.Events.Single();
row.IsDeleted.Should().Be(true);
row.DeletionTime.Should().NotBeNull();
}
[Fact]
public async Task GetEventList_ReturnsOkWithRowsAndTotalCount()
public async Task DeleteEvent_WhenNotFound_ReturnsNotFound()
{
var service = new Mock<ICalendarService>();
service.Setup(s => s.GetEventListAsync("alpha", 1, 10, It.IsAny<CancellationToken>()))
.ReturnsAsync(new PagedEventsResult
{
TotalCount = 2,
Rows = new List<EventDTO>
{
new() { Id = 1, Title = "Alpha meeting" },
new() { Id = 2, Title = "Alpha review" }
}
});
using var ctx = NewContext();
var result = await NewController(ctx).DeleteEvent(12345);
var result = await NewController(service).GetEventList(search: "alpha", page: 1, pageSize: 10);
result.Should().BeOfType<NotFoundObjectResult>();
}
[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<OkObjectResult>().Subject;
var data = ok.Value.Should().BeOfType<DataResponse>().Subject.Data!;
((int)Prop(data, "totalCount")).Should().Be(2);
var rows = ((IEnumerable)Prop(data, "rows")).Cast<Event_DTO>().ToList();
rows.Should().HaveCount(2);
}
[Fact]
public async Task GetEventsForCalendar_ReturnsOkWithEvents()
public void GetEventList_OrdersByStartDateDescending()
{
var service = new Mock<ICalendarService>();
service.Setup(s => s.GetEventsForCalendarAsync(It.IsAny<DateTime?>(), It.IsAny<DateTime?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<EventDTO> { new() { Id = 1, Title = "InRange" } });
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 = await NewController(service).GetEventsForCalendar(
start: new DateTime(2026, 6, 1), end: new DateTime(2026, 6, 30), CancellationToken.None);
var result = NewController(ctx).GetEventList(page: 1, pageSize: 10);
var data = (result as OkObjectResult)!.Value.As<DataResponse>().Data!;
var rows = ((IEnumerable)Prop(data, "rows")).Cast<Event_DTO>().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<OkObjectResult>().Subject;
var data = ok.Value.Should().BeOfType<DataResponse>().Subject.Data;
var events = ((IEnumerable)data!).Cast<Event_DTO>().ToList();
events.Single().Title.Should().Be("InRange");
var events = ok.Value.As<DataResponse>().Data as IEnumerable<Event_DTO>;
events!.Select(e => e.Title).Should().BeEquivalentTo(new[] { "InRange" });
}
}

View file

@ -1,188 +0,0 @@
using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class CalendarServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static CalendarService NewService(ApplicationDbContext ctx) =>
new(new CalendarDataService(ctx));
private static EditEventInput ValidInput(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 CreateEventAsync_PersistsEventWithMappedFieldsAndCreatedDate()
{
using var ctx = NewContext();
await NewService(ctx).CreateEventAsync(ValidInput(), CancellationToken.None);
var saved = ctx.Events.Single();
saved.Title.Should().Be("Standup");
saved.IsDeleted.Should().Be(false);
saved.CreatedDate.Should().NotBeNull();
}
[Fact]
public async Task UpdateEventAsync_WhenNotFound_ReturnsFalse()
{
using var ctx = NewContext();
var result = await NewService(ctx).UpdateEventAsync(ValidInput(id: 999), CancellationToken.None);
result.Should().BeFalse();
}
[Fact]
public async Task UpdateEventAsync_WhenSoftDeleted_ReturnsFalse()
{
using var ctx = NewContext();
var existing = SeedEvent(ctx, "Old", new DateTime(2026, 6, 1), isDeleted: true);
var result = await NewService(ctx).UpdateEventAsync(ValidInput(id: existing.Id, title: "New"), CancellationToken.None);
result.Should().BeFalse();
}
[Fact]
public async Task UpdateEventAsync_WhenEventExists_UpdatesFieldsAndStampsModified()
{
using var ctx = NewContext();
var existing = SeedEvent(ctx, "Old", new DateTime(2026, 6, 1));
var result = await NewService(ctx).UpdateEventAsync(ValidInput(id: existing.Id, title: "New"), CancellationToken.None);
result.Should().BeTrue();
var updated = ctx.Events.Single();
updated.Title.Should().Be("New");
updated.LastModificationTime.Should().NotBeNull();
}
[Fact]
public async Task GetEventByIdAsync_WhenExists_ReturnsMappedDto()
{
using var ctx = NewContext();
var existing = SeedEvent(ctx, "Review", new DateTime(2026, 6, 1));
var dto = await NewService(ctx).GetEventByIdAsync(existing.Id, CancellationToken.None);
dto.Should().NotBeNull();
dto!.Title.Should().Be("Review");
}
[Fact]
public async Task GetEventByIdAsync_WhenSoftDeleted_ReturnsNull()
{
using var ctx = NewContext();
var deleted = SeedEvent(ctx, "Gone", new DateTime(2026, 6, 1), isDeleted: true);
var dto = await NewService(ctx).GetEventByIdAsync(deleted.Id, CancellationToken.None);
dto.Should().BeNull();
}
[Fact]
public async Task DeleteEventAsync_WhenExists_SoftDeletesAndKeepsRow()
{
using var ctx = NewContext();
var existing = SeedEvent(ctx, "Cancel", new DateTime(2026, 6, 1));
var result = await NewService(ctx).DeleteEventAsync(existing.Id, CancellationToken.None);
result.Should().BeTrue();
var row = ctx.Events.Single();
row.IsDeleted.Should().Be(true);
row.DeletionTime.Should().NotBeNull();
}
[Fact]
public async Task DeleteEventAsync_WhenNotFound_ReturnsFalse()
{
using var ctx = NewContext();
var result = await NewService(ctx).DeleteEventAsync(12345, CancellationToken.None);
result.Should().BeFalse();
}
[Fact]
public async Task GetEventListAsync_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 = await NewService(ctx).GetEventListAsync("alpha", 1, 10, CancellationToken.None);
result.TotalCount.Should().Be(2);
result.Rows.Should().HaveCount(2);
}
[Fact]
public async Task GetEventListAsync_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 = await NewService(ctx).GetEventListAsync(null, 1, 10, CancellationToken.None);
result.Rows.Select(r => r.Title).Should().ContainInOrder("Latest", "Middle", "Earliest");
}
[Fact]
public async Task GetEventsForCalendarAsync_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 events = await NewService(ctx).GetEventsForCalendarAsync(
new DateTime(2026, 6, 1), new DateTime(2026, 6, 30), CancellationToken.None);
events.Select(e => e.Title).Should().BeEquivalentTo(new[] { "InRange" });
}
}

View file

@ -1,49 +0,0 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class DashboardServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static DashboardService NewService(ApplicationDbContext ctx) =>
new(new DashboardDataService(ctx));
private static WorkOrder Wo(string? status, bool? isTemplate = false, string? assignTo = null) =>
new() { Status = status, istemplate = isTemplate, AssignTo = assignTo };
[Fact]
public async Task GetStatsAsync_CountsByStatusExcludingTemplates()
{
using var ctx = NewContext();
ctx.workOrders.AddRange(
Wo("Open", isTemplate: false),
Wo("Open", isTemplate: false, assignTo: "u1"),
Wo("In Progress"),
Wo("On Hold"),
Wo("Done"),
Wo("Done"),
Wo("Open", isTemplate: true),
Wo("Cancelled", isTemplate: false)
);
ctx.SaveChanges();
var stats = await NewService(ctx).GetStatsAsync(CancellationToken.None);
stats.Total.Should().Be(7);
stats.Open.Should().Be(4);
stats.NotDispatched.Should().Be(1);
stats.Completed.Should().Be(2);
}
}

View file

@ -1,223 +0,0 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class DispatchBatchingBehaviorTests
{
private static WorkOrderDispatchService BuildService(
Mock<IWorkOrderDataService>? woData = null,
Mock<IVendorDataService>? vendorData = null,
Mock<ITaskListTemplateDataService>? templateData = null,
Mock<IDispatchDataService>? dispatchData = null)
{
var dd = dispatchData ?? new Mock<IDispatchDataService>();
dd.Setup(d => d.GenerateNextPONumberAsync()).ReturnsAsync("1");
dd.Setup(d => d.GenerateNextDispatchNumberAsync()).ReturnsAsync("DSP-1");
dd.Setup(d => d.AddAsync(It.IsAny<Dispatch>()))
.ReturnsAsync((Dispatch d) => d);
var emailPort = new Mock<IDispatchEmailPort>();
emailPort.Setup(e => e.SendDispatchEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(true);
var tokenPort = new Mock<IVendorTokenPort>();
tokenPort.Setup(t => t.GetOrCreateActiveTokenAsync(It.IsAny<int>())).ReturnsAsync("token");
return new WorkOrderDispatchService(
(woData ?? new Mock<IWorkOrderDataService>()).Object,
dd.Object,
(vendorData ?? new Mock<IVendorDataService>()).Object,
Mock.Of<IUserDataService>(),
(templateData ?? new Mock<ITaskListTemplateDataService>()).Object,
Mock.Of<ICommentDataService>(),
emailPort.Object,
tokenPort.Object,
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()));
}
private static Mock<IWorkOrderDataService> WorkOrderDataReturning(params WorkOrder[] workOrders) =>
WorkOrderDataReturning(new Mock<IWorkOrderDataService>(), workOrders);
private static Mock<IWorkOrderDataService> WorkOrderDataReturning(Mock<IWorkOrderDataService> mock, params WorkOrder[] workOrders)
{
mock.Setup(d => d.GetByRequestedIdsWithDetailsAsync(
It.IsAny<IReadOnlyCollection<int>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(workOrders.ToList());
return mock;
}
private static Mock<IVendorDataService> VendorDataReturning(params Vendor[] vendors)
{
var mock = new Mock<IVendorDataService>();
mock.Setup(d => d.GetByIdsOrderedAsync(It.IsAny<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(vendors.ToList());
return mock;
}
[Fact]
public async Task Dispatch_PrimaryWorkOrderFollowsReturnedOrder()
{
var woData = WorkOrderDataReturning(
new WorkOrder { Id = 30, InternalWONumber = "WO-30" },
new WorkOrder { Id = 10, InternalWONumber = "WO-10" });
var dispatchData = new Mock<IDispatchDataService>();
var service = BuildService(woData, VendorDataReturning(new Vendor { Id = 1, CompanyName = "V", Email = "v@x.io" }), dispatchData: dispatchData);
Dispatch? saved = null;
dispatchData.Setup(d => d.AddAsync(It.IsAny<Dispatch>()))
.ReturnsAsync((Dispatch d) => { saved = d; return d; });
await service.DispatchToVendorAsync(new DispatchToVendorInput
{
WorkOrderIds = new() { 30, 10 },
VendorIds = new() { 1 }
}, CancellationToken.None);
saved.Should().NotBeNull();
saved!.WorkOrderId.Should().Be(30);
}
[Fact]
public async Task Dispatch_DeduplicatesVendorsAndSkipsMissing()
{
var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" });
var vendorData = new Mock<IVendorDataService>();
vendorData.Setup(d => d.GetByIdsOrderedAsync(It.IsAny<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Vendor> { new() { Id = 2, CompanyName = "Only Co", Email = "v@x.io" } });
var service = BuildService(woData, vendorData);
var result = await service.DispatchToVendorAsync(new DispatchToVendorInput
{
WorkOrderIds = new() { 10 },
VendorIds = new() { 2, 2, 999 }
}, CancellationToken.None);
result.Dispatches.Should().HaveCount(1);
result.Dispatches.Single().VendorName.Should().Be("Only Co");
vendorData.Verify(d => d.GetByIdsOrderedAsync(
It.Is<IReadOnlyCollection<int>>(ids => ids.SequenceEqual(new[] { 2, 999 })),
It.IsAny<CancellationToken>()), Times.Once);
vendorData.Verify(d => d.GetByIdAsync(It.IsAny<int>()), Times.Never);
}
[Fact]
public async Task Dispatch_FetchesTemplateOnceRegardlessOfVendorCount()
{
var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" });
var templateData = new Mock<ITaskListTemplateDataService>();
templateData.Setup(d => d.GetByIdWithItemsAsync(7))
.ReturnsAsync(new TaskListTemplate
{
Items = new List<TaskListTemplateItem>
{
new() { ItemText = "A", SortOrder = 0 },
new() { ItemText = "B", SortOrder = 1 }
}
});
var vendorData = VendorDataReturning(
new Vendor { Id = 1, CompanyName = "V1", Email = "a@x.io" },
new Vendor { Id = 2, CompanyName = "V2", Email = "b@x.io" });
var service = BuildService(woData, vendorData, templateData);
await service.DispatchToVendorAsync(new DispatchToVendorInput
{
WorkOrderIds = new() { 10 },
VendorIds = new() { 1, 2 },
TaskListTemplateId = 7
}, CancellationToken.None);
templateData.Verify(d => d.GetByIdWithItemsAsync(7), Times.Once);
}
[Fact]
public async Task Dispatch_PerformsSingleBatchedVendorLookup()
{
var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" });
var vendorData = VendorDataReturning(new Vendor { Id = 1, CompanyName = "V1", Email = "a@x.io" });
var service = BuildService(woData, vendorData);
await service.DispatchToVendorAsync(new DispatchToVendorInput
{
WorkOrderIds = new() { 10 },
VendorIds = new() { 1, 2, 3 }
}, CancellationToken.None);
vendorData.Verify(d => d.GetByIdsOrderedAsync(It.IsAny<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()), Times.Once);
vendorData.Verify(d => d.GetByIdAsync(It.IsAny<int>()), Times.Never);
}
[Fact]
public async Task Dispatch_BatchesAuditLogsOncePerVendorAndNeverSingles()
{
var woData = WorkOrderDataReturning(
new WorkOrder { Id = 10, InternalWONumber = "WO-10" },
new WorkOrder { Id = 11, InternalWONumber = "WO-11" });
var vendorData = VendorDataReturning(
new Vendor { Id = 1, CompanyName = "V1", Email = "a@x.io" },
new Vendor { Id = 2, CompanyName = "V2", Email = "b@x.io" });
var service = BuildService(woData, vendorData);
await service.DispatchToVendorAsync(new DispatchToVendorInput
{
WorkOrderIds = new() { 10, 11 },
VendorIds = new() { 1, 2 }
}, CancellationToken.None);
woData.Verify(d => d.AddAuditLogsAsync(It.Is<IReadOnlyCollection<WorkOrderAuditLog>>(logs => logs.Count == 2)), Times.Exactly(2));
woData.Verify(d => d.AddAuditLogAsync(It.IsAny<WorkOrderAuditLog>()), Times.Never);
}
[Fact]
public async Task Dispatch_NoWorkOrders_Throws()
{
var woData = WorkOrderDataReturning();
var service = BuildService(woData, VendorDataReturning());
var act = () => service.DispatchToVendorAsync(new DispatchToVendorInput
{
WorkOrderIds = new() { 404 },
VendorIds = new() { 1 }
}, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
}
[Fact]
public async Task Dispatch_ForwardsSameCancellationTokenToBothBatchDataServices()
{
var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" });
var vendorData = VendorDataReturning(new Vendor { Id = 1, CompanyName = "V1", Email = "a@x.io" });
var service = BuildService(woData, vendorData);
using var cts = new CancellationTokenSource();
var token = cts.Token;
await service.DispatchToVendorAsync(new DispatchToVendorInput
{
WorkOrderIds = new() { 10 },
VendorIds = new() { 1 }
}, token);
woData.Verify(d => d.GetByRequestedIdsWithDetailsAsync(
It.IsAny<IReadOnlyCollection<int>>(),
token), Times.Once);
vendorData.Verify(d => d.GetByIdsOrderedAsync(
It.IsAny<IReadOnlyCollection<int>>(),
token), Times.Once);
}
}

View file

@ -1,77 +0,0 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class DispatchControllerTests
{
private static DispatchController NewController(Mock<IDispatchService> service) =>
new(service.Object);
private static object Prop(object source, string name) =>
source.GetType().GetProperty(name)!.GetValue(source)!;
[Fact]
public async Task List_ReturnsDataResponseWithPaginationShape()
{
var service = new Mock<IDispatchService>();
service.Setup(s => s.GetDispatchListAsync("a", null, null, null, null, "dispatchedAt", "desc", 1, 25, It.IsAny<CancellationToken>()))
.ReturnsAsync(new DispatchListResultDTO
{
Total = 2,
Page = 1,
PageSize = 25,
Items = new List<DispatchListEntryDTO>
{
new() { Id = 1, DispatchNumber = "D1" },
new() { Id = 2, DispatchNumber = "D2" }
}
});
var result = await NewController(service).List("a", null, null, null, null, "dispatchedAt", "desc", 1, 25, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
response.Status.Should().Be("Success");
object data = response.Data!;
Prop(data, "total").Should().Be(2);
Prop(data, "page").Should().Be(1);
Prop(data, "pageSize").Should().Be(25);
((System.Collections.IEnumerable)Prop(data, "items")).Cast<object>().Count().Should().Be(2);
}
[Fact]
public async Task GetById_WhenMissing_ReturnsNotFound()
{
var service = new Mock<IDispatchService>();
service.Setup(s => s.GetDispatchDetailAsync(99, It.IsAny<CancellationToken>()))
.ReturnsAsync((DispatchDetailResultDTO?)null);
var result = await NewController(service).GetById(99, CancellationToken.None);
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
notFound.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Dispatch not found");
}
[Fact]
public async Task GetById_WhenFound_ReturnsDetailAsData()
{
var service = new Mock<IDispatchService>();
var detail = new DispatchDetailResultDTO { Id = 5, DispatchNumber = "D5", Status = "Pending" };
service.Setup(s => s.GetDispatchDetailAsync(5, It.IsAny<CancellationToken>()))
.ReturnsAsync(detail);
var result = await NewController(service).GetById(5, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
response.Status.Should().Be("Success");
((object?)response.Data).Should().BeSameAs(detail);
}
}

View file

@ -1,117 +0,0 @@
using FluentAssertions;
using Moq;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class DispatchServiceTests
{
private static DispatchService NewService(Mock<IDispatchDataService> data) =>
new(data.Object, new CreateDispatchValidation(), new UpdateDispatchValidation());
[Fact]
public async Task GetDispatchListAsync_MapsDataReadModelToServiceDtoPreservingFields()
{
var data = new Mock<IDispatchDataService>();
var source = new DispatchListResult
{
Total = 1,
Page = 2,
PageSize = 25,
Items = new List<DispatchListItemDTO>
{
new()
{
Id = 7,
DispatchNumber = "DISP-7",
PONumber = "PO-1",
NTEAmount = 200m,
Status = "Pending",
VendorId = 9,
VendorName = "Acme",
WorkOrderId = 3,
PrimaryWOInternalNumber = "WO-3",
PrimaryWOTitle = "Fix AC",
LocationName = "Site A",
WorkOrderCount = 2,
PendingUpliftCount = 1
}
}
};
data.Setup(d => d.GetListAsync(It.IsAny<DispatchListQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(source);
var result = await NewService(data).GetDispatchListAsync("a", "Pending", 9, null, null, "ponumber", "asc", 2, 25, CancellationToken.None);
result.Should().BeOfType<DispatchListResultDTO>();
result.Total.Should().Be(1);
result.Page.Should().Be(2);
result.PageSize.Should().Be(25);
var row = result.Items.Single();
row.Id.Should().Be(7);
row.DispatchNumber.Should().Be("DISP-7");
row.PONumber.Should().Be("PO-1");
row.NTEAmount.Should().Be(200m);
row.Status.Should().Be("Pending");
row.VendorId.Should().Be(9);
row.VendorName.Should().Be("Acme");
row.WorkOrderId.Should().Be(3);
row.PrimaryWOInternalNumber.Should().Be("WO-3");
row.PrimaryWOTitle.Should().Be("Fix AC");
row.LocationName.Should().Be("Site A");
row.WorkOrderCount.Should().Be(2);
row.PendingUpliftCount.Should().Be(1);
}
[Fact]
public async Task GetDispatchDetailAsync_MapsNestedCollectionsPreservingShape()
{
var data = new Mock<IDispatchDataService>();
var detail = new DispatchDetailDTO
{
Id = 42,
DispatchNumber = "DISP-42",
PONumber = "PO-9",
NTEAmount = 500m,
Status = "Dispatched",
Description = "desc",
WorkOrderId = 11,
EmailSent = true,
Vendor = new DispatchDetailVendorDTO { Id = 1, CompanyName = "VendorCo", Email = "v@x.com" },
PrimaryLocation = new DispatchPrimaryLocationDTO { Id = 5, Name = "Loc", City = "Austin" },
WorkOrders = new List<DispatchLinkedWorkOrderDTO> { new() { WorkOrderId = 11, Title = "T", Priority = "High" } },
Checklist = new List<DispatchChecklistDTO> { new() { Id = 1, ItemText = "Check", IsCompleted = true, SortOrder = 1 } },
Signoffs = new List<DispatchSignoffDTO> { new() { Id = 1, SignoffType = "Vendor", Name = "J" } },
Comments = new List<DispatchCommentDTO> { new() { Id = 1, Commenttext = "Hi", UserName = "Sara" } },
UpliftRequests = new List<DispatchUpliftRequestDTO> { new() { Id = 1, RequestedNTE = 600m, CurrentNTE = 500m, Delta = 100m, Status = "Pending" } },
AuditLog = new List<DispatchAuditLogDTO> { new() { Id = 1, Action = "Updated", FieldName = "DISP-42:Status" } }
};
data.Setup(d => d.GetDetailAsync(42, It.IsAny<CancellationToken>())).ReturnsAsync(detail);
data.Setup(d => d.GetDetailAsync(999, It.IsAny<CancellationToken>())).ReturnsAsync((DispatchDetailDTO?)null);
var service = NewService(data);
var found = await service.GetDispatchDetailAsync(42, CancellationToken.None);
var missing = await service.GetDispatchDetailAsync(999, CancellationToken.None);
missing.Should().BeNull();
found.Should().BeOfType<DispatchDetailResultDTO>();
found!.Id.Should().Be(42);
found.DispatchNumber.Should().Be("DISP-42");
found.EmailSent.Should().BeTrue();
found.Vendor!.CompanyName.Should().Be("VendorCo");
found.PrimaryLocation!.Name.Should().Be("Loc");
found.WorkOrders.Single().Title.Should().Be("T");
found.Checklist.Single().IsCompleted.Should().BeTrue();
found.Signoffs.Single().Name.Should().Be("J");
found.Comments.Single().UserName.Should().Be("Sara");
found.UpliftRequests.Single().Delta.Should().Be(100m);
found.AuditLog.Single().Action.Should().Be("Updated");
}
}

View file

@ -1,146 +0,0 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class DropdownOptionsServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static DropdownOptionsService NewService(ApplicationDbContext ctx) =>
new(new DropdownOptionsDataService(ctx));
private static DropdownOption Option(string category, string value, string? parent = null, int sort = 0, bool active = true) =>
new() { Category = category, Value = value, ParentValue = parent, SortOrder = sort, IsActive = active };
[Fact]
public async Task GetAllAsync_FiltersActiveAndOrdersBySortThenValue()
{
using var ctx = NewContext();
ctx.DropdownOptions.AddRange(
Option("Color", "Red", sort: 2),
Option("Color", "Blue", sort: 1),
Option("Color", "Green", sort: 1, active: false),
Option("Shape", "Circle")
);
ctx.SaveChanges();
var result = (await NewService(ctx).GetAllAsync(null, CancellationToken.None)).ToList();
result.Should().HaveCount(3);
result.Select(d => d.Value).Should().ContainInOrder("Blue", "Red", "Circle");
}
[Fact]
public async Task GetByCategoryAsync_FiltersByCategoryAndParent()
{
using var ctx = NewContext();
ctx.DropdownOptions.AddRange(
Option("State", "TX", parent: "US"),
Option("State", "CA", parent: "US"),
Option("State", "ON", parent: "CA")
);
ctx.SaveChanges();
var result = (await NewService(ctx).GetByCategoryAsync("State", "US", CancellationToken.None)).ToList();
result.Select(d => d.Value).Should().BeEquivalentTo(new[] { "TX", "CA" });
}
[Fact]
public async Task CreateAsync_WhenCategoryOrValueMissing_Throws()
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateAsync(new DropdownOptionDTO { Category = "", Value = "X" }, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
}
[Fact]
public async Task CreateAsync_WhenDuplicate_Throws()
{
using var ctx = NewContext();
ctx.DropdownOptions.Add(Option("Color", "Red", parent: "Primary"));
ctx.SaveChanges();
var act = () => NewService(ctx).CreateAsync(new DropdownOptionDTO { Category = "Color", Value = "Red", ParentValue = "Primary" }, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
}
[Fact]
public async Task CreateAsync_ForcesActiveTrueAndReturnsDtoWithId()
{
using var ctx = NewContext();
var dto = await NewService(ctx).CreateAsync(
new DropdownOptionDTO { Category = "Color", Value = "Red", IsActive = false }, CancellationToken.None);
dto.Id.Should().BeGreaterThan(0);
dto.IsActive.Should().BeTrue();
ctx.DropdownOptions.Single().IsActive.Should().BeTrue();
}
[Fact]
public async Task UpdateAsync_WhenNotFound_ThrowsKeyNotFound()
{
using var ctx = NewContext();
var act = () => NewService(ctx).UpdateAsync(404, new DropdownOptionDTO { Category = "Color", Value = "Red" }, CancellationToken.None);
await act.Should().ThrowAsync<KeyNotFoundException>();
}
[Fact]
public async Task UpdateAsync_UpdatesValueParentSortActiveButNotCategory()
{
using var ctx = NewContext();
var existing = Option("Color", "Red");
ctx.DropdownOptions.Add(existing);
ctx.SaveChanges();
await NewService(ctx).UpdateAsync(existing.Id, new DropdownOptionDTO
{
Category = "Ignored",
Value = "Crimson",
ParentValue = "Warm",
SortOrder = 5,
IsActive = false
}, CancellationToken.None);
var row = ctx.DropdownOptions.Single();
row.Category.Should().Be("Color");
row.Value.Should().Be("Crimson");
row.ParentValue.Should().Be("Warm");
row.SortOrder.Should().Be(5);
row.IsActive.Should().BeFalse();
}
[Fact]
public async Task DeleteAsync_WhenNotFound_ThrowsKeyNotFound()
{
using var ctx = NewContext();
var act = () => NewService(ctx).DeleteAsync(404, CancellationToken.None);
await act.Should().ThrowAsync<KeyNotFoundException>();
}
[Fact]
public async Task DeleteAsync_RemovesOption()
{
using var ctx = NewContext();
var existing = Option("Color", "Red");
ctx.DropdownOptions.Add(existing);
ctx.SaveChanges();
await NewService(ctx).DeleteAsync(existing.Id, CancellationToken.None);
ctx.DropdownOptions.Should().BeEmpty();
}
}

View file

@ -1,135 +0,0 @@
using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class FollowUpControllerTests
{
private static FollowUpController NewController(Mock<IFollowUpService> service)
{
var controller = new FollowUpController(service.Object, Mock.Of<ILogger<FollowUpController>>())
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, "user-1")
}, "Test"))
}
}
};
return controller;
}
private static FollowUpController NewControllerNoUser(Mock<IFollowUpService> service) =>
new(service.Object, Mock.Of<ILogger<FollowUpController>>())
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity())
}
}
};
private static object Prop(object source, string name) =>
source.GetType().GetProperty(name)!.GetValue(source)!;
[Fact]
public async Task GetFollowUps_ReturnsPaginationEnvelopeWithMappedRows()
{
var service = new Mock<IFollowUpService>();
service.Setup(s => s.GetFollowUpListAsync(1, 12, "", "Open", It.IsAny<CancellationToken>()))
.ReturnsAsync(new PagedResult<FollowUpListRowDTO>
{
Items = new List<FollowUpListRowDTO> { new() { Id = 1, Reason = "R", CreatedByName = "Me" } },
TotalCount = 1,
Page = 1,
PageSize = 12
});
var result = await NewController(service).GetFollowUps();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
envelope.PageSize.Should().Be(12);
envelope.TotalCount.Should().Be(1);
}
[Fact]
public async Task GetById_WhenMissing_ReturnsNotFound()
{
var service = new Mock<IFollowUpService>();
service.Setup(s => s.GetFollowUpDetailAsync(9, It.IsAny<CancellationToken>()))
.ReturnsAsync((FollowUpDetailDTO?)null);
var result = await NewController(service).GetById(9, CancellationToken.None);
result.Should().BeOfType<NotFoundResult>();
}
[Fact]
public async Task Create_WhenNoUser_ReturnsUnauthorized()
{
var service = new Mock<IFollowUpService>();
var result = await NewControllerNoUser(service).Create(new FollowUp_DTO { Reason = "x" }, CancellationToken.None);
var unauthorized = result.Should().BeOfType<UnauthorizedObjectResult>().Subject;
unauthorized.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("User not authenticated");
service.Verify(s => s.CreateOpenFollowUpAsync(It.IsAny<FollowUpCreateRequestDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Create_WhenAuthenticated_ReturnsCreatedWithId()
{
var service = new Mock<IFollowUpService>();
service.Setup(s => s.CreateOpenFollowUpAsync(It.IsAny<FollowUpCreateRequestDTO>(), "user-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(42);
var result = await NewController(service).Create(new FollowUp_DTO { Reason = "x" }, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
response.Status.Should().Be("200");
response.Message.Should().Be("Follow Up created");
((object?)response.Data).Should().Be(42);
}
[Fact]
public async Task Complete_WhenMissing_ReturnsNotFound()
{
var service = new Mock<IFollowUpService>();
service.Setup(s => s.SetFollowUpStatusAsync(3, "Completed", It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var result = await NewController(service).Complete(3, CancellationToken.None);
result.Should().BeOfType<NotFoundResult>();
}
[Fact]
public async Task Delete_WhenMissing_ReturnsNotFoundEnvelope()
{
var service = new Mock<IFollowUpService>();
service.Setup(s => s.RemoveFollowUpAsync(8, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var result = await NewController(service).Delete(8, CancellationToken.None);
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
notFound.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Follow Up not found");
}
}

View file

@ -1,162 +0,0 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class FollowUpServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static FollowUpService NewService(ApplicationDbContext ctx) =>
new(new FollowUpDataService(ctx), new CreateFollowUpValidation(), new UpdateFollowUpValidation());
private static FollowUps SeedFollowUp(ApplicationDbContext ctx, string reason, string status = "Open")
{
var f = new FollowUps
{
Reason = reason,
ScheduleDate = new DateTime(2026, 8, 1),
ScheduleStartTime = new TimeSpan(9, 0, 0),
Status = status,
createdby = "user-1",
CreatedDate = DateTime.Now
};
ctx.FollowUps.Add(f);
ctx.SaveChanges();
return f;
}
[Fact]
public async Task CreateOpenFollowUpAsync_ForcesOpenStatusAndPersistsFields()
{
using var ctx = NewContext();
var service = NewService(ctx);
var id = await service.CreateOpenFollowUpAsync(new FollowUpCreateRequestDTO
{
Reason = "Call back",
ScheduleDate = new DateTime(2026, 9, 1),
ScheduleStartTime = new TimeSpan(10, 30, 0),
EmployeeId = 5,
WorkOrderId = 7,
AccountId = 9,
LocationId = 11
}, "user-9", CancellationToken.None);
var entity = ctx.FollowUps.Single();
entity.Id.Should().Be(id);
entity.Status.Should().Be("Open");
entity.Reason.Should().Be("Call back");
entity.createdby.Should().Be("user-9");
entity.EmployeeId.Should().Be(5);
entity.WorkOrderId.Should().Be(7);
entity.CreatedDate.Should().NotBeNull();
}
[Fact]
public async Task GetFollowUpListAsync_DefaultsToOpenAndMapsShape()
{
using var ctx = NewContext();
SeedFollowUp(ctx, "Open one", "Open");
SeedFollowUp(ctx, "Done one", "Completed");
var page = await NewService(ctx).GetFollowUpListAsync(1, 12, "", null, CancellationToken.None);
page.Items.Should().HaveCount(1);
page.Items.Single().Reason.Should().Be("Open one");
page.Items.Single().Status.Should().Be("Open");
page.PageSize.Should().Be(12);
}
[Fact]
public async Task GetFollowUpDetailAsync_MapsRelatedNamesAndResolvesCreator()
{
using var ctx = NewContext();
var employee = new Employee { FirstName = "Sara", LastName = "Lee" };
ctx.Employees.Add(employee);
ctx.SaveChanges();
var account = new Accounts { Name = "Acme" };
ctx.Accounts.Add(account);
ctx.SaveChanges();
var user = new ApplicationUser { Id = "user-1", FirstName = "Creator", LastName = "One", UserName = "cone" };
var fu = new FollowUps
{
Reason = "Detail",
ScheduleDate = new DateTime(2026, 8, 2),
ScheduleStartTime = new TimeSpan(8, 0, 0),
Status = "Open",
EmployeeId = employee.Id,
AccountId = account.Id,
createdby = "user-1",
CreatedDate = DateTime.Now
};
ctx.Users.Add(user);
ctx.FollowUps.Add(fu);
ctx.SaveChanges();
var detail = await NewService(ctx).GetFollowUpDetailAsync(fu.Id, CancellationToken.None);
detail.Should().NotBeNull();
detail!.AssignedTo.Should().Be("Sara Lee");
detail.AccountName.Should().Be("Acme");
detail.CreatedById.Should().Be("user-1");
detail.CreatedByName.Should().Be("Creator One");
}
[Fact]
public async Task UpdateFollowUpFieldsAsync_UpdatesAndThrowsWhenMissing()
{
using var ctx = NewContext();
var existing = SeedFollowUp(ctx, "Old reason");
await NewService(ctx).UpdateFollowUpFieldsAsync(existing.Id, new FollowUpUpdateRequestDTO
{
Id = existing.Id,
Reason = "New reason",
ScheduleDate = new DateTime(2026, 10, 1),
ScheduleStartTime = new TimeSpan(11, 0, 0)
}, CancellationToken.None);
var row = ctx.FollowUps.Single();
row.Reason.Should().Be("New reason");
var act = () => NewService(ctx).UpdateFollowUpFieldsAsync(existing.Id + 999, new FollowUpUpdateRequestDTO { Id = existing.Id + 999 }, CancellationToken.None);
await act.Should().ThrowAsync<KeyNotFoundException>();
}
[Fact]
public async Task SetFollowUpStatusAsync_TogglesStatusAndReturnsFalseWhenMissing()
{
using var ctx = NewContext();
var existing = SeedFollowUp(ctx, "To complete");
(await NewService(ctx).SetFollowUpStatusAsync(existing.Id, "Completed", CancellationToken.None)).Should().BeTrue();
ctx.FollowUps.Single().Status.Should().Be("Completed");
(await NewService(ctx).SetFollowUpStatusAsync(existing.Id + 999, "Completed", CancellationToken.None)).Should().BeFalse();
}
[Fact]
public async Task RemoveFollowUpAsync_RemovesAndReturnsFalseWhenMissing()
{
using var ctx = NewContext();
var existing = SeedFollowUp(ctx, "Gone");
(await NewService(ctx).RemoveFollowUpAsync(existing.Id, CancellationToken.None)).Should().BeTrue();
(await NewService(ctx).RemoveFollowUpAsync(existing.Id, CancellationToken.None)).Should().BeFalse();
ctx.FollowUps.Should().BeEmpty();
}
}

View file

@ -1,42 +0,0 @@
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
namespace Api.SeaHavenIndustries.Tests;
internal static class IdentityTestHelpers
{
public static (UserManager<ApplicationUser> manager, Mock<IUserRoleStore<ApplicationUser>> store, Mock<IPasswordHasher<ApplicationUser>> hasher) CreateUserManager()
{
var store = new Mock<IUserRoleStore<ApplicationUser>>();
var hasher = new Mock<IPasswordHasher<ApplicationUser>>();
store.As<IUserPasswordStore<ApplicationUser>>();
var manager = new UserManager<ApplicationUser>(
store.Object,
Microsoft.Extensions.Options.Options.Create(new IdentityOptions()),
hasher.Object,
new List<IUserValidator<ApplicationUser>>(),
new List<IPasswordValidator<ApplicationUser>>(),
new Mock<ILookupNormalizer>().Object,
new IdentityErrorDescriber(),
new Mock<IServiceProvider>().Object,
new Mock<ILogger<UserManager<ApplicationUser>>>().Object);
return (manager, store, hasher);
}
public static ApplicationUser User(string id = "u1", string? userName = "alice", bool? isDeleted = false) => new()
{
Id = id,
UserName = userName,
Email = userName + "@example.com",
FirstName = "Alice",
LastName = "Q",
PhoneNumber = "555",
IsDeleted = isDeleted
};
}

View file

@ -4,7 +4,6 @@ using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
@ -32,7 +31,7 @@ public class LocationControllerSitesTests
Mock.Of<ICreateLocationValidation>(),
Mock.Of<IUpdateLocationValidation>());
var controller = new LocationController(service, Mock.Of<ILogger<LocationController>>())
var controller = new LocationController(service, dataService)
{
ControllerContext = new ControllerContext
{

View file

@ -1,105 +0,0 @@
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using FluentAssertions;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class LocationControllerTests
{
private static LocationController NewController(Mock<ILocationService> service) =>
new(service.Object, Mock.Of<ILogger<LocationController>>());
private static object Prop(object source, string name) =>
source.GetType().GetProperty(name)!.GetValue(source)!;
[Fact]
public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", It.IsAny<CancellationToken>()))
.ReturnsAsync(new PagedResult<LocationDTO>
{
Items = new List<LocationDTO> { new() { Id = 1, Name = "Site" } },
TotalCount = 1,
Page = 1,
PageSize = 10
});
var result = await NewController(service).GetLocationList("a", 1, 10, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
envelope.PageNumber.Should().Be(1);
envelope.PageSize.Should().Be(10);
envelope.TotalCount.Should().Be(1);
envelope.TotalPages.Should().Be(1);
}
[Fact]
public async Task GetLocationById_WhenMissing_ReturnsNotFoundAndDoesNotLeakEntity()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationDetailAsync(5, It.IsAny<CancellationToken>()))
.ReturnsAsync((LocationDTO?)null);
var result = await NewController(service).GetLocationById(5, CancellationToken.None);
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
var response = notFound.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be("Location not found");
}
[Fact]
public async Task GetLocationById_WhenFound_MapsServiceDtoToAliasedShape()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetLocationDetailAsync(7, It.IsAny<CancellationToken>()))
.ReturnsAsync(new LocationDTO { Id = 7, Name = "N", Address1 = "A", Zip = "Z", PhoneNumber = "P", Email = "E", Status = "Active" });
var result = await NewController(service).GetLocationById(7, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var body = ok.Value!;
Prop(body, "Id").Should().Be(7);
Prop(body, "Name").Should().Be("N");
Prop(body, "Address").Should().Be("A");
Prop(body, "ZipCode").Should().Be("Z");
Prop(body, "Phone").Should().Be("P");
Prop(body, "ContactEmail").Should().Be("E");
Prop(body, "Status").Should().Be("Active");
}
[Fact]
public async Task AddLocation_WhenServiceSucceeds_ReturnsCreatedResponse()
{
var service = new Mock<ILocationService>();
var result = await NewController(service).AddLocation(new Location_DTO { Name = "X" }, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
response.Status.Should().Be("200");
response.Message.Should().Be("Location Created Successfully");
service.Verify(s => s.CreateLocationFromRequestAsync(It.IsAny<LocationCreateRequestDTO>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task EditLocation_WhenMissing_ReturnsNotFound()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.UpdateLocationFromRequestAsync(99, It.IsAny<LocationUpdateRequestDTO>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException());
var result = await NewController(service).EditLocation(99, new EditLocation_DTO { Name = "X" }, CancellationToken.None);
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
notFound.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Location not found");
}
}

View file

@ -1,133 +0,0 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class LocationServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static LocationService NewService(ApplicationDbContext ctx) =>
new(new LocationDataService(ctx), new CreateLocationValidation(), new UpdateLocationValidation());
private static Locations SeedLocation(ApplicationDbContext ctx, string name, string? city = null, string? status = "Active")
{
var loc = new Locations { Name = name, City = city, Status = status, CreatedDate = DateTime.Now };
ctx.Locations.Add(loc);
ctx.SaveChanges();
return loc;
}
[Fact]
public async Task CreateLocationFromRequestAsync_PersistsMappedFields()
{
using var ctx = NewContext();
var service = NewService(ctx);
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Warehouse",
Title = "Main WH",
Address = "1 Depot Rd",
City = "Austin",
State = "TX",
ZipCode = "73301",
Phone = "555-1000",
ContactEmail = "wh@example.com",
Status = "Active"
}, CancellationToken.None);
var entity = ctx.Locations.Single();
entity.Name.Should().Be("Warehouse");
entity.Title.Should().Be("Main WH");
entity.Address1.Should().Be("1 Depot Rd");
entity.City.Should().Be("Austin");
entity.State.Should().Be("TX");
entity.Zip.Should().Be("73301");
entity.PhoneNumber.Should().Be("555-1000");
entity.Email.Should().Be("wh@example.com");
entity.Status.Should().Be("Active");
}
[Fact]
public async Task GetLocationListPagedAsync_FiltersBySearchAndMapsShape()
{
using var ctx = NewContext();
SeedLocation(ctx, "Alpha Site", "Austin");
SeedLocation(ctx, "Beta Site", "Dallas");
SeedLocation(ctx, "Gamma Yard", "Austin");
var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, "Austin", CancellationToken.None);
page.Items.Should().HaveCount(2);
page.TotalCount.Should().Be(2);
page.Items.All(l => l.City == "Austin").Should().BeTrue();
page.Items.Select(l => l.Name).Should().NotBeNull();
}
[Fact]
public async Task GetLocationDetailAsync_ReturnsMappedDtoOrNull()
{
using var ctx = NewContext();
var existing = SeedLocation(ctx, "Depot", "Houston");
var service = NewService(ctx);
var found = await service.GetLocationDetailAsync(existing.Id, CancellationToken.None);
var missing = await service.GetLocationDetailAsync(existing.Id + 999, CancellationToken.None);
missing.Should().BeNull();
found.Should().NotBeNull();
found!.Name.Should().Be("Depot");
found.City.Should().Be("Houston");
}
[Fact]
public async Task UpdateLocationFromRequestAsync_UpdatesFieldsAndThrowsWhenMissing()
{
using var ctx = NewContext();
var existing = SeedLocation(ctx, "Old", "Round Rock");
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
{
Name = "New",
Address = "9 New St",
City = "Plano",
Status = "Inactive"
}, CancellationToken.None);
var row = ctx.Locations.Single();
row.Name.Should().Be("New");
row.Address1.Should().Be("9 New St");
row.City.Should().Be("Plano");
row.Status.Should().Be("Inactive");
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(existing.Id + 999, new LocationUpdateRequestDTO { Name = "X" }, CancellationToken.None);
await act.Should().ThrowAsync<KeyNotFoundException>();
}
[Fact]
public async Task DeleteLocationByIdAsync_RemovesAndReturnsFalseWhenMissing()
{
using var ctx = NewContext();
var existing = SeedLocation(ctx, "Gone", "Cedar Park");
var removed = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None);
var again = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None);
removed.Should().BeTrue();
again.Should().BeFalse();
ctx.Locations.Should().BeEmpty();
}
}

View file

@ -1,71 +0,0 @@
using Api.SeaHavenIndustries.Helper;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class SanitizedErrorsTests
{
[Fact]
public void Sanitize_ReturnsStableMessageAndNeverExposesExceptionText()
{
var logger = new Mock<ILogger>();
logger.Setup(x => x.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
var message = logger.Object.Sanitize(new InvalidOperationException("SUPER-SECRET-internal-detail"));
message.Should().NotContain("SUPER-SECRET");
message.Should().NotContain("internal-detail");
message.Should().StartWith("An unexpected error occurred");
message.Should().Contain("reference");
}
[Fact]
public void Sanitize_UsesProvidedPublicMessageInsteadOfExceptionText()
{
var logger = new Mock<ILogger>();
logger.Setup(x => x.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
var message = logger.Object.Sanitize(
new InvalidOperationException("SUPER-SECRET-internal-detail"),
"Vendor not found");
message.Should().Contain("Vendor not found");
message.Should().NotContain("SUPER-SECRET");
message.Should().Contain("reference");
}
[Fact]
public void Sanitize_LogsOriginalExceptionAtErrorLevel()
{
var logger = new Mock<ILogger>();
logger.Setup(x => x.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
var exception = new InvalidOperationException("SUPER-SECRET-internal-detail");
logger.Object.Sanitize(exception);
logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.Is<Exception>(ex => ex == exception),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public void Sanitize_ProducesUniqueCorrelationReferencesAcrossCalls()
{
var logger = new Mock<ILogger>();
logger.Setup(x => x.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
var exception = new InvalidOperationException("detail");
var first = logger.Object.Sanitize(exception);
var second = logger.Object.Sanitize(exception);
first.Should().NotBe(second);
}
}

View file

@ -1,281 +0,0 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
internal static class TestAsyncEnumerableHelper
{
public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this IEnumerable<T> source)
{
foreach (var item in source)
yield return item;
await Task.CompletedTask;
}
}
public class SyncServiceBehaviorTests
{
[Fact]
public async Task SyncWorkOrders_CreatesNewAndUpdatesExisting()
{
var data = new Mock<ISyncDataService>();
var external = new Mock<ISyncExternalSource>();
var existingWO = new WorkOrder { Id = 5, ExternalWorkOrderId = "EXT-1", WorkerOrderTitle = "Old" };
data.Setup(d => d.GetLastInternalWONumberAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync("10000005");
data.Setup(d => d.GetWorkOrderByExternalIdAsync("EXT-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(existingWO);
data.Setup(d => d.GetWorkOrderByExternalIdAsync("EXT-2", It.IsAny<CancellationToken>()))
.ReturnsAsync((WorkOrder?)null);
data.Setup(d => d.ResolveLocationAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((int?)null);
data.Setup(d => d.GetLocationCountAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(3);
var items = new List<Dictionary<string, string?>>
{
new() { ["work_order_id"] = "EXT-1", ["description"] = "Updated Title", ["wo_status"] = "in_progress" },
new() { ["work_order_id"] = "EXT-2", ["description"] = "New WO", ["wo_status"] = "new", ["severity"] = "3" }
};
external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny<CancellationToken>()))
.Returns(items.ToAsyncEnumerable());
var service = new SyncService(data.Object, external.Object);
var result = await service.SyncWorkOrdersAsync(CancellationToken.None);
Assert.Equal(2, result.Synced);
Assert.Equal(1, result.Created);
Assert.Equal(1, result.Updated);
Assert.Equal(3, result.LocationsCreated);
Assert.Equal("Updated Title", existingWO.WorkerOrderTitle);
Assert.Equal("In Progress", existingWO.Status);
data.Verify(d => d.EnqueueWorkOrder(It.Is<WorkOrder>(w => w.ExternalWorkOrderId == "EXT-2")), Times.Once);
}
[Fact]
public async Task SyncWorkOrders_MapsStatusAndSeverity()
{
var data = new Mock<ISyncDataService>();
var external = new Mock<ISyncExternalSource>();
data.Setup(d => d.GetLastInternalWONumberAsync(It.IsAny<CancellationToken>())).ReturnsAsync((string?)null);
data.Setup(d => d.GetWorkOrderByExternalIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((WorkOrder?)null);
data.Setup(d => d.ResolveLocationAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync((int?)null);
data.Setup(d => d.GetLocationCountAsync(It.IsAny<CancellationToken>())).ReturnsAsync(0);
var items = new List<Dictionary<string, string?>>
{
new() { ["work_order_id"] = "W1", ["wo_status"] = "completed", ["severity"] = "2" }
};
external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny<CancellationToken>())).Returns(items.ToAsyncEnumerable());
await new SyncService(data.Object, external.Object).SyncWorkOrdersAsync(CancellationToken.None);
data.Verify(d => d.EnqueueWorkOrder(It.Is<WorkOrder>(w => w.Status == "Done" && w.Priority == "Sev 2" && w.Severity == "2")), Times.Once);
}
[Fact]
public async Task SyncComments_SkipsExistingAndOrphans()
{
var data = new Mock<ISyncDataService>();
var external = new Mock<ISyncExternalSource>();
var wo = new WorkOrder { Id = 10, ExternalWorkOrderId = "WO-1" };
data.Setup(d => d.CommentExistsByExternalIdAsync("C-EXISTS", It.IsAny<CancellationToken>())).ReturnsAsync(true);
data.Setup(d => d.CommentExistsByExternalIdAsync("C-NEW", It.IsAny<CancellationToken>())).ReturnsAsync(false);
data.Setup(d => d.CommentExistsByExternalIdAsync("C-ORPHAN", It.IsAny<CancellationToken>())).ReturnsAsync(false);
data.Setup(d => d.GetWorkOrderByExternalIdAsync("WO-1", It.IsAny<CancellationToken>())).ReturnsAsync(wo);
data.Setup(d => d.GetWorkOrderByExternalIdAsync("WO-MISSING", It.IsAny<CancellationToken>())).ReturnsAsync((WorkOrder?)null);
var items = new List<Dictionary<string, string?>>
{
new() { ["comment_id"] = "C-EXISTS", ["work_order_id"] = "WO-1" },
new() { ["comment_id"] = "C-NEW", ["work_order_id"] = "WO-1", ["text"] = "Hello" },
new() { ["comment_id"] = "C-ORPHAN", ["work_order_id"] = "WO-MISSING" }
};
external.Setup(e => e.ScanCommentsAsync(It.IsAny<CancellationToken>())).Returns(items.ToAsyncEnumerable());
var result = await new SyncService(data.Object, external.Object).SyncCommentsAsync(CancellationToken.None);
Assert.Equal(2, result.Synced);
Assert.Equal(1, result.Created);
Assert.Equal(2, result.Skipped);
data.Verify(d => d.EnqueueComment(It.Is<Comments>(c => c.ExternalCommentId == "C-NEW")), Times.Once);
}
}
public class WorkOrderDispatchServiceBehaviorTests
{
[Fact]
public async Task VerifyDispatch_ThrowsWhenChecklistIncomplete()
{
var dispatchData = CreateVerifyDispatchMocks(
out var dispatch,
incompleteCount: 1,
checklistComplete: false,
hasCustomerSignoff: true,
hasVendorSignoff: true);
dispatch.Id = 1;
var service = CreateDispatchService(dispatchData);
var ex = await Assert.ThrowsAsync<DispatchVerificationException>(() => service.VerifyDispatchAsync(1, "user1"));
Assert.Contains("1 checklist item(s) incomplete", ex.Missing);
}
[Fact]
public async Task VerifyDispatch_ThrowsWhenVendorSignoffMissing()
{
var dispatchData = CreateVerifyDispatchMocks(
out var dispatch,
incompleteCount: 0,
checklistComplete: true,
hasCustomerSignoff: true,
hasVendorSignoff: false);
dispatch.Id = 1;
var service = CreateDispatchService(dispatchData);
var ex = await Assert.ThrowsAsync<DispatchVerificationException>(() => service.VerifyDispatchAsync(1, "user1"));
Assert.Contains("Missing Vendor sign-off", ex.Missing);
}
[Fact]
public async Task VerifyDispatch_SucceedsWhenAllComplete()
{
var dispatchData = CreateVerifyDispatchMocks(
out var dispatch,
incompleteCount: 0,
checklistComplete: true,
hasCustomerSignoff: true,
hasVendorSignoff: true);
dispatch.Id = 1;
var userMock = new Mock<IUserDataService>();
userMock.Setup(u => u.GetByIdAsync("user1")).ReturnsAsync(new ApplicationUser { FirstName = "Jane", LastName = "Doe" });
var service = CreateDispatchService(dispatchData, userMock: userMock);
var result = await service.VerifyDispatchAsync(1, "user1");
Assert.NotNull(result);
Assert.Equal("Dispatch verified", result!.Message);
Assert.Equal("Verified", dispatch.Status);
Assert.Equal("Jane Doe", dispatch.VerifiedBy);
}
private static Mock<IDispatchDataService> CreateVerifyDispatchMocks(
out Dispatch dispatch,
int incompleteCount,
bool checklistComplete,
bool hasCustomerSignoff,
bool hasVendorSignoff)
{
dispatch = new Dispatch { Id = 1, DispatchNumber = "DSP-00001", WorkOrderId = 10 };
var mock = new Mock<IDispatchDataService>();
mock.Setup(d => d.GetByIdAsync(1)).ReturnsAsync(dispatch);
var checklist = checklistComplete
? new List<DispatchChecklistItem> { new() { IsCompleted = true } }
: new List<DispatchChecklistItem> { new() { IsCompleted = false } };
mock.Setup(d => d.GetChecklistItemsAsync(1)).ReturnsAsync(checklist);
var signoffs = new List<DispatchSignoff>();
if (hasCustomerSignoff) signoffs.Add(new DispatchSignoff { SignoffType = "Customer" });
if (hasVendorSignoff) signoffs.Add(new DispatchSignoff { SignoffType = "Vendor" });
mock.Setup(d => d.GetSignoffsAsync(1)).ReturnsAsync(signoffs);
return mock;
}
private static WorkOrderDispatchService CreateDispatchService(
Mock<IDispatchDataService>? dispatchDataMock = null,
Mock<IUserDataService>? userMock = null)
{
return new WorkOrderDispatchService(
Mock.Of<IWorkOrderDataService>(),
(dispatchDataMock ?? new Mock<IDispatchDataService>()).Object,
Mock.Of<IVendorDataService>(),
(userMock ?? new Mock<IUserDataService>()).Object,
Mock.Of<ITaskListTemplateDataService>(),
Mock.Of<ICommentDataService>(),
Mock.Of<IDispatchEmailPort>(),
Mock.Of<IVendorTokenPort>(),
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()));
}
}
public class WorkOrderServiceBehaviorTests
{
[Fact]
public async Task ChangeStatus_AddsAuditWhenStatusDiffers()
{
var wo = new WorkOrder { Id = 5, Status = "Open" };
var woData = new Mock<IWorkOrderDataService>();
woData.Setup(d => d.GetByIdAsync(5)).ReturnsAsync(wo);
var service = CreateWorkOrderService(woData);
var result = await service.ChangeStatusAsync(5, "In Progress", "user1");
Assert.NotNull(result);
Assert.Equal("In Progress", wo.Status);
woData.Verify(d => d.AddAuditLogAsync(It.Is<WorkOrderAuditLog>(a =>
a.FieldName == "Status" && a.OldValue == "Open" && a.NewValue == "In Progress")), Times.Once);
}
[Fact]
public async Task ChangeStatus_DoesNotAuditWhenStatusSame()
{
var wo = new WorkOrder { Id = 5, Status = "Open" };
var woData = new Mock<IWorkOrderDataService>();
woData.Setup(d => d.GetByIdAsync(5)).ReturnsAsync(wo);
await CreateWorkOrderService(woData).ChangeStatusAsync(5, "Open", "user1");
woData.Verify(d => d.AddAuditLogAsync(It.IsAny<WorkOrderAuditLog>()), Times.Never);
}
[Fact]
public async Task DeleteWorkOrderCascade_UnlinksQuotesAndDeletesComments()
{
var woData = new Mock<IWorkOrderDataService>();
woData.Setup(d => d.ExistsAsync(5)).ReturnsAsync(true);
var commentData = new Mock<ICommentDataService>();
commentData.Setup(c => c.GetByWorkOrderIdAsync(5))
.ReturnsAsync(new List<Comments> { new() { Id = 1 }, new() { Id = 2 } });
var quotesData = new Mock<IQuotesDataService>();
var result = await CreateWorkOrderService(woData, commentData, quotesData).DeleteWorkOrderCascadeAsync(5, "user1");
Assert.True(result);
quotesData.Verify(q => q.UnlinkFromWorkOrderAsync(5), Times.Once);
commentData.Verify(c => c.DeleteAsync(1), Times.Once);
commentData.Verify(c => c.DeleteAsync(2), Times.Once);
}
private static WorkOrderService CreateWorkOrderService(
Mock<IWorkOrderDataService>? woData = null,
Mock<ICommentDataService>? commentData = null,
Mock<IQuotesDataService>? quotesData = null)
{
return new WorkOrderService(
(woData ?? new Mock<IWorkOrderDataService>()).Object,
(commentData ?? new Mock<ICommentDataService>()).Object,
Mock.Of<IUserDataService>(),
(quotesData ?? new Mock<IQuotesDataService>()).Object,
Mock.Of<IFileStoragePort>(),
Mock.Of<ICreateWorkOrderValidation>(),
Mock.Of<IUpdateWorkOrderValidation>());
}
}

View file

@ -1,75 +0,0 @@
using System.Reflection;
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SeaHaven.DataServices.DependencyInjection;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DependencyInjection;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class ServiceRegistrationTests
{
private static IServiceCollection BuildConventionServices()
{
var configuration = new ConfigurationBuilder().Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IConfiguration>(configuration);
services.AddDataServices();
services.AddBusinessServices(configuration);
return services;
}
private static IEnumerable<(Type Implementation, Type Interface)> ConventionCandidates(Assembly assembly)
{
return assembly.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract && !t.IsGenericType)
.Select(t => (
Implementation: t,
Interface: t.GetInterfaces().FirstOrDefault(i => i.Name == $"I{t.Name}")))
.Where(pair => pair.Interface is not null)!;
}
private static void AssertScopedConventionRegistrations(
IServiceCollection services,
IEnumerable<(Type Implementation, Type Interface)> candidates,
string assemblyLabel)
{
var candidateList = candidates.ToList();
candidateList.Should().NotBeEmpty(
"expected at least one convention candidate in {0}", assemblyLabel);
var missing = candidateList
.Where(pair => !services.Any(d =>
d.Lifetime == ServiceLifetime.Scoped &&
d.ServiceType == pair.Interface &&
d.ImplementationType == pair.Implementation))
.ToList();
missing.Should().BeEmpty(
"expected a scoped descriptor mapping each convention candidate interface to its implementation in {0}, missing: {1}",
assemblyLabel,
string.Join("; ", missing.Select(p => $"{p.Interface!.FullName} -> {p.Implementation.FullName}")));
}
[Fact]
public void ConventionDi_RegistersEveryServicesImplementationClassAsScoped()
{
var services = BuildConventionServices();
var candidates = ConventionCandidates(typeof(WorkOrderDispatchService).Assembly);
AssertScopedConventionRegistrations(services, candidates, "SeaHaven.Services");
}
[Fact]
public void ConventionDi_RegistersEveryDataServicesImplementationClassAsScoped()
{
var services = BuildConventionServices();
var candidates = ConventionCandidates(typeof(VendorDataService).Assembly);
AssertScopedConventionRegistrations(services, candidates, "SeaHaven.DataServices");
}
}

View file

@ -1,120 +0,0 @@
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper.ViewModel;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class SettingsControllerTests
{
private static SettingsController NewController(Mock<ISettingsService> service) =>
new(service.Object, Mock.Of<ILogger<SettingsController>>());
[Fact]
public async Task GetCategoryList_ReturnsPaginationEnvelopeWithServiceItems()
{
var service = new Mock<ISettingsService>();
service.Setup(s => s.GetCategoryListAsync(1, 10, It.IsAny<CancellationToken>()))
.ReturnsAsync((new List<SettingsCategoryDTO> { new() { Id = 1, Name = "A" } }, 1));
var result = await NewController(service).GetCategoryList(1, 10, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
envelope.TotalCount.Should().Be(1);
envelope.TotalPages.Should().Be(1);
}
[Fact]
public async Task GetTemplates_ReturnsTypedServiceDtosInEnvelope()
{
var service = new Mock<ISettingsService>();
service.Setup(s => s.GetTemplatesAsync(1, 10, It.IsAny<CancellationToken>()))
.ReturnsAsync((new List<SettingsTemplateDTO> { new() { Id = 1, WorkerOrderTitle = "T" } }, 1));
var result = await NewController(service).GetTemplates(1, 10, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
envelope.TotalCount.Should().Be(1);
}
[Fact]
public async Task AddCategory_WhenServiceSucceeds_ReturnsSaved()
{
var service = new Mock<ISettingsService>();
var result = await NewController(service).AddCategory(new Category_DTO { Name = "X" }, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
response.Status.Should().Be("200");
response.Message.Should().Be("Successfully Saved Data");
service.Verify(s => s.AddCategoryAsync("X", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task EditCategory_WhenMissing_ReturnsBadRequest()
{
var service = new Mock<ISettingsService>();
service.Setup(s => s.UpdateCategoryAsync(9, "X", It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var result = await NewController(service).EditCategory(new EditCategory_DTO { Id = 9, Name = "X" }, CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
bad.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Category not found");
}
[Fact]
public async Task DeleteCategory_WhenMissing_ReturnsIdNotMatched()
{
var service = new Mock<ISettingsService>();
service.Setup(s => s.DeleteCategoryAsync(9, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var result = await NewController(service).DeleteCategory(9, CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
bad.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("ID not matched!");
}
[Fact]
public async Task GetUsers_MapsServiceUsersToUserRoleViewModel()
{
var service = new Mock<ISettingsService>();
service.Setup(s => s.GetUsersAsync("", 1, 10, It.IsAny<CancellationToken>()))
.ReturnsAsync((new List<SettingsUserDTO>
{
new() { Id = "u1", UserName = "Sara", Email = "s@x.com", RoleName = "User", Status = "Active", Date = "2026-01-01" }
}, 1));
var result = await NewController(service).GetUsers("", 1, 10, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
object data = envelope.Data!;
var rows = ((System.Collections.IEnumerable)data).Cast<UserRoleViewModel>().ToList();
rows.Should().ContainSingle(u => u.UserName == "Sara" && u.RoleName == "User" && u.Email == "s@x.com");
}
[Fact]
public async Task GetRoles_MapsServiceRolesToRolesDto()
{
var service = new Mock<ISettingsService>();
service.Setup(s => s.GetRolesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<RoleDTO> { new() { Name = "Admin", Description = "Full Access" } });
var result = await NewController(service).GetRoles(CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var roles = ((System.Collections.IEnumerable)ok.Value!).Cast<Roles_DTO>().ToList();
roles.Should().ContainSingle(r => r.Name == "Admin" && r.Description == "Full Access");
}
}

View file

@ -1,168 +0,0 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class SettingsServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static SettingsService NewService(ApplicationDbContext ctx) =>
new(new SettingsDataService(ctx), null!);
private static (ApplicationDbContext ctx, UserManager<ApplicationUser> userManager, RoleManager<IdentityRole> roleManager) NewIdentityContext()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddDbContext<ApplicationDbContext>(o => o.UseInMemoryDatabase(Guid.NewGuid().ToString()));
services.AddIdentity<ApplicationUser, IdentityRole>().AddEntityFrameworkStores<ApplicationDbContext>();
var provider = services.BuildServiceProvider();
var scope = provider.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
return (ctx, userManager, roleManager);
}
[Fact]
public async Task AddCategoryAsync_PersistsName()
{
using var ctx = NewContext();
await NewService(ctx).AddCategoryAsync("Plumbing", CancellationToken.None);
ctx.Categories.Single().Name.Should().Be("Plumbing");
}
[Fact]
public async Task UpdateCategoryAsync_UpdatesNameAndReturnsFalseWhenMissing()
{
using var ctx = NewContext();
ctx.Categories.Add(new Category { Name = "Old" });
ctx.SaveChanges();
var service = NewService(ctx);
(await service.UpdateCategoryAsync(1, "New", CancellationToken.None)).Should().BeTrue();
ctx.Categories.Single().Name.Should().Be("New");
(await service.UpdateCategoryAsync(999, "X", CancellationToken.None)).Should().BeFalse();
}
[Fact]
public async Task DeleteCategoryAsync_CascadesTemplatesAndWorkOrderCategories()
{
using var ctx = NewContext();
ctx.Categories.Add(new Category { Id = 1, Name = "C1" });
ctx.Templates.Add(new Template { CategoryId = 1, WorkerOrderTitle = "T" });
ctx.workOrderCategories.Add(new WorkOrderCategories { CategoryId = 1 });
ctx.SaveChanges();
var service = NewService(ctx);
(await service.DeleteCategoryAsync(1, CancellationToken.None)).Should().BeTrue();
(await service.DeleteCategoryAsync(1, CancellationToken.None)).Should().BeFalse();
ctx.Categories.Should().BeEmpty();
ctx.Templates.Should().BeEmpty();
ctx.workOrderCategories.Should().BeEmpty();
}
[Fact]
public async Task GetCategoryListAsync_MapsToServiceDtoAndPages()
{
using var ctx = NewContext();
ctx.Categories.Add(new Category { Name = "A" });
ctx.Categories.Add(new Category { Name = "B" });
ctx.SaveChanges();
var (items, total) = await NewService(ctx).GetCategoryListAsync(1, 10, CancellationToken.None);
total.Should().Be(2);
items.Should().OnlyContain(c => c is SettingsCategoryDTO);
items.Select(c => c.Name).Should().Contain(new[] { "A", "B" });
}
[Fact]
public async Task GetTemplatesAsync_MapsWorkOrderScalarsToTypedDto()
{
using var ctx = NewContext();
var wo = new WorkOrder
{
istemplate = true,
InternalWONumber = "WO-100",
WorkerOrderTitle = "Template A",
Status = "Active",
Priority = "High",
Trade = "HVAC",
VendorNTE = 1500m,
Customer = "Cust"
};
ctx.workOrders.Add(wo);
ctx.SaveChanges();
var (items, total) = await NewService(ctx).GetTemplatesAsync(1, 10, CancellationToken.None);
total.Should().Be(1);
var dto = items.Single();
dto.Should().BeOfType<SettingsTemplateDTO>();
dto.InternalWONumber.Should().Be("WO-100");
dto.WorkerOrderTitle.Should().Be("Template A");
dto.Status.Should().Be("Active");
dto.Priority.Should().Be("High");
dto.Trade.Should().Be("HVAC");
dto.VendorNTE.Should().Be(1500m);
dto.Customer.Should().Be("Cust");
dto.istemplate.Should().BeTrue();
}
[Fact]
public async Task GetUsersAsync_ExcludesAdminsAndResolvesRole()
{
var (ctx, userManager, roleManager) = NewIdentityContext();
var t1 = roleManager.CreateAsync(new IdentityRole("Admin"));
var t2 = roleManager.CreateAsync(new IdentityRole("User"));
await Task.WhenAll(t1, t2);
var admin = new ApplicationUser { UserName = "admin1", Email = "a@x.com", FirstName = "Boss" };
var worker = new ApplicationUser { UserName = "worker1", Email = "w@x.com", FirstName = "Hand" };
await userManager.CreateAsync(admin);
await userManager.CreateAsync(worker);
await userManager.AddToRoleAsync(admin, "Admin");
await userManager.AddToRoleAsync(worker, "User");
ctx.SaveChanges();
var service = new SettingsService(new SettingsDataService(ctx), userManager);
var (users, total) = await service.GetUsersAsync("", 1, 10, CancellationToken.None);
total.Should().BeGreaterThanOrEqualTo(2);
users.Should().NotContain(u => u.RoleName == "Admin");
users.Should().Contain(u => u.UserName == "Hand" && u.RoleName == "User");
}
[Fact]
public async Task GetRolesAsync_MapsKnownRoleNamesToDescriptions()
{
using var ctx = NewContext();
ctx.Roles.Add(new IdentityRole("Admin"));
ctx.Roles.Add(new IdentityRole("Manager"));
ctx.Roles.Add(new IdentityRole("Unknown"));
ctx.SaveChanges();
var roles = await NewService(ctx).GetRolesAsync(CancellationToken.None);
var byName = roles.ToDictionary(r => r.Name!, r => r.Description);
byName["Admin"].Should().Be("Full Access");
byName["Manager"].Should().Be("Full access accept quotes");
roles.Should().NotContain(r => r.Name == "Unknown");
}
}

View file

@ -1,146 +0,0 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class TaskListTemplateServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static TaskListTemplateService NewService(ApplicationDbContext ctx) =>
new(new TaskListTemplateDataService(ctx));
private static TaskListTemplate Template(string name, bool active, params (string text, int sort)[] items)
{
var t = new TaskListTemplate { Name = name, IsActive = active };
t.Items = items.Select(i => new TaskListTemplateItem { ItemText = i.text, SortOrder = i.sort }).ToList();
return t;
}
[Fact]
public async Task GetAllAsync_ReturnsOnlyActiveOrderedByNameWithOrderedItems()
{
using var ctx = NewContext();
ctx.TaskListTemplates.AddRange(
Template("Zeta", true, ("b", 2), ("a", 1)),
Template("Alpha", true, ("first", 0)),
Template("Hidden", false)
);
ctx.SaveChanges();
var result = (await NewService(ctx).GetAllAsync(CancellationToken.None)).ToList();
result.Select(t => t.Name).Should().ContainInOrder("Alpha", "Zeta");
result.Single(t => t.Name == "Zeta").Items.Select(i => i.ItemText).Should().ContainInOrder("a", "b");
}
[Fact]
public async Task GetByIdAsync_WhenMissing_ReturnsNull()
{
using var ctx = NewContext();
var result = await NewService(ctx).GetByIdAsync(99, CancellationToken.None);
result.Should().BeNull();
}
[Fact]
public async Task GetByIdAsync_ReturnsTemplateWithOrderedItems()
{
using var ctx = NewContext();
var t = Template("Alpha", true, ("b", 2), ("a", 1));
ctx.TaskListTemplates.Add(t);
ctx.SaveChanges();
var result = await NewService(ctx).GetByIdAsync(t.Id, CancellationToken.None);
result.Should().NotBeNull();
result!.Items.Select(i => i.ItemText).Should().ContainInOrder("a", "b");
}
[Fact]
public async Task CreateAsync_PersistsTemplateAndItemsAndReturnsId()
{
using var ctx = NewContext();
var input = new TaskListTemplateInput
{
Name = "New",
Description = "desc",
IsActive = true,
Items = new()
{
new() { ItemText = "two", SortOrder = 0 },
new() { ItemText = "one", SortOrder = 0 }
}
};
var id = await NewService(ctx).CreateAsync(input, CancellationToken.None);
id.Should().BeGreaterThan(0);
var row = ctx.TaskListTemplates.Include(t => t.Items).Single();
row.Name.Should().Be("New");
row.Items.Should().HaveCount(2);
row.Items!.Select(i => i.SortOrder).Should().ContainInOrder(0, 1);
}
[Fact]
public async Task UpdateAsync_WhenNotFound_ReturnsFalse()
{
using var ctx = NewContext();
var result = await NewService(ctx).UpdateAsync(404, new TaskListTemplateInput { Name = "x" }, CancellationToken.None);
result.Should().BeFalse();
}
[Fact]
public async Task UpdateAsync_ReplacesItems()
{
using var ctx = NewContext();
var t = Template("Alpha", true, ("old", 0));
ctx.TaskListTemplates.Add(t);
ctx.SaveChanges();
var updated = await NewService(ctx).UpdateAsync(t.Id, new TaskListTemplateInput
{
Name = "Renamed",
IsActive = false,
Items = new() { new() { ItemText = "new1", SortOrder = 1 }, new() { ItemText = "new2", SortOrder = 2 } }
}, CancellationToken.None);
updated.Should().BeTrue();
var row = ctx.TaskListTemplates.Include(x => x.Items).Single();
row.Name.Should().Be("Renamed");
row.IsActive.Should().BeFalse();
row.Items!.Select(i => i.ItemText).Should().BeEquivalentTo(new[] { "new1", "new2" });
}
[Fact]
public async Task DeleteAsync_SoftDeletesByDeactivating()
{
using var ctx = NewContext();
var t = Template("Alpha", true);
ctx.TaskListTemplates.Add(t);
ctx.SaveChanges();
var result = await NewService(ctx).DeleteAsync(t.Id, CancellationToken.None);
result.Should().BeTrue();
ctx.TaskListTemplates.Single().IsActive.Should().BeFalse();
}
[Fact]
public async Task DeleteAsync_WhenNotFound_ReturnsFalse()
{
using var ctx = NewContext();
var result = await NewService(ctx).DeleteAsync(404, CancellationToken.None);
result.Should().BeFalse();
}
}

View file

@ -1,213 +0,0 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class UpliftControllerTests
{
private static UpliftController NewController(Mock<IUpliftService> service, params string[] roles)
{
var controller = new UpliftController(service.Object, Mock.Of<ILogger<UpliftController>>());
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, "42") };
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"))
}
};
return controller;
}
[Fact]
public async Task List_ReturnsPagedResult()
{
var service = new Mock<IUpliftService>();
var dto = new UpliftListResultDTO
{
Total = 1,
Page = 1,
PageSize = 25,
Items = new[] { new UpliftListItemDTO { Id = 5, Status = "Pending" } }
};
service.Setup(x => x.ListAsync(It.IsAny<ClaimsPrincipal>(), "Pending", null, 1, 25, It.IsAny<CancellationToken>()))
.ReturnsAsync(dto);
var controller = NewController(service);
var result = await controller.List();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
envelope.Status.Should().Be("Success");
}
[Fact]
public async Task Approve_NotFound_Returns404()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.ApproveAsync(It.IsAny<ClaimsPrincipal>(), 99, null, It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException("not found"));
var controller = NewController(service);
var result = await controller.Approve(99, null);
var nf = result.Should().BeOfType<NotFoundObjectResult>().Subject;
nf.Value.Should().BeOfType<Response>();
}
[Fact]
public async Task Approve_Forbidden_ReturnsSanitized403AndLogsInternally()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.ApproveAsync(It.IsAny<ClaimsPrincipal>(), 5, null, It.IsAny<CancellationToken>()))
.ThrowsAsync(new UpliftForbiddenException("SECRET-tier-restriction-detail"));
var logger = new Mock<ILogger<UpliftController>>();
logger.Setup(x => x.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
var controller = new UpliftController(service.Object, logger.Object);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "42") }, "Test"))
}
};
var result = await controller.Approve(5, null);
var status = result.Should().BeOfType<ObjectResult>().Subject;
status.StatusCode.Should().Be(403);
var response = status.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().NotContain("SECRET");
response.Message.Should().Contain("reference");
logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.Is<Exception>(ex => ex.Message.Contains("SECRET")),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task Approve_ForbiddenTier_Returns403()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.ApproveAsync(It.IsAny<ClaimsPrincipal>(), 5, null, It.IsAny<CancellationToken>()))
.ThrowsAsync(new UpliftForbiddenException("requires Tier 2"));
var controller = NewController(service);
var result = await controller.Approve(5, null);
var status = result.Should().BeOfType<ObjectResult>().Subject;
status.StatusCode.Should().Be(403);
}
[Fact]
public async Task Approve_AlreadyDecided_Returns400()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.ApproveAsync(It.IsAny<ClaimsPrincipal>(), 5, null, It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("already decided"));
var controller = NewController(service);
var result = await controller.Approve(5, null);
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task Approve_Success_ReturnsApprovedResult()
{
var service = new Mock<IUpliftService>();
var approved = new UpliftApproveResultDTO { Id = 5, Status = "Approved", NTEAmount = 750m };
service.Setup(x => x.ApproveAsync(It.IsAny<ClaimsPrincipal>(), 5, "ok", It.IsAny<CancellationToken>()))
.ReturnsAsync(approved);
var controller = NewController(service);
var result = await controller.Approve(5, new UpliftController.DecisionRequest { Note = "ok" });
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
var approveData = envelope.Data as UpliftApproveResultDTO;
approveData.Should().Be(approved);
}
[Fact]
public async Task Deny_WithoutNote_Returns400()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.DenyAsync(It.IsAny<ClaimsPrincipal>(), 5, null, It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("A note is required when denying"));
var controller = NewController(service);
var result = await controller.Deny(5, null);
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task Deny_Success_ReturnsDeniedResult()
{
var service = new Mock<IUpliftService>();
var denied = new UpliftDenyResultDTO { Id = 5, Status = "Denied" };
service.Setup(x => x.DenyAsync(It.IsAny<ClaimsPrincipal>(), 5, "too high", It.IsAny<CancellationToken>()))
.ReturnsAsync(denied);
var controller = NewController(service);
var result = await controller.Deny(5, new UpliftController.DecisionRequest { Note = "too high" });
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
service.Verify(x => x.DenyAsync(It.IsAny<ClaimsPrincipal>(), 5, "too high", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public void CanApprove_DelegatesToService()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.CanApprove(It.IsAny<ClaimsPrincipal>(), 2))
.Returns(true);
var controller = NewController(service, "Manager");
var result = controller.CanApprove(2);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
service.Verify(x => x.CanApprove(It.Is<ClaimsPrincipal>(p => p.IsInRole("Manager")), 2), Times.Once);
}
[Fact]
public async Task ListForDispatch_ReturnsItems()
{
var service = new Mock<IUpliftService>();
var items = new[] { new UpliftForDispatchDTO { Id = 1, DispatchId = 10 } };
service.Setup(x => x.ListForDispatchAsync(It.IsAny<ClaimsPrincipal>(), 10, It.IsAny<CancellationToken>()))
.ReturnsAsync(items);
var controller = NewController(service);
var result = await controller.ListForDispatch(10);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
envelope.Status.Should().Be("Success");
}
}

View file

@ -1,191 +0,0 @@
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using System.Text.Json;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class UserControllerTests
{
private static string Json(object? value) =>
JsonSerializer.Serialize(value, value?.GetType() ?? typeof(object));
private static UserController NewController(Mock<IUserService> service, string? userId = null)
{
var controller = new UserController(service.Object, Mock.Of<ILogger<UserController>>());
if (userId != null)
{
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, userId)
}, "Test"))
}
};
}
return controller;
}
[Fact]
public async Task GetUsers_ReturnsServiceList()
{
var service = new Mock<IUserService>();
service.Setup(s => s.GetUsersAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<UserListRowDTO>
{
new() { Id = "1", Name = "A", Email = "a@x.com", RoleName = "Admin", Status = "Active", Date = "Jan 01 2026" }
});
var controller = NewController(service);
var result = await controller.GetUsers(CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeAssignableTo<IEnumerable<UserListRowDTO>>();
}
[Fact]
public async Task AddUser_Success_ReturnsUpdatedSuccessfully()
{
var service = new Mock<IUserService>();
service.Setup(s => s.AddUserAsync(It.IsAny<AddUserRequestDTO>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AddUserOutcomeDTO { Success = true });
var controller = NewController(service);
var result = await controller.AddUser(new User_DTO { Name = "N", Email = "n@x.com", Role = "Admin" }, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
response.Status.Should().Be("200");
response.Message.Should().Be("Updated Successfully");
}
[Fact]
public async Task AddUser_Failure_ReturnsBadRequestWithServiceError()
{
var service = new Mock<IUserService>();
service.Setup(s => s.AddUserAsync(It.IsAny<AddUserRequestDTO>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AddUserOutcomeDTO { Success = false, Error = "boom" });
var controller = NewController(service);
var result = await controller.AddUser(new User_DTO(), CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Error");
response.Message.Should().Be("boom");
}
[Fact]
public async Task EditUser_NotFound_ReturnsUserNotFoundMessage()
{
var service = new Mock<IUserService>();
service.Setup(s => s.EditUserAsync(It.IsAny<EditUserRequestDTO>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var controller = NewController(service);
var result = await controller.EditUser(new EditUser_DTO { Id = "9" }, CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be("User not found");
}
[Fact]
public async Task EditUser_Found_ReturnsUpdatedSuccessfully()
{
var service = new Mock<IUserService>();
service.Setup(s => s.EditUserAsync(It.IsAny<EditUserRequestDTO>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var controller = NewController(service);
var result = await controller.EditUser(new EditUser_DTO { Id = "9" }, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeOfType<DataResponse>();
}
[Fact]
public async Task DeleteUser_NotFound_ReturnsIdNotMatchedMessage()
{
var service = new Mock<IUserService>();
service.Setup(s => s.DeleteUserAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var controller = NewController(service);
var result = await controller.DeleteUser(new EditUser_DTO { Id = "missing" }, CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be("ID not matched!");
}
[Fact]
public async Task DeleteUser_Found_DelegatesAndReturnsOk()
{
var service = new Mock<IUserService>();
service.Setup(s => s.DeleteUserAsync("5", It.IsAny<CancellationToken>())).ReturnsAsync(true);
var controller = NewController(service);
var result = await controller.DeleteUser(new EditUser_DTO { Id = "5" }, CancellationToken.None);
result.Should().BeOfType<OkObjectResult>();
service.Verify(s => s.DeleteUserAsync("5", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task DeleteCurrentUser_UsesClaimUserId()
{
var service = new Mock<IUserService>();
service.Setup(s => s.DeleteCurrentUserAsync("77", It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
var controller = NewController(service, "77");
var result = await controller.DeleteCurrentUser(CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeOfType<DataResponse>();
service.Verify(s => s.DeleteCurrentUserAsync("77", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task UserProfile_PreservesLowercaseAddedDateKey()
{
var service = new Mock<IUserService>();
service.Setup(s => s.GetUserProfileAsync("77", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<UserProfileRowDTO>
{
new() { Id = "77", Name = "Me", AddedDate = new DateTime(2026, 1, 1), Email = "me@x.com", Contact = "321" }
});
var controller = NewController(service, "77");
var result = await controller.UserProfile(CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var json = Json(ok.Value);
json.Should().Contain("\"Id\":\"77\"");
json.Should().Contain("\"Name\":\"Me\"");
json.Should().Contain("\"addeddate\":\"");
json.Should().Contain("\"Email\":\"me@x.com\"");
json.Should().Contain("\"Contact\":\"321\"");
}
}

View file

@ -1,195 +0,0 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Moq;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class UserServiceTests
{
private static UserService NewService(
Mock<IUserDataService> userData,
Mock<IEmailSender> email,
out Mock<IUserRoleStore<ApplicationUser>> store)
{
var (manager, s, _) = IdentityTestHelpers.CreateUserManager();
store = s;
return new UserService(manager, userData.Object, email.Object);
}
[Fact]
public async Task GetUsers_FormatsCreatedDate_AndHandlesNullDate()
{
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetAllForListAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<UserListRowData>
{
new() { Id = "1", Name = "A", Email = "a@x.com", RoleName = "Admin", Status = "Active", CreatedDate = new DateTime(2026, 1, 5) },
new() { Id = "2", Name = "B", Email = "b@x.com", RoleName = "Tech", Status = "Active", CreatedDate = null }
});
var service = NewService(userData, new Mock<IEmailSender>(), out _);
var users = (await service.GetUsersAsync(CancellationToken.None)).ToList();
users.Should().HaveCount(2);
users[0].Date.Should().Be("Jan 05 2026");
users[0].RoleName.Should().Be("Admin");
users[0].Status.Should().Be("Active");
users[1].Date.Should().Be("");
users[1].Name.Should().Be("B");
}
[Fact]
public async Task DeleteUser_NotFound_ReturnsFalseWithoutCascade()
{
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetForEditAsync("missing", It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
var service = NewService(userData, new Mock<IEmailSender>(), out _);
var found = await service.DeleteUserAsync("missing", CancellationToken.None);
found.Should().BeFalse();
userData.Verify(u => u.DeleteUserWithCascadeAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task DeleteUser_Found_PerformsCascade()
{
var user = IdentityTestHelpers.User();
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetForEditAsync("u1", It.IsAny<CancellationToken>())).ReturnsAsync(user);
var service = NewService(userData, new Mock<IEmailSender>(), out _);
var found = await service.DeleteUserAsync("u1", CancellationToken.None);
found.Should().BeTrue();
userData.Verify(u => u.DeleteUserWithCascadeAsync(user, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task DeleteCurrentUser_SetsIsDeletedFlag()
{
var user = IdentityTestHelpers.User();
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetForEditAsync("u1", It.IsAny<CancellationToken>())).ReturnsAsync(user);
var service = NewService(userData, new Mock<IEmailSender>(), out _);
await service.DeleteCurrentUserAsync("u1", CancellationToken.None);
user.IsDeleted.Should().BeTrue();
userData.Verify(u => u.UpdateUserAsync(user, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task EditUser_NotFound_ReturnsFalse()
{
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetForEditAsync("x", It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
var service = NewService(userData, new Mock<IEmailSender>(), out _);
var succeeded = await service.EditUserAsync(new EditUserRequestDTO { Id = "x", Email = "e@x.com", Name = "n", Role = "Admin" }, CancellationToken.None);
succeeded.Should().BeFalse();
}
[Fact]
public async Task GetUserProfile_MapsCreatedDateToAddedDate()
{
var user = new UserProfileData
{
Id = "u1",
FirstName = "Alice",
CreatedDate = new DateTime(2026, 3, 4)
};
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetProfileAsync("u1", It.IsAny<CancellationToken>())).ReturnsAsync(user);
var service = NewService(userData, new Mock<IEmailSender>(), out _);
var rows = await service.GetUserProfileAsync("u1", CancellationToken.None);
rows.Should().HaveCount(1);
rows[0].AddedDate.Should().Be(new DateTime(2026, 3, 4));
rows[0].Name.Should().Be("Alice");
}
[Fact]
public async Task AddUser_CreateSuccess_PersistsRoleAndEmailsLoginInformation()
{
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByIdAsync(It.IsAny<string>())).ReturnsAsync((ApplicationUser?)null);
var email = new Mock<IEmailSender>();
var service = NewService(userData, email, out var store);
store.As<IUserPasswordStore<ApplicationUser>>()
.Setup(s => s.SetPasswordHashAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
store.Setup(s => s.CreateAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(IdentityResult.Success);
store.Setup(s => s.AddToRoleAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
var outcome = await service.AddUserAsync(new AddUserRequestDTO { Name = "N", Email = "n@x.com", Role = "Admin" }, CancellationToken.None);
outcome.Success.Should().BeTrue();
store.Verify(s => s.CreateAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Once);
store.Verify(s => s.AddToRoleAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
email.Verify(
e => e.SendEmailAsync(
"n@x.com",
"Login Information",
It.Is<string>(body => ContainsStrongGeneratedPassword(body))),
Times.Once);
}
[Fact]
public async Task AddUser_CreateFails_ReturnsFirstError()
{
var userData = new Mock<IUserDataService>();
userData.Setup(u => u.GetByIdAsync(It.IsAny<string>())).ReturnsAsync((ApplicationUser?)null);
var service = NewService(userData, new Mock<IEmailSender>(), out var store);
store.As<IUserPasswordStore<ApplicationUser>>()
.Setup(s => s.SetPasswordHashAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
store.Setup(s => s.CreateAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(IdentityResult.Failed(new IdentityError { Description = "duplicate" }));
var outcome = await service.AddUserAsync(new AddUserRequestDTO { Name = "N", Email = "n@x.com", Role = "Admin" }, CancellationToken.None);
outcome.Success.Should().BeFalse();
outcome.Error.Should().Be("duplicate");
}
private static bool ContainsStrongGeneratedPassword(string body)
{
const string marker = "Your password is: ";
var start = body.IndexOf(marker, StringComparison.Ordinal);
if (start < 0)
return false;
var password = body[(start + marker.Length)..]
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault();
return password is
{
Length: 8
}
&& password.Any(char.IsUpper)
&& password.Any(char.IsLower)
&& password.Any(char.IsDigit)
&& password.Any(character => !char.IsLetterOrDigit(character));
}
}

View file

@ -1,317 +0,0 @@
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class VendorControllerTests
{
private static VendorController NewController(Mock<IVendorService> service)
{
var controller = new VendorController(service.Object, Mock.Of<ILogger<VendorController>>());
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, "42")
}, "Test"))
}
};
return controller;
}
[Fact]
public async Task Update_WithoutIsActive_PassesNullToServiceToPreserveStatus()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.UpdateVendorAsync(It.IsAny<int>(), It.IsAny<UpdateVendorDTO>(), It.IsAny<string>()))
.ReturnsAsync(new VendorDTO());
var controller = NewController(service);
var model = new EditVendor_DTO
{
Id = 8,
CompanyName = "Updated Name"
};
var result = await controller.Update(8, model);
result.Should().BeOfType<OkObjectResult>();
service.Verify(x => x.UpdateVendorAsync(
8,
It.Is<UpdateVendorDTO>(dto => dto.IsActive == null),
"42"), Times.Once);
}
[Fact]
public async Task Update_WithExplicitIsActive_ForwardsValueUnchanged()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.UpdateVendorAsync(It.IsAny<int>(), It.IsAny<UpdateVendorDTO>(), It.IsAny<string>()))
.ReturnsAsync(new VendorDTO());
var controller = NewController(service);
var model = new EditVendor_DTO
{
Id = 8,
CompanyName = "Updated Name",
IsActive = false
};
await controller.Update(8, model);
service.Verify(x => x.UpdateVendorAsync(
8,
It.Is<UpdateVendorDTO>(dto => dto.IsActive == false),
"42"), Times.Once);
}
[Fact]
public async Task Create_WithoutIsActive_DefaultsToActive()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.CreateVendorAsync(It.IsAny<CreateVendorDTO>(), It.IsAny<string>()))
.ReturnsAsync(new VendorDTO());
var controller = NewController(service);
var model = new Vendor_DTO
{
CompanyName = "New Co"
};
await controller.Create(model);
service.Verify(x => x.CreateVendorAsync(
It.Is<CreateVendorDTO>(dto => dto.IsActive == true),
"42"), Times.Once);
}
[Fact]
public async Task GetFacets_DelegatesToServiceAndReturnsOk()
{
var service = new Mock<IVendorService>();
var facets = new VendorFacetsDTO
{
Companies = new[] { new VendorFacetCompanyDTO { Name = "Active Co" } },
Trades = new[] { "Plumbing" },
Locations = new[] { new VendorFacetLocationDTO { Label = "Chicago, IL" } },
JobBuckets = new[] { new VendorJobBucketDTO { Id = "under-50", Label = "Under 50" } }
};
service.Setup(x => x.GetFacetsAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(facets);
var controller = NewController(service);
var result = await controller.GetFacets(true);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().Be(facets);
service.Verify(x => x.GetFacetsAsync(true, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task GetDropdown_DelegatesToServiceAndReturnsOk()
{
var service = new Mock<IVendorService>();
var items = new[]
{
new VendorDropdownItemDTO { Id = 1, CompanyName = "Vendor A", DistanceMiles = 5.2 }
};
service.Setup(x => x.GetDropdownAsync("Plumbing", "60601", It.IsAny<CancellationToken>()))
.ReturnsAsync(items);
var controller = NewController(service);
var result = await controller.GetDropdown("Plumbing", "60601");
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().Be(items);
}
[Fact]
public async Task GetPortalToken_ReturnsTokenDtoFromService()
{
var service = new Mock<IVendorService>();
var token = new VendorPortalTokenDTO { Token = "abc", PortalUrl = "https://example.com/v/abc/dashboard" };
service.Setup(x => x.GetPortalTokenAsync(5, It.IsAny<CancellationToken>()))
.ReturnsAsync(token);
var controller = NewController(service);
var result = await controller.GetPortalToken(5);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().Be(token);
}
[Fact]
public async Task GetPortalToken_ReturnsNotFound_WhenVendorDoesNotExist()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.GetPortalTokenAsync(99, It.IsAny<CancellationToken>()))
.ReturnsAsync((VendorPortalTokenDTO?)null);
var controller = NewController(service);
var result = await controller.GetPortalToken(99);
result.Should().BeOfType<NotFoundResult>();
}
[Fact]
public async Task RotatePortalToken_ReturnsRotatedToken()
{
var service = new Mock<IVendorService>();
var token = new VendorPortalTokenDTO { Token = "new-token" };
service.Setup(x => x.RotatePortalTokenAsync(5, It.IsAny<CancellationToken>()))
.ReturnsAsync(token);
var controller = NewController(service);
var result = await controller.RotatePortalToken(5);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().Be(token);
}
[Fact]
public async Task RevokePortalToken_ReturnsRevokedTrue_OnSuccess()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.RevokePortalTokenAsync(5, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var controller = NewController(service);
var result = await controller.RevokePortalToken(5);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().NotBeNull();
}
[Fact]
public async Task RevokePortalToken_ReturnsNotFound_WhenVendorDoesNotExist()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.RevokePortalTokenAsync(99, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var controller = NewController(service);
var result = await controller.RevokePortalToken(99);
result.Should().BeOfType<NotFoundResult>();
}
[Fact]
public async Task Delete_WhenDeactivationBlocked_ReturnsSanitizedConflictWithOpenWorkOrdersAndLogs()
{
var openWorkOrders = new List<LinkedWorkOrderDTO> { new() { WorkOrderId = 7 } };
var service = new Mock<IVendorService>();
service.Setup(x => x.DeleteVendorAsync(It.IsAny<int>(), It.IsAny<string>()))
.ThrowsAsync(new VendorDeactivationBlockedException("SECRET-internal-reason", openWorkOrders));
var logger = new Mock<ILogger<VendorController>>();
logger.Setup(x => x.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
var controller = new VendorController(service.Object, logger.Object);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "42") }, "Test"))
}
};
var result = await controller.Delete(5);
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
var value = conflict.Value!;
var message = (string)value.GetType().GetProperty("Message")!.GetValue(value)!;
message.Should().NotContain("SECRET");
message.Should().Contain("reference");
var returnedOpenWorkOrders = (List<LinkedWorkOrderDTO>)value.GetType().GetProperty("OpenWorkOrders")!.GetValue(value)!;
returnedOpenWorkOrders.Should().HaveCount(1);
logger.Verify(
x => x.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.IsAny<It.IsAnyType>(),
It.Is<Exception>(ex => ex.Message.Contains("SECRET")),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task Delete_MapsDeactivationBlocked_ToConflict()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.DeleteVendorAsync(It.IsAny<int>(), It.IsAny<string>()))
.ThrowsAsync(new VendorDeactivationBlockedException("blocked", new List<LinkedWorkOrderDTO>()));
var controller = NewController(service);
var result = await controller.Delete(5);
result.Should().BeOfType<ConflictObjectResult>();
}
[Fact]
public async Task Delete_MapsNotFoundInvalidOperation_ToNotFound()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.DeleteVendorAsync(It.IsAny<int>(), It.IsAny<string>()))
.ThrowsAsync(new InvalidOperationException());
var controller = NewController(service);
var result = await controller.Delete(5);
var nf = result.Should().BeOfType<NotFoundObjectResult>().Subject;
nf.Value.Should().BeOfType<Response>();
}
[Fact]
public async Task GetVendorById_ReturnsNotFound_WhenVendorIsNull()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.GetVendorByIdAsync(99))
.ReturnsAsync((VendorDTO?)null);
var controller = NewController(service);
var result = await controller.GetVendorById(99, null);
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task Update_UnauthenticatedUser_ReturnsUnauthorized()
{
var service = new Mock<IVendorService>();
var controller = new VendorController(service.Object, Mock.Of<ILogger<VendorController>>());
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal() }
};
var result = await controller.Update(5, new EditVendor_DTO { Id = 5, CompanyName = "X" });
result.Should().BeOfType<UnauthorizedObjectResult>();
}
}

View file

@ -1,324 +0,0 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class VendorDataServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static ApplicationDbContext NewSqlServerContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlServer("Server=(local);Database=TranslationOnly;Trusted_Connection=True;TrustServerCertificate=True")
.Options;
return new ApplicationDbContext(options);
}
private static Vendor VendorWithJobs(
string company,
string city,
string state,
string trades,
int jobCount,
bool isActive = true)
{
return new Vendor
{
CompanyName = company,
ContactName = $"{company} Technician",
City = city,
State = state,
TradeSpecialties = trades,
IsActive = isActive,
IsDeleted = false,
Dispatches = Enumerable.Range(1, jobCount)
.Select(workOrderId => new Dispatch { WorkOrderId = workOrderId })
.ToList()
};
}
private static async Task SeedBoundaryVendors(ApplicationDbContext context)
{
context.Vendors.AddRange(
VendorWithJobs("Under Co", "Chicago", "IL", "Plumbing, HVAC", 49),
VendorWithJobs("Fifty Co", "Chicago", "IL", "Electrical", 50),
VendorWithJobs("Hundred Co", "Dallas", "TX", "Plumbing", 100),
VendorWithJobs("One Fifty Co", "Dallas", "TX", "Roofing", 150));
await context.SaveChangesAsync();
}
[Theory]
[InlineData("under-50", "Under Co")]
[InlineData("50-99", "Fifty Co")]
[InlineData("100-149", "Hundred Co")]
[InlineData("150-plus", "One Fifty Co")]
public async Task GetPagedAsync_AppliesExactJobBucketBoundaries(
string bucket,
string expectedCompany)
{
await using var context = NewContext();
await SeedBoundaryVendors(context);
var service = new VendorDataService(context);
var (items, totalCount) = await service.GetPagedAsync(
1,
12,
isActive: true,
jobBuckets: new[] { bucket });
totalCount.Should().Be(1);
items.Single().CompanyName.Should().Be(expectedCompany);
}
[Fact]
public async Task GetPagedAsync_UsesOrWithinFacetAndAndAcrossFacets()
{
await using var context = NewContext();
await SeedBoundaryVendors(context);
var service = new VendorDataService(context);
var (items, totalCount) = await service.GetPagedAsync(
1,
12,
isActive: true,
companies: new[] { "Under Co", "Hundred Co" },
trades: new[] { "HVAC", "Plumbing" },
locations: new[] { "Chicago, IL" },
jobBuckets: new[] { "under-50", "100-149" });
totalCount.Should().Be(1);
items.Single().CompanyName.Should().Be("Under Co");
}
[Fact]
public async Task GetPagedAsync_StatusAndCountAreIndependentOfInactiveHistory()
{
await using var context = NewContext();
context.Vendors.AddRange(
VendorWithJobs("Active Co", "Chicago", "IL", "Plumbing", 1),
VendorWithJobs("Inactive Co", "Chicago", "IL", "Plumbing", 1, false));
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var active = await service.GetPagedAsync(1, 1, isActive: true);
var inactive = await service.GetPagedAsync(1, 1, isActive: false);
active.TotalCount.Should().Be(1);
inactive.TotalCount.Should().Be(1);
active.Items.Single().CompanyName.Should().Be("Active Co");
inactive.Items.Single().CompanyName.Should().Be("Inactive Co");
}
[Fact]
public async Task GetPagedAsync_LocationFacetMatchesCityStateAndFullLabels()
{
await using var context = NewContext();
context.Vendors.AddRange(
new Vendor { CompanyName = "CityOnly", City = "Austin", State = null, TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "StateOnly", City = null, State = "TX", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "FullLoc", City = "Austin", State = "TX", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var cityOnly = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "Austin" });
cityOnly.TotalCount.Should().Be(1);
cityOnly.Items.Single().CompanyName.Should().Be("CityOnly");
var stateOnly = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "TX" });
stateOnly.TotalCount.Should().Be(1);
stateOnly.Items.Single().CompanyName.Should().Be("StateOnly");
var full = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "Austin, TX" });
full.TotalCount.Should().Be(1);
full.Items.Single().CompanyName.Should().Be("FullLoc");
}
[Fact]
public async Task GetPagedAsync_TradeFacetMatchesExactTokensNotSubstrings()
{
await using var context = NewContext();
context.Vendors.AddRange(
new Vendor { CompanyName = "HvacOnly", City = "Chicago", State = "IL", TradeSpecialties = "HVAC", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "HvacControls", City = "Chicago", State = "IL", TradeSpecialties = "HVAC Controls", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "PlumbingCo", City = "Chicago", State = "IL", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var hvac = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "HVAC" });
hvac.TotalCount.Should().Be(1);
hvac.Items.Single().CompanyName.Should().Be("HvacOnly");
var hvacControls = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "HVAC Controls" });
hvacControls.TotalCount.Should().Be(1);
hvacControls.Items.Single().CompanyName.Should().Be("HvacControls");
var plumb = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumb" });
plumb.TotalCount.Should().Be(0);
plumb.Items.Should().BeEmpty();
var plumbing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumbing" });
plumbing.TotalCount.Should().Be(1);
plumbing.Items.Single().CompanyName.Should().Be("PlumbingCo");
}
[Fact]
public async Task GetPagedAsync_TradeFacetMatchesTokenInCommaDelimitedList()
{
await using var context = NewContext();
context.Vendors.AddRange(
new Vendor { CompanyName = "Multi", City = "Chicago", State = "IL", TradeSpecialties = "Plumbing, HVAC", IsActive = true, IsDeleted = false },
new Vendor { CompanyName = "Middle", City = "Chicago", State = "IL", TradeSpecialties = "Electrical, Plumbing, Roofing", IsActive = true, IsDeleted = false });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var plumbing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumbing" });
plumbing.TotalCount.Should().Be(2);
plumbing.Items.Select(v => v.CompanyName).Should().BeEquivalentTo("Multi", "Middle");
var roofing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Roofing" });
roofing.TotalCount.Should().Be(1);
roofing.Items.Single().CompanyName.Should().Be("Middle");
}
[Fact]
public async Task GetPagedAsync_CountsDistinctDirectAndLinkedWorkOrders()
{
await using var context = NewContext();
var vendor = new Vendor
{
CompanyName = "History Co",
IsActive = true,
IsDeleted = false,
Dispatches = new List<Dispatch>
{
new()
{
WorkOrderId = 100,
DispatchWorkOrders = new List<DispatchWorkOrder>
{
new() { WorkOrderId = 100 },
new() { WorkOrderId = 101 }
}
},
new() { WorkOrderId = 101 }
}
};
context.Vendors.Add(vendor);
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var result = await service.GetPagedAsync(1, 12, isActive: true);
result.Items.Single().TotalJobs.Should().Be(2);
}
[Fact]
public async Task GetByIdAsync_ReturnsTrackedVendorWithoutLoadingDispatchGraph()
{
await using var context = NewContext();
var vendor = VendorWithJobs("Lean Co", "Chicago", "IL", "Plumbing", 2);
context.Vendors.Add(vendor);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
var service = new VendorDataService(context);
var result = await service.GetByIdAsync(vendor.Id);
result.Should().NotBeNull();
context.Entry(result!).State.Should().Be(EntityState.Unchanged);
context.Entry(result!).Collection(nameof(Vendor.Dispatches)).IsLoaded.Should().BeFalse();
result!.Dispatches.Should().BeNull();
}
[Theory]
[InlineData("trade")]
[InlineData("location")]
[InlineData("job-bucket")]
public void BuildDirectoryQuery_TranslatesRepresentativeFiltersForSqlServer(string filter)
{
using var context = NewSqlServerContext();
var service = new VendorDataService(context);
var query = filter switch
{
"trade" => service.BuildDirectoryQuery(trades: new[] { "Plumbing" }),
"location" => service.BuildDirectoryQuery(locations: new[] { "Chicago, IL" }),
_ => service.BuildDirectoryQuery(jobBuckets: new[] { "50-99" })
};
var sql = query.ToQueryString();
sql.Should().Contain("SELECT");
if (filter == "trade")
sql.Should().Contain("TradeSpecialties");
if (filter == "location")
sql.Should().Contain("City");
if (filter == "job-bucket")
sql.Should().Contain("UNION");
}
[Fact]
public void BuildLinkedWorkOrdersQuery_TranslatesForSqlServerWithoutClientEvaluation()
{
using var context = NewSqlServerContext();
var service = new VendorDataService(context);
var sql = service.BuildLinkedWorkOrdersQuery(60).ToQueryString();
sql.Should().Contain("SELECT");
sql.Should().Contain("Dispatches");
sql.Should().Contain("DispatchWorkOrders");
sql.Should().Contain("workOrders");
}
[Fact]
public async Task GetLinkedWorkOrdersAsync_DedupsToOneRecordPerWorkOrderAcrossDirectAndMultiLinks()
{
await using var context = NewContext();
var vendor = new Vendor { Id = 60, CompanyName = "Linked Co", IsActive = true };
context.Vendors.Add(vendor);
context.workOrders.AddRange(
new WorkOrder
{
Id = 701,
WorkerOrderNumber = "WO-701",
WorkerOrderTitle = "Direct And Multi",
Status = "Scheduled",
LifecycleStatus = LifecycleStatus.Scheduled
},
new WorkOrder
{
Id = 702,
WorkerOrderNumber = "WO-702",
WorkerOrderTitle = "Multi Only",
Status = "Scheduled",
LifecycleStatus = LifecycleStatus.Scheduled
});
var directDispatch = new Dispatch { Id = 7001, VendorId = 60, WorkOrderId = 701, Vendor = vendor };
var multiDispatch = new Dispatch { Id = 7002, VendorId = 60, WorkOrderId = null, Vendor = vendor };
context.Dispatches.AddRange(directDispatch, multiDispatch);
context.DispatchWorkOrders.AddRange(
new DispatchWorkOrder { DispatchId = 7002, Dispatch = multiDispatch, WorkOrderId = 702 },
new DispatchWorkOrder { DispatchId = 7002, Dispatch = multiDispatch, WorkOrderId = 701 });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var result = await service.GetLinkedWorkOrdersAsync(60);
result.Should().HaveCount(2);
result.Select(x => x.WorkOrderId).Should().BeEquivalentTo(new[] { 701, 702 });
result.Select(x => x.WorkOrderId).Should().OnlyHaveUniqueItems();
}
}

View file

@ -1,319 +0,0 @@
using System.Collections;
using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class VendorOperationsControllerTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static VendorOperationsController NewController(ApplicationDbContext context)
{
var dataService = new VendorOperationsDataService(context, new DispatchDataService(context));
var service = new VendorOperationsService(dataService, TimeProvider.System);
var controller = new VendorOperationsController(service);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") }, "test"))
}
};
return controller;
}
private static object Prop(object source, string name) =>
source.GetType().GetProperty(name)!.GetValue(source)!;
[Fact]
public async Task Notifications_ReturnsUnassignedWorkOrderInsideFortyEightHours()
{
using var context = NewContext();
context.workOrders.Add(new WorkOrder
{
InternalWONumber = "WO-48",
WorkerOrderTitle = "Needs vendor",
ScheduledDate = DateTime.UtcNow.AddHours(47)
});
await context.SaveChangesAsync();
var result = await NewController(context).GetNotifications(CancellationToken.None);
var value = ((OkObjectResult)result).Value!;
var items = ((IEnumerable)Prop(value, "Items")).Cast<object>().ToList();
items.Should().ContainSingle();
Prop(items[0], "Type").Should().Be("NoVendor");
Prop(items[0], "Title").Should().Be("No vendor assigned — 48h to service");
}
[Fact]
public async Task Notifications_RemovesReminderAsSoonAsVendorIsAssigned()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder
{
InternalWONumber = "WO-ASSIGNED",
ScheduledDate = DateTime.UtcNow.AddHours(24)
};
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
context.Dispatches.Add(new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
Status = "Sent",
ScheduledDate = workOrder.ScheduledDate
});
await context.SaveChangesAsync();
var result = await NewController(context).GetNotifications(CancellationToken.None);
var items = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Items")).Cast<object>();
items.Should().BeEmpty();
}
[Fact]
public async Task Availability_SurfacesOverlapAndRanksPreferredVendorFirst()
{
using var context = NewContext();
var site = new Locations { Name = "St Louis" };
var preferred = new Vendor
{
CompanyName = "Preferred",
ContactName = "Alex",
IsActive = true,
TradeSpecialties = "HVAC",
AvailabilityStatus = "Available",
AvailabilityUpdatedAt = DateTime.UtcNow
};
var busy = new Vendor { CompanyName = "Busy", ContactName = "Sam", IsActive = true };
var workOrder = new WorkOrder
{
InternalWONumber = "WO-BUSY",
ScheduledStart = DateTime.UtcNow.AddHours(1),
ScheduledEnd = DateTime.UtcNow.AddHours(3)
};
context.AddRange(site, preferred, busy, workOrder);
await context.SaveChangesAsync();
context.SitePreferredVendors.Add(new SitePreferredVendor
{
LocationId = site.Id,
VendorId = preferred.Id,
Trade = "HVAC",
SortOrder = 0
});
context.Dispatches.Add(new Dispatch
{
VendorId = busy.Id,
WorkOrderId = workOrder.Id,
Status = "Sent",
ScheduledDate = workOrder.ScheduledStart
});
await context.SaveChangesAsync();
var result = await NewController(context).GetAvailability(
DateTime.UtcNow.AddHours(2),
DateTime.UtcNow.AddHours(4),
site.Id,
"HVAC",
CancellationToken.None);
var rows = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Data")).Cast<object>().ToList();
Prop(rows[0], "CompanyName").Should().Be("Preferred");
Prop(rows[0], "IsPreferred").Should().Be(true);
var busyRow = rows.Single(row => (string?)Prop(row, "CompanyName") == "Busy");
Prop(busyRow, "AvailabilityStatus").Should().Be("Unavailable");
}
[Fact]
public async Task ReplaceSitePreferences_RejectsInactiveVendor()
{
using var context = NewContext();
var site = new Locations { Name = "St Louis" };
var inactive = new Vendor { CompanyName = "Inactive", IsActive = false };
context.AddRange(site, inactive);
await context.SaveChangesAsync();
var result = await NewController(context).ReplaceSitePreferences(site.Id,
new List<SitePreferenceRequest>
{
new() { VendorId = inactive.Id }
},
CancellationToken.None);
result.Should().BeOfType<BadRequestObjectResult>();
context.SitePreferredVendors.Should().BeEmpty();
}
[Fact]
public async Task InsightsExports_ReturnStableCsvAndPdfFormats()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "=HYPERLINK(\"https://invalid.test\")", IsActive = false };
var workOrder = new WorkOrder { DueDate = DateTime.UtcNow.AddHours(1), Trade = "Plumbing" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
context.Dispatches.Add(new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
Status = "Completed",
DispatchedAt = DateTime.UtcNow.AddHours(-4),
ScheduledDate = DateTime.UtcNow.AddHours(-2),
AcknowledgedAt = DateTime.UtcNow.AddHours(-3),
CompletedDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var controller = NewController(context);
var csv = (FileContentResult)await controller.ExportInsightsCsv(null, null, null, null, null, CancellationToken.None);
var pdf = (FileContentResult)await controller.ExportInsightsPdf(null, null, null, null, null, CancellationToken.None);
csv.ContentType.Should().Be("text/csv");
var csvText = System.Text.Encoding.UTF8.GetString(csv.FileContents);
csvText.Should().Contain("AverageCycleHours");
csvText.Should().Contain("\"'=HYPERLINK(\"\"https://invalid.test\"\")\"");
pdf.ContentType.Should().Be("application/pdf");
System.Text.Encoding.ASCII.GetString(pdf.FileContents, 0, 8).Should().StartWith("%PDF-1.4");
}
[Fact]
public async Task AssignVendor_RejectsInactiveVendor()
{
using var context = NewContext();
var vendor = new Vendor { CompanyName = "Inactive", IsActive = false };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var result = await NewController(context).AssignVendor(
workOrder.Id,
new VendorAssignmentRequest { VendorId = vendor.Id },
CancellationToken.None);
result.Should().BeOfType<BadRequestObjectResult>();
context.Dispatches.Should().BeEmpty();
}
[Fact]
public async Task AssignVendor_PersistsEtaAuditAndDisclosesConflicts()
{
using var context = NewContext();
var start = DateTime.UtcNow.AddHours(2);
var vendor = new Vendor
{
CompanyName = "Gateway",
IsActive = true,
AvailabilityStatus = "Available",
AvailabilityUpdatedAt = DateTime.UtcNow
};
var existingWorkOrder = new WorkOrder
{
InternalWONumber = "WO-EXISTING",
ScheduledStart = start,
ScheduledEnd = start.AddHours(2)
};
var assignedWorkOrder = new WorkOrder
{
InternalWONumber = "WO-NEW",
ScheduledStart = start.AddMinutes(30),
ScheduledEnd = start.AddHours(1)
};
context.AddRange(vendor, existingWorkOrder, assignedWorkOrder);
await context.SaveChangesAsync();
context.Dispatches.Add(new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = existingWorkOrder.Id,
Status = "Sent",
ScheduledDate = existingWorkOrder.ScheduledStart
});
await context.SaveChangesAsync();
var eta = start.AddMinutes(45);
var result = await NewController(context).AssignVendor(
assignedWorkOrder.Id,
new VendorAssignmentRequest
{
VendorId = vendor.Id,
ScheduledStart = assignedWorkOrder.ScheduledStart,
ScheduledEnd = assignedWorkOrder.ScheduledEnd,
EstimatedArrivalAt = eta,
EtaManualOverride = true
},
CancellationToken.None);
var value = ((OkObjectResult)result).Value!;
Prop(value, "AvailabilityStatus").Should().Be("Unavailable");
((IEnumerable)Prop(value, "Conflicts")).Cast<object>().Should().ContainSingle();
var dispatch = await context.Dispatches.SingleAsync(item =>
item.WorkOrderId == assignedWorkOrder.Id);
dispatch.EstimatedArrivalAt.Should().Be(eta);
dispatch.EtaManualOverride.Should().BeTrue();
dispatch.DispatchNumber.Should().StartWith("DISP-");
context.WorkOrderAuditLogs.Should().ContainSingle(log =>
log.WorkOrderId == assignedWorkOrder.Id && log.Action == "vendor_assigned");
}
[Fact]
public async Task AssignVendor_ReusesDispatchLinkedThroughMultiWorkOrderJoin()
{
using var context = NewContext();
var originalVendor = new Vendor { CompanyName = "Original", IsActive = true };
var replacementVendor = new Vendor { CompanyName = "Replacement", IsActive = true };
var workOrder = new WorkOrder
{
InternalWONumber = "WO-LINKED",
ScheduledStart = DateTime.UtcNow.AddHours(2),
ScheduledEnd = DateTime.UtcNow.AddHours(4)
};
context.AddRange(originalVendor, replacementVendor, workOrder);
await context.SaveChangesAsync();
var dispatch = new Dispatch
{
VendorId = originalVendor.Id,
Status = "Sent",
DispatchNumber = "DISP-LINKED"
};
context.Dispatches.Add(dispatch);
await context.SaveChangesAsync();
context.DispatchWorkOrders.Add(new DispatchWorkOrder
{
DispatchId = dispatch.Id,
WorkOrderId = workOrder.Id
});
await context.SaveChangesAsync();
var result = await NewController(context).AssignVendor(
workOrder.Id,
new VendorAssignmentRequest
{
VendorId = replacementVendor.Id,
ScheduledStart = workOrder.ScheduledStart,
ScheduledEnd = workOrder.ScheduledEnd
},
CancellationToken.None);
result.Should().BeOfType<OkObjectResult>();
context.Dispatches.Should().ContainSingle();
(await context.Dispatches.SingleAsync()).VendorId.Should().Be(replacementVendor.Id);
}
}

View file

@ -1,317 +0,0 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class VendorPortalControllerTests
{
private static VendorPortalController NewController(Mock<IVendorPortalService> service, string? token = "valid-token")
{
var controller = new VendorPortalController(service.Object, Mock.Of<ILogger<VendorPortalController>>());
var context = new DefaultHttpContext();
if (token != null)
context.Request.Headers["X-Vendor-Token"] = token;
controller.ControllerContext = new ControllerContext { HttpContext = context };
return controller;
}
private static readonly VendorPortalSession Session = new()
{
Id = 1,
CompanyName = "Test Co",
ContactName = "Sam",
Email = "sam@test.co",
Phone = "555-0100"
};
[Fact]
public async Task Session_MissingToken_ReturnsUnauthorized()
{
var service = new Mock<IVendorPortalService>();
var controller = NewController(service, token: null);
var result = await controller.Session();
result.Should().BeOfType<UnauthorizedObjectResult>();
}
[Fact]
public async Task Session_InvalidToken_ReturnsUnauthorized()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync("bad", It.IsAny<CancellationToken>()))
.ReturnsAsync((VendorPortalSession?)null);
var controller = NewController(service, token: "bad");
var result = await controller.Session();
result.Should().BeOfType<UnauthorizedObjectResult>();
}
[Fact]
public async Task Session_ValidToken_ReturnsSessionData()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync("valid-token", It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
var controller = NewController(service);
var result = await controller.Session();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
envelope.Status.Should().Be("Success");
var sessionData = envelope.Data as VendorPortalSession;
sessionData.Should().Be(Session);
}
[Fact]
public async Task ListDispatches_DelegatesToServiceWithStatus()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
var dispatches = new[] { new VendorDispatchSummaryDTO { Id = 10, Status = "Sent" } };
service.Setup(x => x.ListDispatchesAsync(Session, "Sent", It.IsAny<CancellationToken>()))
.ReturnsAsync(dispatches);
var controller = NewController(service);
var result = await controller.ListDispatches("Sent");
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
service.Verify(x => x.ListDispatchesAsync(Session, "Sent", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task GetDispatch_NotFound_Returns404()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.GetDispatchDetailAsync(Session, 99, It.IsAny<CancellationToken>()))
.ReturnsAsync((VendorDispatchDetailDTO?)null);
var controller = NewController(service);
var result = await controller.GetDispatch(99);
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task Accept_KeyNotFound_Returns404()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.AcceptDispatchAsync(Session, 99, It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException());
var controller = NewController(service);
var result = await controller.Accept(99);
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task Accept_InvalidOperation_Returns400()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.AcceptDispatchAsync(Session, 10, It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Cannot accept"));
var controller = NewController(service);
var result = await controller.Accept(10);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var resp = bad.Value.Should().BeOfType<Response>().Subject;
resp.Message.Should().NotContain("Cannot accept");
resp.Message.Should().Contain("reference");
}
[Fact]
public async Task Accept_Success_ReturnsAcknowledgedResult()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
var acceptResult = new AcceptDispatchResultDTO { Id = 10, Status = "Acknowledged" };
service.Setup(x => x.AcceptDispatchAsync(Session, 10, It.IsAny<CancellationToken>()))
.ReturnsAsync(acceptResult);
var controller = NewController(service);
var result = await controller.Accept(10);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
var acceptData = envelope.Data as AcceptDispatchResultDTO;
acceptData.Should().Be(acceptResult);
}
[Fact]
public async Task ChangeStatus_ForbiddenTransition_Returns400()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.ChangeStatusAsync(Session, 10, "Completed", It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Transition not allowed"));
var controller = NewController(service);
var result = await controller.ChangeStatus(10, new VendorPortalController.ChangeStatusRequest { Status = "Completed" });
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var resp = bad.Value.Should().BeOfType<Response>().Subject;
resp.Message.Should().NotContain("Transition");
resp.Message.Should().Contain("reference");
}
[Fact]
public async Task RequestCancel_Success_ReturnsSuccessWithMessage()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
var controller = NewController(service);
var result = await controller.RequestCancel(10, new VendorPortalController.RequestCancelRequest { Reason = "Parts delay" });
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
envelope.Status.Should().Be("Success");
envelope.Message.Should().Be("Cancel request sent to dispatcher");
service.Verify(x => x.RequestCancelAsync(Session, 10, "Parts delay", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task UpdateChecklistItem_LockedDispatch_Returns400()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.UpdateChecklistItemAsync(Session, 10, 5, true, It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Dispatch is locked"));
var controller = NewController(service);
var result = await controller.UpdateChecklistItem(10, 5, new VendorPortalController.ChecklistUpdateRequest { IsCompleted = true });
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task AddSignoff_DuplicateSignoff_Returns400()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.AddSignoffAsync(Session, 10, "vendor", "Name", "sig", "drawn", It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("A vendor signoff already exists"));
var controller = NewController(service);
var result = await controller.AddSignoff(10, new VendorPortalController.SignoffRequest
{
SignoffType = "vendor",
Name = "Name",
Signature = "sig",
SignatureMethod = "drawn"
});
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task RequestUplift_NTEBelowCurrent_Returns400()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.RequestUpliftAsync(Session, 10, 100m, null, It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Requested NTE must be greater than the current NTE"));
var controller = NewController(service);
var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody { RequestedNTE = 100m });
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task RequestUplift_Success_ReturnsResult()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
var upliftResult = new UpliftRequestResultDTO { Id = 77, Status = "Pending", RequiredTier = 1 };
service.Setup(x => x.RequestUpliftAsync(Session, 10, 500m, "Need more parts", It.IsAny<CancellationToken>()))
.ReturnsAsync(upliftResult);
var controller = NewController(service);
var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody
{
RequestedNTE = 500m,
Reason = "Need more parts"
});
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
var upliftData = envelope.Data as UpliftRequestResultDTO;
upliftData.Should().Be(upliftResult);
}
[Fact]
public async Task CancelUpliftRequest_NonPending_Returns400()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.CancelUpliftRequestAsync(Session, 10, 77, It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Cannot cancel a 'Approved' uplift request"));
var controller = NewController(service);
var result = await controller.CancelUpliftRequest(10, 77);
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task CancelUpliftRequest_Success_ReturnsCancelledStatus()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
var cancelResult = new CancelUpliftResultDTO { Id = 77, Status = "Cancelled" };
service.Setup(x => x.CancelUpliftRequestAsync(Session, 10, 77, It.IsAny<CancellationToken>()))
.ReturnsAsync(cancelResult);
var controller = NewController(service);
var result = await controller.CancelUpliftRequest(10, 77);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
var cancelData = envelope.Data as CancelUpliftResultDTO;
cancelData.Should().Be(cancelResult);
}
[Fact]
public async Task AddComment_EmptyText_Returns400()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.AddCommentAsync(Session, 10, "", It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Comment cannot be empty"));
var controller = NewController(service);
var result = await controller.AddComment(10, new VendorPortalController.CommentRequest { CommentText = "" });
result.Should().BeOfType<BadRequestObjectResult>();
}
}

View file

@ -1,179 +0,0 @@
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.Helper;
using Api.SeaHavenIndustries.Infrastructure;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class VendorPortalDocumentTests : IDisposable
{
private const string Token = "vendor-document-test-token";
private readonly string _contentRoot = Path.Combine(
Path.GetTempPath(),
$"seahaven-vendor-documents-{Guid.NewGuid():N}");
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private VendorPortalController NewController(ApplicationDbContext context)
{
var vendorData = new VendorDataService(context);
var tokenService = new VendorPortalTokenService(
vendorData,
Microsoft.Extensions.Options.Options.Create(new VendorPortalOptions()));
var documentData = new VendorDocumentDataService(context);
var storageEnvironment = new Mock<IWebHostEnvironment>();
storageEnvironment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot);
var storage = new VendorDocumentStorageAdapter(storageEnvironment.Object);
var service = new VendorPortalService(
tokenService,
new DispatchDataService(context),
Mock.Of<IUpliftDataService>(),
Mock.Of<ICommentDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<IEmailSender>(),
documentData,
storage,
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
Microsoft.Extensions.Options.Options.Create(new ApprovalsOptions()),
Microsoft.Extensions.Options.Options.Create(new VendorDocumentsOptions()));
var controller = new VendorPortalController(service, Mock.Of<ILogger<VendorPortalController>>());
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
};
controller.Request.Headers["X-Vendor-Token"] = Token;
return controller;
}
private static async Task<(Vendor Vendor, Dispatch Dispatch)> SeedDispatch(
ApplicationDbContext context)
{
var vendor = new Vendor { CompanyName = "Gateway", IsActive = true };
var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" };
context.AddRange(vendor, workOrder);
await context.SaveChangesAsync();
var dispatch = new Dispatch
{
VendorId = vendor.Id,
WorkOrderId = workOrder.Id,
Status = "Completed"
};
context.Dispatches.Add(dispatch);
context.VendorAccessTokens.Add(new VendorAccessToken
{
VendorId = vendor.Id,
Token = Token,
IssuedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddDays(1)
});
await context.SaveChangesAsync();
return (vendor, dispatch);
}
private static FormFile FormFile(byte[] bytes, string fileName, string contentType)
{
return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName)
{
Headers = new HeaderDictionary(),
ContentType = contentType
};
}
[Fact]
public async Task UploadCompletionDocument_RejectsMismatchedFileSignature()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile("not a pdf"u8.ToArray(), "completion.pdf", "application/pdf"));
result.Should().BeOfType<BadRequestObjectResult>();
context.VendorCompletionDocuments.Should().BeEmpty();
}
[Fact]
public async Task UploadCompletionDocument_QuarantinesValidFileUntilScanPasses()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var controller = NewController(context);
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
var upload = await controller.UploadCompletionDocument(
dispatch.Id,
FormFile(pdf, "completion.pdf", "application/pdf"));
upload.Should().BeOfType<OkObjectResult>();
var document = await context.VendorCompletionDocuments.SingleAsync();
document.ScanStatus.Should().Be("Pending");
document.ReviewStatus.Should().Be("Processing");
File.Exists(VendorDocumentStorage.ResolvePath(_contentRoot, document)).Should().BeTrue();
var download = await controller.DownloadCompletionDocument(dispatch.Id, document.Id);
var locked = download.Should().BeOfType<ObjectResult>().Subject;
locked.StatusCode.Should().Be(StatusCodes.Status423Locked);
}
[Fact]
public async Task UploadCompletionDocument_AcceptsMixedCasePdfContentType()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var pdf = "%PDF-1.4\nvendor completion"u8.ToArray();
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(pdf, "completion.pdf", "Application/PDF"));
result.Should().BeOfType<OkObjectResult>();
context.VendorCompletionDocuments.Should().HaveCount(1);
}
[Fact]
public async Task UploadCompletionDocument_AcceptsMixedCaseImageContentType()
{
using var context = NewContext();
var (_, dispatch) = await SeedDispatch(context);
var png = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00 };
var result = await NewController(context).UploadCompletionDocument(
dispatch.Id,
FormFile(png, "photo.png", "Image/PNG"));
result.Should().BeOfType<OkObjectResult>();
context.VendorCompletionDocuments.Should().HaveCount(1);
}
public void Dispose()
{
if (Directory.Exists(_contentRoot))
{
Directory.Delete(_contentRoot, recursive: true);
}
}
}

View file

@ -1,491 +0,0 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using FluentValidation;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class VendorServiceTests
{
private static VendorService NewService(Mock<IVendorDataService> data) =>
new(
data.Object,
Mock.Of<IVendorPortalTokenService>(),
Mock.Of<IZipCodeDistance>(),
Microsoft.Extensions.Options.Options.Create(new FrontendOptions()),
new CreateVendorValidation(),
new UpdateVendorValidation(),
new WorkOrderVendorUpdateValidation());
[Fact]
public async Task CreateVendor_PersistsCompleteProfile()
{
var data = new Mock<IVendorDataService>();
Vendor? saved = null;
data.Setup(x => x.AddAsync(It.IsAny<Vendor>()))
.ReturnsAsync((Vendor vendor) => { saved = vendor; vendor.Id = 7; return vendor; });
var result = await NewService(data).CreateVendorAsync(new CreateVendorDTO
{
Name = "Gateway Plumbing",
ContactName = "Jordan Lee",
Email = "dispatch@gateway.example",
Phone = "(555) 555-0100",
CompanyPhone = "(555) 555-0199",
Address = "10 Lake St",
City = "Chicago",
State = "IL",
Zipcode = "60601",
TradeSpecialties = "Plumbing, Backflow",
GoogleMapsUrl = "https://maps.google.com/example",
Notes = "After-hours dispatch available",
IsActive = true
}, "42");
saved.Should().NotBeNull();
saved!.ContactName.Should().Be("Jordan Lee");
saved.TradeSpecialties.Should().Be("Plumbing, Backflow");
saved.CompanyPhone.Should().Be("(555) 555-0199");
saved.GoogleMapsUrl.Should().Be("https://maps.google.com/example");
saved.Notes.Should().Be("After-hours dispatch available");
saved.City.Should().Be("Chicago");
result.IsActive.Should().BeTrue();
result.ContactName.Should().Be("Jordan Lee");
}
[Fact]
public async Task UpdateVendor_UpdatesProfileAndActiveState()
{
var existing = new Vendor { Id = 8, CompanyName = "Old", IsActive = true };
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(8)).ReturnsAsync(existing);
data.Setup(x => x.GetLinkedWorkOrdersAsync(8)).ReturnsAsync(new List<LinkedWorkOrderInfo>());
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
var result = await NewService(data).UpdateVendorAsync(8, new UpdateVendorDTO
{
Name = "New",
ContactName = "Casey",
TradeSpecialties = "HVAC",
IsActive = false
}, "42");
existing.CompanyName.Should().Be("New");
existing.ContactName.Should().Be("Casey");
existing.TradeSpecialties.Should().Be("HVAC");
existing.IsActive.Should().BeFalse();
existing.LastModifierUserId.Should().Be(42);
result.IsActive.Should().BeFalse();
}
[Fact]
public async Task DeleteVendor_DeactivatesWithoutDeletingHistory()
{
var existing = new Vendor { Id = 9, CompanyName = "Keep History", IsActive = true };
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(9)).ReturnsAsync(existing);
data.Setup(x => x.GetLinkedWorkOrdersAsync(9)).ReturnsAsync(new List<LinkedWorkOrderInfo>());
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
await NewService(data).DeleteVendorAsync(9, "42");
existing.IsActive.Should().BeFalse();
existing.IsDeleted.Should().NotBeTrue();
existing.DeletionTime.Should().BeNull();
existing.LastModificationTime.Should().NotBeNull();
}
[Fact]
public async Task GetVendorsPaged_RequestsSelectedStatusAndMapsDirectoryFields()
{
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetPagedAsync(
1,
12,
"chicago",
false,
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>(),
It.IsAny<IReadOnlyCollection<string>?>()))
.ReturnsAsync((new[]
{
new Vendor
{
Id = 10,
CompanyName = "Inactive Vendor",
ContactName = "Taylor",
City = "Chicago",
State = "IL",
TradeSpecialties = "Electrical",
IsActive = false
}
}.AsEnumerable(), 1));
var result = await NewService(data).GetVendorsPagedAsync(1, 12, "chicago", false);
result.TotalCount.Should().Be(1);
result.Items.Single().Should().BeEquivalentTo(new
{
Id = 10,
Name = "Inactive Vendor",
ContactName = "Taylor",
City = "Chicago",
State = "IL",
TradeSpecialties = "Electrical",
IsActive = false
}, options => options.ExcludingMissingMembers());
}
[Fact]
public async Task GetVendorById_CountsDistinctHistoricalWorkOrders()
{
var vendor = new Vendor
{
Id = 11,
CompanyName = "History Vendor",
Dispatches = new List<Dispatch>
{
new()
{
WorkOrderId = 100,
DispatchWorkOrders = new List<DispatchWorkOrder>
{
new() { WorkOrderId = 100 },
new() { WorkOrderId = 101 }
}
},
new() { WorkOrderId = 101 }
}
};
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdWithDetailsAsync(11)).ReturnsAsync(vendor);
var result = await NewService(data).GetVendorByIdAsync(11);
result.Should().NotBeNull();
result!.TotalJobs.Should().Be(2);
}
[Fact]
public async Task GetVendorsPaged_ForwardsFacetFilters()
{
var companies = new[] { "Gateway Plumbing", "Roto-Rooter Dallas" };
var trades = new[] { "Backflow Preventers" };
var locations = new[] { "Dallas, TX" };
var jobBuckets = new[] { "100-149" };
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetPagedAsync(
1,
25,
"gateway",
true,
companies,
trades,
locations,
jobBuckets))
.ReturnsAsync((Enumerable.Empty<Vendor>(), 0));
await NewService(data).GetVendorsPagedAsync(
1,
25,
"gateway",
true,
companies,
trades,
locations,
jobBuckets);
data.VerifyAll();
}
[Fact]
public void CreateVendor_RequiresTechnicianNameAndHttpsMapUrl()
{
var validator = new CreateVendorValidation();
var result = validator.Validate(new CreateVendorDTO
{
Name = "Gateway Plumbing",
ContactName = "",
GoogleMapsUrl = "http://maps.google.com/gateway"
});
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName));
result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl));
}
[Fact]
public async Task UpdateVendor_DeactivatingWithOpenWorkOrders_ThrowsAndLeavesActive()
{
var existing = new Vendor { Id = 20, CompanyName = "Guarded", IsActive = true };
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(20)).ReturnsAsync(existing);
data.Setup(x => x.GetLinkedWorkOrdersAsync(20))
.ReturnsAsync(new List<LinkedWorkOrderInfo>
{
new()
{
WorkOrderId = 500,
WorkOrderNumber = "WO-500",
Status = "Scheduled",
LifecycleStatus = LifecycleStatus.Scheduled
}
});
var act = () => NewService(data).UpdateVendorAsync(20, new UpdateVendorDTO { IsActive = false }, "42");
await act.Should().ThrowAsync<VendorDeactivationBlockedException>();
existing.IsActive.Should().BeTrue();
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), Times.Never);
}
[Fact]
public async Task UpdateVendor_WithUnknownCompanyId_ThrowsAndDoesNotFallBackToName()
{
var existing = new Vendor { Id = 30, CompanyName = "Existing", IsActive = true };
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(30)).ReturnsAsync(existing);
data.Setup(x => x.GetCompanyByIdAsync(999)).ReturnsAsync((VendorCompany?)null);
var act = () => NewService(data).UpdateVendorAsync(30, new UpdateVendorDTO
{
CompanyId = 999,
Name = "Fallback Try"
}, "42");
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.GetCompanyByNormalizedNameAsync(It.IsAny<string>()), Times.Never);
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), Times.Never);
}
[Fact]
public async Task CreateVendor_PropagatesExistingCompanyIdWithoutCreatingCompany()
{
var data = new Mock<IVendorDataService>();
var company = new VendorCompany { Id = 77, Name = "Acme", NormalizedName = "acme" };
data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(company);
Vendor? saved = null;
data.Setup(x => x.AddAsync(It.IsAny<Vendor>()))
.ReturnsAsync((Vendor v) => { saved = v; v.Id = 40; return v; });
var result = await NewService(data).CreateVendorAsync(new CreateVendorDTO
{
Name = "Acme",
CompanyId = 77,
ContactName = "Riley"
}, "42");
saved.Should().NotBeNull();
saved!.CompanyId.Should().Be(77);
result.CompanyId.Should().Be(77);
data.Verify(x => x.AddCompanyAsync(It.IsAny<VendorCompany>()), Times.Never);
}
[Fact]
public void CreateVendor_RejectsInvalidPhoneAndInvalidPreferredContact()
{
var validator = new CreateVendorValidation();
var result = validator.Validate(new CreateVendorDTO
{
Name = "Acme",
ContactName = "Riley",
Phone = "555-1234",
PreferredContact = "Fax"
});
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorDTO.Phone));
result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorDTO.PreferredContact));
}
[Fact]
public async Task CreateVendor_NormalizesCommonNorthAmericanPhoneFormatsBeforeValidation()
{
var data = new Mock<IVendorDataService>();
Vendor? saved = null;
data.Setup(x => x.AddAsync(It.IsAny<Vendor>()))
.ReturnsAsync((Vendor vendor) =>
{
saved = vendor;
vendor.Id = 41;
return vendor;
});
await NewService(data).CreateVendorAsync(new CreateVendorDTO
{
Name = "Acme",
ContactName = "Riley",
Phone = "+1 312 555 0100"
}, "42");
saved!.Phone.Should().Be("(312) 555-0100");
}
[Fact]
public async Task UpdateVendor_RoutesCompanyOwnedFieldsToTheLinkedCompany()
{
var existing = new Vendor
{
Id = 42,
CompanyId = 77,
CompanyName = "Acme",
Address = "Old address",
IsActive = true
};
var company = new VendorCompany
{
Id = 77,
Name = "Acme",
NormalizedName = "acme",
Address = "Old address"
};
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(42)).ReturnsAsync(existing);
data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(company);
data.Setup(x => x.UpdateCompanyAsync(company)).Returns(Task.CompletedTask);
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
await NewService(data).UpdateVendorAsync(42, new UpdateVendorDTO
{
Name = "Acme Services",
Address = "200 New Street"
}, "42");
company.Name.Should().Be("Acme Services");
company.NormalizedName.Should().Be("acme services");
company.Address.Should().Be("200 New Street");
existing.Address.Should().Be("200 New Street");
data.Verify(x => x.UpdateCompanyAsync(company), Times.Once);
}
[Fact]
public async Task UpdateVendor_SparseCompanyDoesNotEraseExistingVendorFields()
{
var existing = new Vendor
{
Id = 43,
CompanyId = 77,
CompanyName = "Acme",
Address = "Keep this address",
CompanyPhone = "(312) 555-0199",
IsActive = true
};
var sparseCompany = new VendorCompany
{
Id = 77,
Name = "Acme",
NormalizedName = "acme"
};
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(43)).ReturnsAsync(existing);
data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(sparseCompany);
data.Setup(x => x.UpdateCompanyAsync(sparseCompany)).Returns(Task.CompletedTask);
data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask);
await NewService(data).UpdateVendorAsync(43, new UpdateVendorDTO
{
Name = "Acme"
}, "42");
existing.Address.Should().Be("Keep this address");
existing.CompanyPhone.Should().Be("(312) 555-0199");
}
[Fact]
public void WorkOrderVendorUpdate_AcceptsCanonicalPhoneAndValidPreferredContact()
{
var validator = new WorkOrderVendorUpdateValidation();
var result = validator.Validate(new WorkOrderVendorUpdateDTO
{
WorkOrderId = 5,
Phone = "(312) 555-0100",
PreferredContact = "Text"
});
result.IsValid.Should().BeTrue();
}
[Fact]
public async Task UpdateVendorFromWorkOrder_RejectsUnassignedWorkOrder()
{
var existing = new Vendor { Id = 50, ContactName = "Original", IsActive = true };
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(50)).ReturnsAsync(existing);
data.Setup(x => x.IsVendorAssignedToWorkOrderAsync(50, 777)).ReturnsAsync(false);
var act = () => NewService(data).UpdateVendorFromWorkOrderAsync(50, new WorkOrderVendorUpdateDTO
{
WorkOrderId = 777,
ContactName = "Should Not Apply"
}, "42");
await act.Should().ThrowAsync<InvalidOperationException>();
existing.ContactName.Should().Be("Original");
data.Verify(x => x.UpdateAsync(It.IsAny<Vendor>()), Times.Never);
}
[Fact]
public async Task UpdateVendorFromWorkOrder_RecordsAuditOldAndNewValues()
{
var existing = new Vendor
{
Id = 50,
CompanyName = "Assigned",
ContactName = "Old Name",
Phone = "(312) 555-0000",
PreferredContact = "Phone",
IsActive = true
};
var data = new Mock<IVendorDataService>();
data.Setup(x => x.GetByIdAsync(50)).ReturnsAsync(existing);
data.Setup(x => x.IsVendorAssignedToWorkOrderAsync(50, 777)).ReturnsAsync(true);
var auditLogs = new List<VendorAuditLog>();
data.Setup(x => x.UpdateWithAuditLogsAsync(
existing,
It.IsAny<IReadOnlyCollection<VendorAuditLog>>()))
.Callback<Vendor, IReadOnlyCollection<VendorAuditLog>>((_, logs) => auditLogs.AddRange(logs))
.Returns(Task.CompletedTask);
await NewService(data).UpdateVendorFromWorkOrderAsync(50, new WorkOrderVendorUpdateDTO
{
WorkOrderId = 777,
ContactName = "New Name",
Phone = "(312) 555-0199",
PreferredContact = "Email"
}, "42");
existing.ContactName.Should().Be("New Name");
existing.Phone.Should().Be("(312) 555-0199");
existing.PreferredContact.Should().Be("Email");
auditLogs.Should().NotBeEmpty();
auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.ContactName) && a.OldValue == "Old Name" && a.NewValue == "New Name");
auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.Phone) && a.OldValue == "(312) 555-0000" && a.NewValue == "(312) 555-0199");
auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.PreferredContact) && a.OldValue == "Phone" && a.NewValue == "Email");
}
[Fact]
public async Task GetDeactivationImpact_RejectsUnknownVendor()
{
var data = new Mock<IVendorDataService>();
data.Setup(x => x.ExistsAsync(404)).ReturnsAsync(false);
var act = () => NewService(data).GetDeactivationImpactAsync(404);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*404*not found");
data.Verify(x => x.GetLinkedWorkOrdersAsync(It.IsAny<int>()), Times.Never);
}
}

View file

@ -1,8 +1,13 @@
using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
@ -16,14 +21,50 @@ namespace Api.SeaHavenIndustries.Tests;
public class WorkOrderControllerSearchTests
{
private static WorkOrderBoardController NewController(IWorkOrderAdvancedSearchService advancedSearch)
private static ApplicationDbContext NewContext()
{
var controller = new WorkOrderBoardController(
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static UserManager<ApplicationUser> MockUserManager()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object, null!, null!, null!, null!, null!, null!, null!, null!).Object;
}
private static WorkOrderController NewController(
ApplicationDbContext ctx,
IWorkOrderAdvancedSearchService advancedSearch)
{
var controller = new WorkOrderController(
MockUserManager(),
Mock.Of<IWorkOrderService>(),
Mock.Of<IWorkOrderDataService>(),
Mock.Of<ICommentDataService>(),
Mock.Of<IQuotesDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<IDispatchDataService>(),
Mock.Of<IVendorDataService>(),
Mock.Of<ITaskListTemplateDataService>(),
ctx,
Mock.Of<IWebHostEnvironment>(),
Mock.Of<IHttpContextAccessor>(),
null!,
null!,
Mock.Of<IConfiguration>(),
Mock.Of<IWorkOrderBoardService>(),
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
advancedSearch);
advancedSearch,
Mock.Of<IWorkOrderDetailService>(),
Mock.Of<IWorkOrderCommentService>(),
Mock.Of<IWorkOrderCompletionService>(),
Mock.Of<IWorkOrderMediaService>());
controller.ControllerContext = new ControllerContext
{
@ -47,9 +88,10 @@ public class WorkOrderControllerSearchTests
[Fact]
public async Task SearchBoard_WhenCustomPresetMissingDates_ReturnsBadRequest()
{
using var ctx = NewContext();
var advancedSearch = new WorkOrderAdvancedSearchService(
Mock.Of<IWorkOrderAdvancedSearchDataService>());
var controller = NewController(advancedSearch);
var controller = NewController(ctx, advancedSearch);
var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto
{
@ -65,13 +107,57 @@ public class WorkOrderControllerSearchTests
[Fact]
public async Task GetBoard_WhenWeekEndBeforeWeekStart_ReturnsBadRequest()
{
var controller = NewController(Mock.Of<IWorkOrderAdvancedSearchService>());
using var ctx = NewContext();
var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(ctx));
var controller = NewController(ctx, Mock.Of<IWorkOrderAdvancedSearchService>());
// Replace board service - controller uses injected mock. Need different approach.
var result = await controller.GetBoard(
// Use real board service via manual controller setup
var advancedSearch = Mock.Of<IWorkOrderAdvancedSearchService>();
var controllerWithBoard = new WorkOrderController(
MockUserManager(),
Mock.Of<IWorkOrderService>(),
Mock.Of<IWorkOrderDataService>(),
Mock.Of<ICommentDataService>(),
Mock.Of<IQuotesDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<IDispatchDataService>(),
Mock.Of<IVendorDataService>(),
Mock.Of<ITaskListTemplateDataService>(),
ctx,
Mock.Of<IWebHostEnvironment>(),
Mock.Of<IHttpContextAccessor>(),
null!,
null!,
Mock.Of<IConfiguration>(),
boardService,
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
advancedSearch,
Mock.Of<IWorkOrderDetailService>(),
Mock.Of<IWorkOrderCommentService>(),
Mock.Of<IWorkOrderCompletionService>(),
Mock.Of<IWorkOrderMediaService>());
controllerWithBoard.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new System.Security.Claims.ClaimsPrincipal(
new System.Security.Claims.ClaimsIdentity(
new[] { new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, "test-user") },
"Test"))
}
};
var result = await controllerWithBoard.GetBoard(
new DateOnly(2026, 6, 22),
new DateOnly(2026, 6, 15));
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
badRequest.Value.Should().BeOfType<string>().Which.Should().Contain("weekEnd");
var body = badRequest.Value.Should().BeOfType<Response>().Subject;
body.Status.Should().Be("Error");
body.Message.Should().Contain("weekEnd");
}
}

View file

@ -1,104 +0,0 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class WorkOrderDataServiceBatchingTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static async Task SeedAsync(ApplicationDbContext context)
{
context.Locations.Add(new Locations { Id = 100, Name = "Site A", Address1 = "1 Main St" });
context.workOrders.AddRange(
new WorkOrder { Id = 1, InternalWONumber = "WO-1", WorkerOrderTitle = "First", LocationId = 100 },
new WorkOrder { Id = 2, InternalWONumber = "WO-2", WorkerOrderTitle = "Second", LocationId = 100 },
new WorkOrder { Id = 3, InternalWONumber = "WO-3", WorkerOrderTitle = "Third", LocationId = 100 },
new WorkOrder { Id = 4, InternalWONumber = "WO-T", WorkerOrderTitle = "Template", LocationId = 100, istemplate = true });
await context.SaveChangesAsync();
}
[Fact]
public async Task GetByRequestedIdsWithDetailsAsync_ReturnsResultsInInputOrder()
{
await using var context = NewContext();
await SeedAsync(context);
var service = new WorkOrderDataService(context);
var result = await service.GetByRequestedIdsWithDetailsAsync(
new[] { 3, 1, 2 },
CancellationToken.None);
result.Select(w => w.Id).Should().Equal(new[] { 3, 1, 2 });
}
[Fact]
public async Task GetByRequestedIdsWithDetailsAsync_DeduplicatesIdsAndSkipsMissing()
{
await using var context = NewContext();
await SeedAsync(context);
var service = new WorkOrderDataService(context);
var result = await service.GetByRequestedIdsWithDetailsAsync(
new[] { 2, 2, 999, 1 },
CancellationToken.None);
result.Select(w => w.Id).Should().Equal(new[] { 2, 1 });
}
[Fact]
public async Task GetByRequestedIdsWithDetailsAsync_ExcludesTemplatesAndEagerLoadsLocation()
{
await using var context = NewContext();
await SeedAsync(context);
var service = new WorkOrderDataService(context);
var result = await service.GetByRequestedIdsWithDetailsAsync(
new[] { 1, 4 },
CancellationToken.None);
result.Select(w => w.Id).Should().Equal(new[] { 1 });
result.Single().Locations.Should().NotBeNull();
result.Single().Locations!.Name.Should().Be("Site A");
}
[Fact]
public async Task AddAuditLogsAsync_PersistsAllRowsInSingleSave()
{
await using var context = NewContext();
await SeedAsync(context);
var service = new WorkOrderDataService(context);
await service.AddAuditLogsAsync(new[]
{
new WorkOrderAuditLog { WorkOrderId = 1, FieldName = "Dispatch", Action = "Dispatch" },
new WorkOrderAuditLog { WorkOrderId = 2, FieldName = "Dispatch", Action = "Dispatch" }
});
context.WorkOrderAuditLogs.Should().HaveCount(2);
}
[Fact]
public async Task GetByIdsOrderedAsync_PreservesInputOrderDeduplicatesAndSkipsMissing()
{
await using var context = NewContext();
context.Vendors.AddRange(
new Vendor { Id = 7, CompanyName = "V7", Email = "v7@x.io" },
new Vendor { Id = 8, CompanyName = "V8", Email = "v8@x.io" });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var result = await service.GetByIdsOrderedAsync(new[] { 8, 8, 7, 999 }, CancellationToken.None);
result.Select(v => v.Id).Should().Equal(new[] { 8, 7 });
}
}

View file

@ -1,207 +0,0 @@
using Api.SeaHavenIndustries.Controllers;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ActionConstraints;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Xunit.Abstractions;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// Framework-backed route contract tests that prove the public endpoint set exposed by the
/// WorkOrder URL space (api/WorkOrder, api/workorders) is preserved exactly after splitting the
/// original godfile WorkOrderController into cohesive controllers, and that no verb+route
/// combination is registered more than once by different actions.
///
/// Routes are read from the ASP.NET Core action descriptor provider so the EXACT templates the
/// framework will dispatch are compared, including multi-attribute actions (e.g. Editworkorder,
/// GetWorkorderById) and the two shared controller-level base routes.
/// </summary>
public class WorkOrderRouteContractTests
{
private readonly ITestOutputHelper _output;
private static readonly HashSet<Type> WorkOrderFamilyControllerTypes = new()
{
typeof(WorkOrderController),
typeof(WorkOrderBoardController),
typeof(WorkOrderDetailController),
typeof(WorkOrderCompletionController),
typeof(WorkOrderMediaController),
typeof(WorkOrderDispatchController),
};
/// <summary>
/// Baseline public endpoint set (verb + action-relative route) that the original single
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
/// duplication is collapsed here, so this is the distinct action-relative contract. 46 routes
/// come from 44 actions (Editworkorder and GetWorkorderById each bind two routes).
/// </summary>
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
{
"DELETE DeleteWorkorder",
"DELETE completion-templates/{id:int}",
"DELETE {id:int}/media/{mediaId:int}",
"GET GetComments",
"GET GetCommentsByWorkorderId",
"GET GetDispatch/{id}",
"GET GetDispatches/{workOrderId}",
"GET GetFilteredWorkorder",
"GET GetFilteredWorkorder2",
"GET GetWorkOrderList",
"GET GetWorkorderById",
"GET Getworkorders",
"GET GetworkordersDD",
"GET board",
"GET board/search",
"GET completion-templates",
"GET completion-templates/{id:int}",
"GET lookups/dispatchers",
"GET {id:int}",
"GET {id:int}/audit",
"GET {id:int}/comments",
"GET {id:int}/detail",
"GET {id:int}/media",
"PATCH {id:int}/board",
"PATCH {id:int}/comments/{commentId:int}",
"POST AddChecklistItem",
"POST AddComment",
"POST AddCommentJson",
"POST AddDispatchComment",
"POST AddDispatchSignoff",
"POST AddWorkorder",
"POST ChangeAssignment",
"POST ChangeStatus",
"POST DispatchToVendor",
"POST EditWorkorder",
"POST UpdateChecklistItem",
"POST UpdateDispatch",
"POST VerifyDispatch",
"POST board",
"POST completion-templates",
"POST {id:int}/cancel",
"POST {id:int}/comments",
"POST {id:int}/completion-doc",
"POST {id:int}/media",
"PUT completion-templates/{id:int}",
"PUT {id:int}",
};
public WorkOrderRouteContractTests(ITestOutputHelper output)
{
_output = output;
}
private static IReadOnlyList<ActionDescriptor> BuildActionDescriptors()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddMvcCore()
.AddApplicationPart(typeof(WorkOrderController).Assembly);
using var provider = services.BuildServiceProvider();
var actionProvider = provider.GetRequiredService<IActionDescriptorCollectionProvider>();
return actionProvider.ActionDescriptors.Items;
}
private static IReadOnlyList<(string Verb, string FullTemplate, string Controller, string Action)> FullRoutesFor(
Func<ActionDescriptor, bool> predicate)
{
var tuples = new List<(string Verb, string FullTemplate, string Controller, string Action)>();
foreach (var action in BuildActionDescriptors().Where(predicate))
{
var cad = action as ControllerActionDescriptor;
var template = cad?.AttributeRouteInfo?.Template?.Trim('/');
if (string.IsNullOrEmpty(template))
continue;
var methods = action.ActionConstraints
.OfType<HttpMethodActionConstraint>()
.SelectMany(c => c.HttpMethods)
.Distinct(StringComparer.OrdinalIgnoreCase);
foreach (var method in methods)
tuples.Add((method.ToUpperInvariant(), template, cad!.ControllerName, cad.ActionName));
}
return tuples;
}
private static string Normalize(string fullTemplate)
{
foreach (var prefix in new[] { "api/WorkOrder/", "api/workorders/" })
{
if (fullTemplate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
return fullTemplate[prefix.Length..];
}
foreach (var exact in new[] { "api/WorkOrder", "api/workorders" })
{
if (fullTemplate.Equals(exact, StringComparison.OrdinalIgnoreCase))
return string.Empty;
}
return fullTemplate;
}
[Fact]
public void WorkOrder_Family_Preserves_Expected_Public_Endpoint_Set()
{
var actual = FullRoutesFor(a => a is ControllerActionDescriptor cad
&& WorkOrderFamilyControllerTypes.Contains(cad.ControllerTypeInfo))
.Select(t => $"{t.Verb} {Normalize(t.FullTemplate)}")
.ToHashSet(StringComparer.Ordinal);
Dump(actual);
var missing = ExpectedWorkOrderEndpoints.Except(actual).OrderBy(x => x).ToList();
var unexpected = actual.Except(ExpectedWorkOrderEndpoints).OrderBy(x => x).ToList();
missing.Should().BeEmpty("every pre-split WorkOrder endpoint must still be reachable");
unexpected.Should().BeEmpty("the split must not introduce new public WorkOrder endpoints");
actual.Should().HaveCount(ExpectedWorkOrderEndpoints.Count,
"the distinct action-relative endpoint count must match the baseline");
}
[Fact]
public void WorkOrder_Family_Has_No_Duplicate_Verb_And_Route_From_Different_Actions()
{
// Duplicate detection is done at the FULL route-template level. Each action legitimately
// resolves under both base routes (api/WorkOrder and api/workorders); those are distinct
// full templates. A true collision is the SAME full template produced by two actions,
// which would cause an AmbiguousMatchException at request time.
var collisions = FullRoutesFor(a => a is ControllerActionDescriptor cad
&& WorkOrderFamilyControllerTypes.Contains(cad.ControllerTypeInfo))
.GroupBy(t => (t.Verb, t.FullTemplate))
.Where(g => g.Select(x => (x.Controller, x.Action)).Distinct().Count() > 1)
.Select(g => $"{g.Key.Verb} {g.Key.FullTemplate} -> {string.Join(", ", g.Select(x => $"{x.Controller}.{x.Action}"))}")
.ToList();
collisions.Should().BeEmpty(
"no two actions in the WorkOrder family may register the same verb+full-route");
}
[Fact]
public void WorkOrder_Url_Space_Has_No_Duplicate_Verb_And_Route_From_Different_Actions()
{
var collisions = FullRoutesFor(_ => true)
.Where(t => t.FullTemplate.StartsWith("api/WorkOrder", StringComparison.OrdinalIgnoreCase)
|| t.FullTemplate.StartsWith("api/workorders", StringComparison.OrdinalIgnoreCase))
.GroupBy(t => (t.Verb, t.FullTemplate))
.Where(g => g.Select(x => (x.Controller, x.Action)).Distinct().Count() > 1)
.Select(g => $"{g.Key.Verb} {g.Key.FullTemplate} -> {string.Join(", ", g.Select(x => $"{x.Controller}.{x.Action}"))}")
.ToList();
collisions.Should().BeEmpty(
"no two controllers sharing api/WorkOrder or api/workorders may register the same verb+full-route");
}
private void Dump(IEnumerable<string> endpoints)
{
_output.WriteLine("WorkOrder-family public endpoints (verb + action-relative route):");
foreach (var endpoint in endpoints.OrderBy(x => x, StringComparer.Ordinal))
_output.WriteLine(" " + endpoint);
}
}

View file

@ -24,8 +24,4 @@
<ProjectReference Include="..\SeaHaven.DataServices\SeaHaven.DataServices.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Api.SeaHavenIndustries.Tests" />
</ItemGroup>
</Project>

View file

@ -1,12 +1,14 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Microsoft.EntityFrameworkCore;
namespace Api.SeaHavenIndustries.Controllers
{
@ -14,54 +16,73 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/Authentication")]
public class AuthenticationController : Controller
{
private readonly IAuthenticationService _authenticationService;
private readonly ILogger<AuthenticationController> _logger;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IConfiguration _configuration;
private readonly ApplicationDbContext _db;
private readonly SendMessage _sendMessage;
public AuthenticationController(IAuthenticationService authenticationService, ILogger<AuthenticationController> logger)
public AuthenticationController(UserManager<ApplicationUser> userManager, IConfiguration configuration, ApplicationDbContext db, SendMessage sendMessage)
{
_authenticationService = authenticationService;
_logger = logger;
_userManager = userManager;
_configuration = configuration;
_db = db;
_sendMessage = sendMessage;
}
[AllowAnonymous]
[HttpPost]
[Route("login")]
public async Task<IActionResult> Login([FromBody] LoginModel model, CancellationToken cancellationToken)
public async Task<IActionResult> Login([FromBody] LoginModel model)
{
try
{
var result = await _authenticationService.LoginAsync(model.Username, model.Password, cancellationToken);
if (result != null)
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<Claim>
{
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));
}
var token = GetToken(authClaims);
return Ok(new
{
token = result.Token,
expiration = result.Expiration,
email = result.Email,
userRoles = result.UserRole,
phoneNumber = result.PhoneNumber,
fullname = result.Fullname,
id = result.Id
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();
}
catch (Exception ex)
{
return StatusCode(500, _logger.Sanitize(ex));
return StatusCode(500, ex.Message);
}
}
[Route("ChangePassword")]
[HttpPost]
public async Task<IActionResult> ChangePassword(ChangePasswords usermodel, CancellationToken cancellationToken)
public async Task<IActionResult> ChangePassword(ChangePasswords usermodel)
{
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
var succeeded = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Confirmpassword, cancellationToken);
if (succeeded)
var user = await _userManager.FindByIdAsync(userid);
var result = await _userManager.ChangePasswordAsync(user, usermodel.Currentpassword ?? "", usermodel.Confirmpassword ?? "");
if (result.Succeeded)
{
return Ok(new Response { Status = "Success ", Message = "Password successfully changed" });
}
@ -69,36 +90,49 @@ namespace Api.SeaHavenIndustries.Controllers
return BadRequest(new Response { Status = "Old Password is incorrect" });
}
private JwtSecurityToken GetToken(List<Claim> authClaims)
{
var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JWT:Secret"] ?? ""));
var token = new JwtSecurityToken(
issuer: _configuration["JWT:ValidIssuer"],
audience: _configuration["JWT:ValidAudience"],
expires: DateTime.Now.AddDays(10),
claims: authClaims,
signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256)
);
return token;
}
[HttpPost]
[Route("UpdateProfile")]
public async Task<IActionResult> UserProfileUpdate([FromForm] User_DTO model, CancellationToken cancellationToken)
public async Task<IActionResult> UserProfileUpdate([FromForm] User_DTO model)
{
try
{
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
var dto = new UpdateProfileRequestDTO
{
Name = model.Name,
Email = model.Email,
Contact = model.Contact
};
var data = await _authenticationService.UpdateProfileAsync(userid, dto, cancellationToken);
ApplicationUser appuser = _db.Users.AsNoTracking().Where(u => u.Id == userid).FirstOrDefault() ?? new ApplicationUser();
appuser.FirstName = model.Name;
appuser.Email = model.Email;
appuser.Contact = model.Contact;
_db.Users.Update(appuser);
await _db.SaveChangesAsync();
return Ok(new DataResponse
{
Message = "Introduction Updated Successfully",
Status = "200",
Data = data == null ? null : new
Data = _db.Users.Where(u => u.Id == userid).Select(s => new
{
data.FirstName,
data.Email,
data.Contact
}
s.FirstName,
//s.Location,
s.Email,
s.Contact
}).FirstOrDefault()
});
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
@ -107,11 +141,25 @@ namespace Api.SeaHavenIndustries.Controllers
[AllowAnonymous]
[HttpPost()]
[Route("ForgetPassword")]
public async Task<IActionResult> ForgetPassword(string Email, CancellationToken cancellationToken)
public async Task<IActionResult> ForgetPassword(string Email)
{
var found = await _authenticationService.ForgetPasswordAsync(Email, cancellationToken);
if (found)
var user = await _db.Users.Where(u => u.Email.ToLower().Trim() == Email.ToLower().ToLower()).FirstOrDefaultAsync();
if (user != null)
{
if (_db.ForgetPasswordCodes.Where(u => u.Email.ToLower().Trim() == Email.ToLower().Trim()).Any())
{
_db.ForgetPasswordCodes.Remove(_db.ForgetPasswordCodes.Where(u => u.Email.ToLower().Trim() == Email.ToLower().Trim()).FirstOrDefault());
_db.SaveChanges();
}
ForgetPasswordCode forgetPasswordCode = new ForgetPasswordCode();
forgetPasswordCode.Email = user.Email ?? "";
forgetPasswordCode.UserId = user.Id;
forgetPasswordCode.Code = GenerateRandomNo();
_db.ForgetPasswordCodes.Add(forgetPasswordCode);
_db.SaveChanges();
string body = $"Your Password Reset Code is: " + forgetPasswordCode.Code;
await _sendMessage.SendEMail(user.Email, "Forget Password Request.", body);
return Ok(new Response { Status = "Success ", Message = "Please check your email for code" });
}
else
@ -123,11 +171,11 @@ namespace Api.SeaHavenIndustries.Controllers
[AllowAnonymous]
[HttpPost()]
[Route("VerificationCode")]
public async Task<IActionResult> VerificationCode(string code, CancellationToken cancellationToken)
public async Task<IActionResult> VerificationCode(string code)
{
try
{
if (await _authenticationService.VerifyCodeAsync(code, cancellationToken))
if (await _db.ForgetPasswordCodes.Where(u => u.Code == code).AnyAsync())
{
return Ok(new Response { Status = "Success ", Message = "Code Matched" });
@ -139,7 +187,7 @@ namespace Api.SeaHavenIndustries.Controllers
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = "Error: " + ex.Message });
}
}
@ -147,12 +195,16 @@ namespace Api.SeaHavenIndustries.Controllers
[AllowAnonymous]
[HttpPost()]
[Route("ResetPassword")]
public async Task<IActionResult> ResetPassword(ForgetPassword_Dto fpdto, CancellationToken cancellationToken)
public async Task<IActionResult> ResetPassword(ForgetPassword_Dto fpdto)
{
try
{
if (await _authenticationService.ResetPasswordAsync(fpdto.Email, fpdto.Code, fpdto.Password, cancellationToken))
if (_db.ForgetPasswordCodes.Where(u => u.Email.ToLower().Trim() == fpdto.Email.ToLower().Trim() && u.Code == fpdto.Code.Trim()).Any())
{
var GetUser = _db.ForgetPasswordCodes.Where(u => u.Email.ToLower().Trim() == fpdto.Email.ToLower().Trim()).FirstOrDefault();
var user = await _userManager.FindByIdAsync(GetUser.UserId);
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
var result = await _userManager.ResetPasswordAsync(user, token, fpdto.Password);
return Ok(new Response { Status = "Success ", Message = "password changed" });
}
else
@ -163,10 +215,16 @@ namespace Api.SeaHavenIndustries.Controllers
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = "Error: " + ex.Message });
}
}
private Random _random = new Random();
private string GenerateRandomNo()
{
return _random.Next(0, 999999).ToString("D6");
}
#endregion
}
}

View file

@ -1,11 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace Api.SeaHavenIndustries.Controllers
{
@ -14,13 +16,13 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/Calendar")]
public class CalendarController : Controller
{
private readonly ICalendarService _calendarService;
private readonly ILogger<CalendarController> _logger;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ApplicationDbContext _db;
public CalendarController(ICalendarService calendarService, ILogger<CalendarController> logger)
public CalendarController(UserManager<ApplicationUser> userManager, ApplicationDbContext db)
{
_calendarService = calendarService;
_logger = logger;
_userManager = userManager;
_db = db;
}
// ===================================================================================
@ -28,17 +30,37 @@ namespace Api.SeaHavenIndustries.Controllers
// ===================================================================================
[HttpPost]
[Route("AddEvent")]
public async Task<IActionResult> AddEvent(EditEvent_DTO eventDto, CancellationToken cancellationToken)
public async Task<IActionResult> AddEvent(EditEvent_DTO eventDto)
{
try
{
await _calendarService.CreateEventAsync(MapToInput(eventDto), cancellationToken);
var model = new Events
{
Title = eventDto.Title,
Description = eventDto.Description,
Location = eventDto.Location,
RecordID = eventDto.RecordID,
RecordName = eventDto.RecordName,
RecordTargetType = eventDto.RecordTargetType,
ReminderMinutes = eventDto.ReminderMinutes,
EventColor = eventDto.EventColor,
StartDate = eventDto.StartDate,
StartTime = eventDto.StartTime,
EndDate = eventDto.EndDate,
EndTime = eventDto.EndTime,
AllDay = eventDto.AllDay,
CreatedDate = DateTime.Now,
IsDeleted = false
};
_db.Events.Add(model);
await _db.SaveChangesAsync();
return Ok(new DataResponse { Message = "Event Created Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
@ -47,7 +69,7 @@ namespace Api.SeaHavenIndustries.Controllers
// ===================================================================================
[HttpPost]
[Route("EditEvent")]
public async Task<IActionResult> EditEvent(EditEvent_DTO eventDto, CancellationToken cancellationToken)
public async Task<IActionResult> EditEvent(EditEvent_DTO eventDto)
{
try
{
@ -56,17 +78,35 @@ namespace Api.SeaHavenIndustries.Controllers
return BadRequest(new Response { Status = "Error", Message = "Event ID is required for editing" });
}
var updated = await _calendarService.UpdateEventAsync(MapToInput(eventDto), cancellationToken);
if (!updated)
var model = await _db.Events.FindAsync(eventDto.Id.Value);
if (model == null || model.IsDeleted == true)
{
return NotFound(new Response { Status = "Error", Message = "Event not found" });
}
model.Title = eventDto.Title;
model.Description = eventDto.Description;
model.Location = eventDto.Location;
model.RecordID = eventDto.RecordID;
model.RecordName = eventDto.RecordName;
model.RecordTargetType = eventDto.RecordTargetType;
model.ReminderMinutes = eventDto.ReminderMinutes;
model.EventColor = eventDto.EventColor;
model.StartDate = eventDto.StartDate;
model.StartTime = eventDto.StartTime;
model.EndDate = eventDto.EndDate;
model.EndTime = eventDto.EndTime;
model.AllDay = eventDto.AllDay;
model.LastModificationTime = DateTime.Now;
_db.Events.Update(model);
await _db.SaveChangesAsync();
return Ok(new DataResponse { Message = "Event Updated Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
@ -75,22 +115,44 @@ namespace Api.SeaHavenIndustries.Controllers
// ===================================================================================
[HttpGet]
[Route("GetEventById/{id}")]
public async Task<IActionResult> GetEventById(int id, CancellationToken cancellationToken)
public async Task<IActionResult> GetEventById(int id)
{
try
{
var dto = await _calendarService.GetEventByIdAsync(id, cancellationToken);
var model = await _db.Events
.Where(e => e.Id == id && e.IsDeleted != true)
.FirstOrDefaultAsync();
if (dto == null)
if (model == null)
{
return NotFound(new Response { Status = "Error", Message = "Event not found" });
}
return Ok(new DataResponse { Message = "Success", Status = "200", Data = MapToResponse(dto) });
var dto = new Event_DTO
{
Id = model.Id,
Title = model.Title,
Description = model.Description,
Location = model.Location,
RecordID = model.RecordID,
RecordName = model.RecordName,
RecordTargetType = model.RecordTargetType,
ReminderMinutes = model.ReminderMinutes,
EventColor = model.EventColor,
StartDate = model.StartDate,
StartTime = model.StartTime,
EndDate = model.EndDate,
EndTime = model.EndTime,
AllDay = model.AllDay,
CreatedDate = model.CreatedDate,
createdby = model.createdby
};
return Ok(new DataResponse { Message = "Success", Status = "200", Data = dto });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
@ -99,21 +161,28 @@ namespace Api.SeaHavenIndustries.Controllers
// ===================================================================================
[HttpDelete]
[Route("DeleteEvent/{id}")]
public async Task<IActionResult> DeleteEvent(int id, CancellationToken cancellationToken)
public async Task<IActionResult> DeleteEvent(int id)
{
try
{
var deleted = await _calendarService.DeleteEventAsync(id, cancellationToken);
if (!deleted)
var model = await _db.Events.FindAsync(id);
if (model == null)
{
return NotFound(new Response { Status = "Error", Message = "Event not found" });
}
// Soft delete
model.IsDeleted = true;
model.DeletionTime = DateTime.Now;
_db.Events.Update(model);
await _db.SaveChangesAsync();
return Ok(new DataResponse { Message = "Event Deleted Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
@ -122,24 +191,63 @@ namespace Api.SeaHavenIndustries.Controllers
// ===================================================================================
[HttpGet]
[Route("GetEventList")]
public async Task<IActionResult> GetEventList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
public IActionResult GetEventList(string? search = "", int page = 1, int pageSize = 10)
{
try
{
var result = await _calendarService.GetEventListAsync(search, page, pageSize, cancellationToken);
var query = _db.Events.Where(e => e.IsDeleted != true);
var events = result.Rows.Select(MapToResponse).ToList();
// Apply search filter if provided
if (!string.IsNullOrWhiteSpace(search))
{
var searchLower = search.ToLower();
query = query.Where(e =>
e.Title.ToLower().Contains(searchLower) ||
(e.Description != null && e.Description.ToLower().Contains(searchLower)) ||
(e.Location != null && e.Location.ToLower().Contains(searchLower))
);
}
// IMPORTANT: Calculate totalCount AFTER applying filters
var totalCount = query.Count();
// Apply pagination
var events = query
.OrderByDescending(e => e.StartDate)
.ThenByDescending(e => e.CreatedDate)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(e => new Event_DTO
{
Id = e.Id,
Title = e.Title,
Description = e.Description,
Location = e.Location,
RecordID = e.RecordID,
RecordName = e.RecordName,
RecordTargetType = e.RecordTargetType,
ReminderMinutes = e.ReminderMinutes,
EventColor = e.EventColor,
StartDate = e.StartDate,
StartTime = e.StartTime,
EndDate = e.EndDate,
EndTime = e.EndTime,
AllDay = e.AllDay,
CreatedDate = e.CreatedDate,
createdby = e.createdby
})
.ToList();
return Ok(new DataResponse
{
Message = "Success",
Status = "200",
Data = new { rows = events, totalCount = result.TotalCount }
Data = new { rows = events, totalCount }
});
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
@ -148,62 +256,51 @@ namespace Api.SeaHavenIndustries.Controllers
// ===================================================================================
[HttpGet]
[Route("GetEventsForCalendar")]
public async Task<IActionResult> GetEventsForCalendar([FromQuery] DateTime? start, [FromQuery] DateTime? end, CancellationToken cancellationToken)
public IActionResult GetEventsForCalendar([FromQuery] DateTime? start, [FromQuery] DateTime? end)
{
try
{
var events = await _calendarService.GetEventsForCalendarAsync(start, end, cancellationToken);
var query = _db.Events.Where(e => e.IsDeleted != true);
return Ok(new DataResponse { Message = "Success", Status = "200", Data = events.Select(MapToResponse).ToList() });
// Filter by date range if provided
if (start.HasValue && end.HasValue)
{
query = query.Where(e =>
(e.StartDate >= start.Value && e.StartDate <= end.Value) ||
(e.EndDate >= start.Value && e.EndDate <= end.Value) ||
(e.StartDate <= start.Value && e.EndDate >= end.Value)
);
}
var events = query
.Select(e => new Event_DTO
{
Id = e.Id,
Title = e.Title,
Description = e.Description,
Location = e.Location,
RecordID = e.RecordID,
RecordName = e.RecordName,
RecordTargetType = e.RecordTargetType,
ReminderMinutes = e.ReminderMinutes,
EventColor = e.EventColor,
StartDate = e.StartDate,
StartTime = e.StartTime,
EndDate = e.EndDate,
EndTime = e.EndTime,
AllDay = e.AllDay,
CreatedDate = e.CreatedDate,
createdby = e.createdby
})
.ToList();
return Ok(new DataResponse { Message = "Success", Status = "200", Data = events });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
private static EditEventInput MapToInput(EditEvent_DTO eventDto)
{
return new EditEventInput
{
Id = eventDto.Id,
Title = eventDto.Title,
Description = eventDto.Description,
Location = eventDto.Location,
RecordID = eventDto.RecordID,
RecordName = eventDto.RecordName,
RecordTargetType = eventDto.RecordTargetType,
ReminderMinutes = eventDto.ReminderMinutes,
EventColor = eventDto.EventColor,
StartDate = eventDto.StartDate,
StartTime = eventDto.StartTime,
EndDate = eventDto.EndDate,
EndTime = eventDto.EndTime,
AllDay = eventDto.AllDay
};
}
private static Event_DTO MapToResponse(EventDTO dto)
{
return new Event_DTO
{
Id = dto.Id,
Title = dto.Title,
Description = dto.Description,
Location = dto.Location,
RecordID = dto.RecordID,
RecordName = dto.RecordName,
RecordTargetType = dto.RecordTargetType,
ReminderMinutes = dto.ReminderMinutes,
EventColor = dto.EventColor,
StartDate = dto.StartDate,
StartTime = dto.StartTime,
EndDate = dto.EndDate,
EndTime = dto.EndTime,
AllDay = dto.AllDay,
CreatedDate = dto.CreatedDate,
createdby = dto.createdby
};
}
}
}

View file

@ -1,101 +1,136 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Api.SeaHavenIndustries.Helper.ViewModel;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("[controller]")]
public class CommonController : Controller
{
private readonly ICommonService _commonService;
private readonly ILogger<CommonController> _logger;
public CommonController(ICommonService commonService, ILogger<CommonController> logger)
private readonly UserManager<ApplicationUser> _userManager;
private readonly ApplicationDbContext _db;
public CommonController(UserManager<ApplicationUser> userManager, ApplicationDbContext db)
{
_commonService = commonService;
_logger = logger;
_userManager = userManager;
_db = db;
}
[HttpGet]
[Route("GetUsers")]
public async Task<IActionResult> GetUsers(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
public async Task<IActionResult> GetUsers(string? search = "", int page = 1, int pageSize = 10)
{
try
{
var result = await _commonService.GetUsersPagedAsync(search, page, pageSize, cancellationToken);
var allUsersQuery = _db.Users
.Where(w => (w.FirstName ?? "").ToLower().Contains((search ?? "").ToLower()))
.OrderByDescending(d => d.Id);
var filteredUsers = result.Users.Select(u => new UserRoleViewModel
var totalCount = allUsersQuery.Count();
var paginatedUsers = allUsersQuery
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
// Calculate the number of pages
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
var filteredUsers = new List<UserRoleViewModel>();
foreach (var user in paginatedUsers)
{
RoleName = u.RoleName,
Email = u.Email,
UserName = u.UserName,
Date = u.Date,
Status = u.Status,
Id = u.Id
}).ToList();
var roles = await _userManager.GetRolesAsync(user);
if (!roles.Contains("Admin"))
{
filteredUsers.Add(new UserRoleViewModel
{
RoleName = roles.Count()>0?roles.First():"",
Email = user.Email,
UserName = user.FirstName,
Date = user.CreatedDate?.Date.ToString(),
Status = user.UniqueName,
Id = user.Id
});
}
}
// Create a view model containing the paged items and pagination metadata
var viewModel = new Pagination_DTO
{
Data = filteredUsers,
PageNumber = result.PageNumber,
PageSize = result.PageSize,
TotalCount = result.TotalCount,
TotalPages = result.TotalPages
PageNumber = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = totalPages
};
return Ok(viewModel);
}
catch (Exception ex)
{
return BadRequest(new { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new { Status = "Error", Message = ex.Message });
}
}
[HttpGet]
[Route("GetRoles")]
public async Task<IActionResult> GetRoles(CancellationToken cancellationToken)
public async Task<IActionResult> GetRoles()
{
try
{
var data = await _commonService.GetRolesAsync(cancellationToken);
var data = await _db.Roles.Select(s => new {Id=s.Name,s.Name}).ToListAsync();
return Ok(data);
}
catch (Exception ex)
{
return BadRequest(new { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new { Status = "Error", Message = ex.Message });
}
}
[HttpGet]
[Route("GetSitesName")]
public async Task<IActionResult> GetSiteName(CancellationToken cancellationToken)
public async Task<IActionResult> GetSiteName()
{
var data = await _commonService.GetSiteNamesAsync(cancellationToken);
var data =await _db.Locations.Select(n=> new
{
n.Id,
n.Name,
n.Title
}).ToListAsync();
return Ok(data);
}
[HttpGet]
[Route("GetContacts")]
public async Task<IActionResult> GetContacts(CancellationToken cancellationToken)
public async Task<IActionResult> GetContacts()
{
var data = await _commonService.GetContactsAsync(cancellationToken);
var data =await _db.Contacts.Select(n=> new
{
n.Id,
Name = (n.FirstName + " " + n.LastName).Trim(),
email=n.Email,
phone=n.PhoneNumber
}).ToListAsync();
return Ok(data);
}
[HttpGet]
[Route("GetCategories")]
public async Task<IActionResult> GetCategories(CancellationToken cancellationToken)
public async Task<IActionResult> GetCategories()
{
var data = await _commonService.GetCategoriesAsync(cancellationToken);
var data = await _db.Categories.Select(s=> new
{
s.Id,
s.Name
}).ToListAsync();
return Ok(data);
}
}
}
}

View file

@ -1,6 +1,7 @@
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace Api.SeaHavenIndustries.Controllers
{
@ -9,24 +10,29 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/[controller]")]
public class DashboardController : Controller
{
private readonly IDashboardService _dashboardService;
private readonly ApplicationDbContext _db;
public DashboardController(IDashboardService dashboardService)
public DashboardController(ApplicationDbContext db)
{
_dashboardService = dashboardService;
_db = db;
}
[HttpGet("Stats")]
public async Task<IActionResult> GetStats(CancellationToken cancellationToken)
public async Task<IActionResult> GetStats()
{
var stats = await _dashboardService.GetStatsAsync(cancellationToken);
var query = _db.workOrders.Where(w => w.istemplate != true);
var total = await query.CountAsync();
var open = await query.CountAsync(w => w.Status == "Open" || w.Status == "In Progress" || w.Status == "On Hold");
var notDispatched = await query.CountAsync(w => (w.Status == "Open") && (w.AssignTo == null || w.AssignTo == ""));
var completed = await query.CountAsync(w => w.Status == "Done");
return Ok(new
{
total = stats.Total,
open = stats.Open,
notDispatched = stats.NotDispatched,
completed = stats.Completed
total,
open,
notDispatched,
completed
});
}
}

View file

@ -1,7 +1,9 @@
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
{
@ -12,10 +14,12 @@ namespace Api.SeaHavenIndustries.Controllers
public class DispatchController : Controller
{
private readonly IDispatchService _dispatchService;
private readonly ApplicationDbContext _db; // Keep for complex queries
public DispatchController(IDispatchService dispatchService)
public DispatchController(IDispatchService dispatchService, ApplicationDbContext db)
{
_dispatchService = dispatchService;
_db = db;
}
[HttpGet]
@ -28,29 +32,317 @@ namespace Api.SeaHavenIndustries.Controllers
[FromQuery] string? sortBy = "dispatchedAt",
[FromQuery] string? sortDir = "desc",
[FromQuery] int page = 1,
[FromQuery] int pageSize = 25,
CancellationToken cancellationToken = default)
[FromQuery] int pageSize = 25)
{
var result = await _dispatchService.GetDispatchListAsync(
search, status, vendorId, dateFrom, dateTo, sortBy, sortDir, page, pageSize, cancellationToken);
var q = _db.Dispatches
.Include(d => d.Vendor)
.Include(d => d.WorkOrder).ThenInclude(w => w!.Locations)
.Where(d => d.IsDeleted == null || d.IsDeleted == false);
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.ToLower();
q = q.Where(d =>
(d.DispatchNumber ?? "").ToLower().Contains(s) ||
(d.PONumber ?? "").ToLower().Contains(s) ||
(d.Vendor != null && (d.Vendor.CompanyName ?? "").ToLower().Contains(s)) ||
(d.WorkOrder != null && (d.WorkOrder.InternalWONumber ?? "").ToLower().Contains(s)) ||
(d.WorkOrder != null && (d.WorkOrder.WorkerOrderTitle ?? "").ToLower().Contains(s)) ||
(d.WorkOrder != null && d.WorkOrder.Locations != null && (d.WorkOrder.Locations.Name ?? "").ToLower().Contains(s))
);
}
if (!string.IsNullOrWhiteSpace(status))
q = q.Where(d => d.Status == status);
if (vendorId.HasValue)
q = q.Where(d => d.VendorId == vendorId.Value);
if (dateFrom.HasValue)
q = q.Where(d => d.DispatchedAt >= dateFrom.Value);
if (dateTo.HasValue)
{
var end = dateTo.Value.Date.AddDays(1);
q = q.Where(d => d.DispatchedAt < end);
}
var total = await q.CountAsync();
var dir = (sortDir ?? "desc").ToLowerInvariant() == "asc";
switch ((sortBy ?? "").ToLowerInvariant())
{
case "ponumber":
q = dir ? q.OrderBy(d => d.PONumber) : q.OrderByDescending(d => d.PONumber);
break;
case "dispatchnumber":
q = dir ? q.OrderBy(d => d.DispatchNumber) : q.OrderByDescending(d => d.DispatchNumber);
break;
case "vendor":
q = dir ? q.OrderBy(d => d.Vendor!.CompanyName) : q.OrderByDescending(d => d.Vendor!.CompanyName);
break;
case "nte":
case "nteamount":
q = dir ? q.OrderBy(d => d.NTEAmount) : q.OrderByDescending(d => d.NTEAmount);
break;
case "status":
q = dir ? q.OrderBy(d => d.Status) : q.OrderByDescending(d => d.Status);
break;
case "scheduleddate":
q = dir ? q.OrderBy(d => d.ScheduledDate) : q.OrderByDescending(d => d.ScheduledDate);
break;
case "completeddate":
q = dir ? q.OrderBy(d => d.CompletedDate) : q.OrderByDescending(d => d.CompletedDate);
break;
default:
q = dir ? q.OrderBy(d => d.DispatchedAt) : q.OrderByDescending(d => d.DispatchedAt);
break;
}
var rows = await q
.Skip((Math.Max(page, 1) - 1) * pageSize)
.Take(pageSize)
.Select(d => new
{
d.Id,
d.DispatchNumber,
d.PONumber,
d.NTEAmount,
d.Status,
d.DispatchedAt,
d.ScheduledDate,
d.CompletedDate,
d.AcknowledgedAt,
d.VerifiedAt,
VendorId = d.VendorId,
VendorName = d.Vendor != null ? d.Vendor.CompanyName : null,
WorkOrderId = d.WorkOrderId,
PrimaryWOInternalNumber = d.WorkOrder != null ? d.WorkOrder.InternalWONumber : null,
PrimaryWOTitle = d.WorkOrder != null ? d.WorkOrder.WorkerOrderTitle : null,
LocationName = d.WorkOrder != null && d.WorkOrder.Locations != null ? d.WorkOrder.Locations.Name : null,
WorkOrderCount = _db.DispatchWorkOrders.Count(dwo => dwo.DispatchId == d.Id),
PendingUpliftCount = _db.DispatchUpliftRequests.Count(u => u.DispatchId == d.Id && u.Status == "Pending" && (u.IsDeleted == null || u.IsDeleted == false))
})
.ToListAsync();
return Ok(new DataResponse
{
Status = "Success",
Data = new { total = result.Total, page = result.Page, pageSize = result.PageSize, items = result.Items }
Data = new { total, page, pageSize, items = rows }
});
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
public async Task<IActionResult> GetById(int id)
{
var dispatch = await _dispatchService.GetDispatchDetailAsync(id, cancellationToken);
var dispatch = await _db.Dispatches
.Include(d => d.Vendor)
.Include(d => d.WorkOrder).ThenInclude(w => w!.Locations)
.FirstOrDefaultAsync(d => d.Id == id);
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
var linkedWOs = await _db.DispatchWorkOrders
.Where(dwo => dwo.DispatchId == id)
.Select(dwo => new
{
dwo.WorkOrderId,
InternalWONumber = dwo.WorkOrder != null ? dwo.WorkOrder.InternalWONumber : null,
Title = dwo.WorkOrder != null ? dwo.WorkOrder.WorkerOrderTitle : null,
Priority = dwo.WorkOrder != null ? dwo.WorkOrder.Priority : null,
Status = dwo.WorkOrder != null ? dwo.WorkOrder.Status : null,
LocationName = dwo.WorkOrder != null && dwo.WorkOrder.Locations != null ? dwo.WorkOrder.Locations.Name : null,
Trade = dwo.WorkOrder != null ? dwo.WorkOrder.Trade : null,
SubTrade = dwo.WorkOrder != null ? dwo.WorkOrder.SubTrade : null,
Problem = dwo.WorkOrder != null ? dwo.WorkOrder.Problem : null
}).ToListAsync();
if (linkedWOs.Count == 0 && dispatch.WorkOrderId.HasValue)
{
var wo = await _db.workOrders
.Include(w => w.Locations)
.FirstOrDefaultAsync(w => w.Id == dispatch.WorkOrderId.Value);
if (wo != null)
{
linkedWOs = new[] { new
{
WorkOrderId = wo.Id,
InternalWONumber = wo.InternalWONumber,
Title = wo.WorkerOrderTitle,
Priority = wo.Priority,
Status = wo.Status,
LocationName = wo.Locations != null ? wo.Locations.Name : null,
Trade = wo.Trade,
SubTrade = wo.SubTrade,
Problem = wo.Problem
}}.ToList();
}
}
var checklist = await _db.DispatchChecklistItems
.Where(c => c.DispatchId == id && (c.IsDeleted == null || c.IsDeleted == false))
.OrderBy(c => c.SortOrder)
.Select(c => new { c.Id, c.ItemText, c.IsCompleted, c.CompletedBy, c.CompletedAt, c.SortOrder, c.WorkOrderId })
.ToListAsync();
var signoffs = await _db.DispatchSignoffs
.Where(s => s.DispatchId == id && (s.IsDeleted == null || s.IsDeleted == false))
.Select(s => new { s.Id, s.SignoffType, s.Name, s.Signature, s.SignatureMethod, s.SignedAt })
.ToListAsync();
var rawComments = await (from c in _db.Comments
where c.DispatchId == id && (c.IsDeleted == null || c.IsDeleted == false)
join u in _db.Users on c.UserId equals u.Id into users
from u in users.DefaultIfEmpty()
orderby c.CreatedDate
select new
{
c.Id,
c.Commenttext,
c.Commenter,
c.CommentType,
c.RecordType,
c.CreatedDate,
c.UserId,
UserFirstName = u != null ? u.FirstName : null,
UserLastName = u != null ? u.LastName : null
})
.ToListAsync();
var comments = rawComments.Select(c => new
{
c.Id,
c.Commenttext,
c.Commenter,
c.CommentType,
c.RecordType,
c.CreatedDate,
UserName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim()
}).ToList();
var rawUplifts = await (from u in _db.DispatchUpliftRequests
where u.DispatchId == id && (u.IsDeleted == null || u.IsDeleted == false)
join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs
from dec in decs.DefaultIfEmpty()
orderby u.CreatedDate descending
select new
{
u.Id,
u.CurrentNTE,
u.RequestedNTE,
u.VendorReason,
u.Status,
u.RequiredTier,
u.RequestedByVendorName,
RequestedAt = u.CreatedDate,
u.DecidedAt,
u.DecisionNote,
DecidedByFirstName = dec != null ? dec.FirstName : null,
DecidedByLastName = dec != null ? dec.LastName : null
})
.ToListAsync();
var upliftRequests = rawUplifts.Select(u => new
{
u.Id,
u.CurrentNTE,
u.RequestedNTE,
Delta = u.RequestedNTE - (u.CurrentNTE ?? 0m),
u.VendorReason,
u.Status,
u.RequiredTier,
u.RequestedByVendorName,
u.RequestedAt,
u.DecidedAt,
u.DecisionNote,
DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim()
}).ToList();
var dispatchNumber = dispatch.DispatchNumber ?? "";
var workOrderIds = linkedWOs.Select(l => l.WorkOrderId).Where(wid => wid > 0).ToList();
var rawAudit = await (from a in _db.WorkOrderAuditLogs
where workOrderIds.Contains(a.WorkOrderId)
&& a.FieldName != null
&& a.FieldName.Contains(dispatchNumber)
join u in _db.Users on a.UserId equals u.Id into users
from u in users.DefaultIfEmpty()
orderby a.CreatedAt descending
select new
{
a.Id,
a.Action,
a.FieldName,
a.OldValue,
a.NewValue,
a.CreatedAt,
a.UserId,
UserFirstName = u != null ? u.FirstName : null,
UserLastName = u != null ? u.LastName : null
})
.ToListAsync();
var auditLog = rawAudit.Select(a => new
{
Id = a.Id,
Action = a.Action,
FieldName = a.FieldName,
OldValue = a.OldValue,
NewValue = a.NewValue,
CreatedAt = a.CreatedAt,
UserName = string.Join(" ", new[] { a.UserFirstName, a.UserLastName }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim()
}).ToList();
return Ok(new DataResponse
{
Status = "Success",
Data = dispatch
Data = new
{
dispatch.Id,
dispatch.DispatchNumber,
dispatch.PONumber,
dispatch.NTEAmount,
dispatch.Description,
dispatch.Status,
dispatch.ScheduledDate,
dispatch.CompletedDate,
dispatch.DispatchedAt,
dispatch.AcknowledgedAt,
dispatch.VerifiedAt,
dispatch.VerifiedBy,
dispatch.EmailSent,
dispatch.WorkOrderId,
Vendor = dispatch.Vendor == null ? null : new
{
dispatch.Vendor.Id,
dispatch.Vendor.CompanyName,
dispatch.Vendor.ContactName,
dispatch.Vendor.Email,
dispatch.Vendor.Phone,
dispatch.Vendor.Address,
dispatch.Vendor.City,
dispatch.Vendor.State,
dispatch.Vendor.Zip,
dispatch.Vendor.TradeSpecialties
},
PrimaryLocation = dispatch.WorkOrder?.Locations == null ? null : new
{
dispatch.WorkOrder.Locations.Id,
dispatch.WorkOrder.Locations.Name,
dispatch.WorkOrder.Locations.Address1,
dispatch.WorkOrder.Locations.City,
dispatch.WorkOrder.Locations.State,
dispatch.WorkOrder.Locations.Zip
},
WorkOrders = linkedWOs,
Checklist = checklist,
Signoffs = signoffs,
Comments = comments,
UpliftRequests = upliftRequests,
AuditLog = auditLog
}
});
}
}

View file

@ -1,10 +1,7 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace Api.SeaHavenIndustries.Controllers
{
@ -13,82 +10,78 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/[controller]")]
public class DropdownOptionsController : Controller
{
private readonly IDropdownOptionsService _dropdownOptionsService;
private readonly ILogger<DropdownOptionsController> _logger;
private readonly ApplicationDbContext _db;
public DropdownOptionsController(IDropdownOptionsService dropdownOptionsService, ILogger<DropdownOptionsController> logger)
public DropdownOptionsController(ApplicationDbContext db)
{
_dropdownOptionsService = dropdownOptionsService;
_logger = logger;
_db = db;
}
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] string? category = null, CancellationToken cancellationToken = default)
public async Task<IActionResult> GetAll([FromQuery] string? category = null)
{
var data = await _dropdownOptionsService.GetAllAsync(category, cancellationToken);
var query = _db.DropdownOptions.Where(d => d.IsActive);
if (!string.IsNullOrWhiteSpace(category))
query = query.Where(d => d.Category == category);
var data = await query.OrderBy(d => d.Category).ThenBy(d => d.SortOrder).ThenBy(d => d.Value).ToListAsync();
return Ok(data);
}
[HttpGet("ByCategory/{category}")]
public async Task<IActionResult> GetByCategory(string category, [FromQuery] string? parent = null, CancellationToken cancellationToken = default)
public async Task<IActionResult> GetByCategory(string category, [FromQuery] string? parent = null)
{
var data = await _dropdownOptionsService.GetByCategoryAsync(category, parent, cancellationToken);
var query = _db.DropdownOptions.Where(d => d.IsActive && d.Category == category);
if (!string.IsNullOrWhiteSpace(parent))
query = query.Where(d => d.ParentValue == parent);
var data = await query.OrderBy(d => d.SortOrder).ThenBy(d => d.Value).ToListAsync();
return Ok(data);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] DropdownOption_DTO model, CancellationToken cancellationToken)
public async Task<IActionResult> Create([FromBody] DropdownOption model)
{
try
{
var created = await _dropdownOptionsService.CreateAsync(MapToService(model), cancellationToken);
return Ok(created);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { message = _logger.Sanitize(ex, "The dropdown option could not be saved") });
}
if (string.IsNullOrWhiteSpace(model.Category) || string.IsNullOrWhiteSpace(model.Value))
return BadRequest(new { message = "Category and Value are required" });
var existing = await _db.DropdownOptions
.AnyAsync(d => d.Category == model.Category && d.Value == model.Value && d.ParentValue == model.ParentValue);
if (existing)
return BadRequest(new { message = "This option already exists" });
model.Id = 0;
model.IsActive = true;
_db.DropdownOptions.Add(model);
await _db.SaveChangesAsync();
return Ok(model);
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, [FromBody] DropdownOption_DTO model, CancellationToken cancellationToken)
public async Task<IActionResult> Update(int id, [FromBody] DropdownOption model)
{
try
{
var updated = await _dropdownOptionsService.UpdateAsync(id, MapToService(model), cancellationToken);
return Ok(updated);
}
catch (KeyNotFoundException)
{
var item = await _db.DropdownOptions.FindAsync(id);
if (item == null)
return NotFound(new { message = "Option not found" });
}
item.Value = model.Value;
item.ParentValue = model.ParentValue;
item.SortOrder = model.SortOrder;
item.IsActive = model.IsActive;
await _db.SaveChangesAsync();
return Ok(item);
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
public async Task<IActionResult> Delete(int id)
{
try
{
await _dropdownOptionsService.DeleteAsync(id, cancellationToken);
return Ok(new { message = "Deleted" });
}
catch (KeyNotFoundException)
{
var item = await _db.DropdownOptions.FindAsync(id);
if (item == null)
return NotFound(new { message = "Option not found" });
}
}
private static DropdownOptionDTO MapToService(DropdownOption_DTO model)
{
return new DropdownOptionDTO
{
Id = model.Id,
Category = model.Category,
Value = model.Value,
ParentValue = model.ParentValue,
SortOrder = model.SortOrder,
IsActive = model.IsActive
};
_db.DropdownOptions.Remove(item);
await _db.SaveChangesAsync();
return Ok(new { message = "Deleted" });
}
}
}

View file

@ -1,13 +1,14 @@
using System.Security.Claims;
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using Microsoft.EntityFrameworkCore;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.DataServices.Interfaces;
[ApiController]
[Authorize]
@ -16,12 +17,16 @@ using SeaHaven.Services.Interfaces;
public class FollowUpController : ControllerBase
{
private readonly IFollowUpService _followUpService;
private readonly ILogger<FollowUpController> _logger;
private readonly IFollowUpDataService _followUpDataService; // Keep for direct entity operations
private readonly ApplicationDbContext _db; // Keep for User lookups
private readonly UserManager<ApplicationUser> _users;
public FollowUpController(IFollowUpService followUpService, ILogger<FollowUpController> logger)
public FollowUpController(IFollowUpService followUpService, IFollowUpDataService followUpDataService, ApplicationDbContext db, UserManager<ApplicationUser> users)
{
_followUpService = followUpService;
_logger = logger;
_followUpDataService = followUpDataService;
_db = db;
_users = users;
}
[HttpGet("GetFollowUpList")]
@ -29,31 +34,70 @@ public class FollowUpController : ControllerBase
string? search = "",
string? status = "Open",
int page = 1,
int pageSize = 12,
CancellationToken cancellationToken = default)
int pageSize = 12)
{
var pagedResult = await _followUpService.GetFollowUpListAsync(page, pageSize, search, status, cancellationToken);
status = string.IsNullOrWhiteSpace(status) ? "Open" : status;
var pagedResult = await _followUpService.GetFollowUpsPagedAsync(page, pageSize, search, status);
var items = pagedResult.Items;
var data = items.Select(x => new
var rows = items.Select(x => new
{
x.Id,
x.Reason,
x.ScheduleDate,
x.ScheduleStartTime,
x.EmployeeId,
x.WorkOrderId,
x.AccountId,
x.LocationId,
x.AccountName,
x.AssignedTo,
x.LocationName,
x.WorkOrderNumber,
x.InternalWONumber,
x.Status,
x.CreatedDate,
x.CreatedById,
x.CreatedByName
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)
.Where(id => !string.IsNullOrEmpty(id))
.Distinct()
.ToList();
var creators = await _db.Users
.Where(u => creatorIds.Contains(u.Id))
.Select(u => new { u.Id, u.FirstName, u.LastName, u.UserName })
.ToDictionaryAsync(u => u.Id, u => u);
string NameFor(string? userId)
{
if (string.IsNullOrEmpty(userId) || !creators.TryGetValue(userId, out var u)) return "";
var full = ((u.FirstName ?? "") + " " + (u.LastName ?? "")).Trim();
return string.IsNullOrWhiteSpace(full) ? (u.UserName ?? "") : full;
}
var data = rows.Select(r => new
{
r.Id,
r.Reason,
r.ScheduleDate,
r.ScheduleStartTime,
r.EmployeeId,
r.WorkOrderId,
r.AccountId,
r.LocationId,
r.AccountName,
r.AssignedTo,
r.LocationName,
r.WorkOrderNumber,
r.InternalWONumber,
r.Status,
r.CreatedDate,
r.CreatedById,
CreatedByName = NameFor(r.CreatedById)
}).ToList();
return Ok(new Pagination_DTO
@ -67,12 +111,26 @@ public class FollowUpController : ControllerBase
}
[HttpGet("GetById")]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
public async Task<IActionResult> GetById(int id)
{
var model = await _followUpService.GetFollowUpDetailAsync(id, cancellationToken);
var model = await _followUpDataService.GetByIdWithDetailsAsync(id);
if (model == null) return NotFound();
string createdByName = "";
if (!string.IsNullOrEmpty(model.createdby))
{
var u = await _db.Users
.Where(x => x.Id == model.createdby)
.Select(x => new { x.FirstName, x.LastName, x.UserName })
.FirstOrDefaultAsync();
if (u != null)
{
var n = ((u.FirstName ?? "") + " " + (u.LastName ?? "")).Trim();
createdByName = string.IsNullOrWhiteSpace(n) ? (u.UserName ?? "") : n;
}
}
return Ok(new
{
model.Id,
@ -83,20 +141,22 @@ public class FollowUpController : ControllerBase
model.WorkOrderId,
model.AccountId,
model.LocationId,
model.AccountName,
model.AssignedTo,
model.LocationName,
model.WorkOrderNumber,
model.InternalWONumber,
AccountName = model.Account?.Name ?? "",
AssignedTo = model.Employee != null
? (model.Employee.FirstName + " " + model.Employee.LastName)
: "",
LocationName = model.Location?.Name ?? "",
WorkOrderNumber = model.WorkOrder?.WorkerOrderNumber ?? "",
InternalWONumber = model.WorkOrder?.InternalWONumber ?? "",
model.Status,
model.CreatedDate,
model.CreatedById,
model.CreatedByName
CreatedById = model.createdby,
CreatedByName = createdByName
});
}
[HttpPost("Create")]
public async Task<IActionResult> Create(FollowUp_DTO dto, CancellationToken cancellationToken)
public async Task<IActionResult> Create(FollowUp_DTO dto)
{
try
{
@ -104,9 +164,23 @@ public class FollowUpController : ControllerBase
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
var id = await _followUpService.CreateOpenFollowUpAsync(MapToCreateRequest(dto), userId, cancellationToken);
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 = id });
await _followUpDataService.AddAsync(model);
return Ok(new DataResponse { Status = "200", Message = "Follow Up created", Data = model.Id });
}
catch (ValidationException vex)
{
@ -115,16 +189,29 @@ public class FollowUpController : ControllerBase
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
}
}
[HttpPost("Update")]
public async Task<IActionResult> Update(FollowUp_DTO dto, CancellationToken cancellationToken)
public async Task<IActionResult> Update(FollowUp_DTO dto)
{
try
{
await _followUpService.UpdateFollowUpFieldsAsync(dto.Id, MapToUpdateRequest(dto), cancellationToken);
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;
await _followUpDataService.UpdateAsync(model);
return Ok(new DataResponse { Status = "200", Message = "Follow Up updated" });
}
catch (ValidationException vex)
@ -138,37 +225,47 @@ public class FollowUpController : ControllerBase
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
}
}
[HttpPost("Complete")]
public async Task<IActionResult> Complete(int id, CancellationToken cancellationToken)
public async Task<IActionResult> Complete(int id)
{
var found = await _followUpService.SetFollowUpStatusAsync(id, "Completed", cancellationToken);
if (!found) return NotFound();
var model = await _followUpDataService.GetByIdAsync(id);
if (model == null) return NotFound();
model.Status = "Completed";
model.LastModificationTime = DateTime.UtcNow;
await _followUpDataService.UpdateAsync(model);
return Ok();
}
[HttpPost("Cancel")]
public async Task<IActionResult> Cancel(int id, CancellationToken cancellationToken)
public async Task<IActionResult> Cancel(int id)
{
var found = await _followUpService.SetFollowUpStatusAsync(id, "Cancelled", cancellationToken);
if (!found) return NotFound();
var model = await _followUpDataService.GetByIdAsync(id);
if (model == null) return NotFound();
model.Status = "Cancelled";
model.LastModificationTime = DateTime.UtcNow;
await _followUpDataService.UpdateAsync(model);
return Ok();
}
[HttpPost("Delete")]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
public async Task<IActionResult> Delete(int id)
{
try
{
var found = await _followUpService.RemoveFollowUpAsync(id, cancellationToken);
if (!found)
var model = await _followUpDataService.GetByIdAsync(id);
if (model == null)
return NotFound(new Response { Status = "Error", Message = "Follow Up not found" });
await _followUpDataService.DeleteAsync(id);
return Ok(new DataResponse { Status = "200", Message = "Follow Up deleted" });
}
catch (KeyNotFoundException)
@ -177,36 +274,7 @@ public class FollowUpController : ControllerBase
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
}
}
private static FollowUpCreateRequestDTO MapToCreateRequest(FollowUp_DTO dto)
{
return new FollowUpCreateRequestDTO
{
Reason = dto.Reason,
ScheduleDate = dto.ScheduleDate,
ScheduleStartTime = dto.ScheduleStartTime,
EmployeeId = dto.EmployeeId,
WorkOrderId = dto.WorkOrderId,
AccountId = dto.AccountId,
LocationId = dto.LocationId
};
}
private static FollowUpUpdateRequestDTO MapToUpdateRequest(FollowUp_DTO dto)
{
return new FollowUpUpdateRequestDTO
{
Id = dto.Id,
Reason = dto.Reason,
ScheduleDate = dto.ScheduleDate,
ScheduleStartTime = dto.ScheduleStartTime,
EmployeeId = dto.EmployeeId,
WorkOrderId = dto.WorkOrderId,
AccountId = dto.AccountId,
LocationId = dto.LocationId
};
}
}

View file

@ -1,12 +1,13 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using Microsoft.EntityFrameworkCore;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.DataServices.Interfaces;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
{
@ -17,12 +18,12 @@ namespace Api.SeaHavenIndustries.Controllers
public class LocationController : Controller
{
private readonly ILocationService _locationService;
private readonly ILogger<LocationController> _logger;
private readonly ILocationDataService _locationDataService; // Keep for direct entity operations
public LocationController(ILocationService locationService, ILogger<LocationController> logger)
public LocationController(ILocationService locationService, ILocationDataService locationDataService)
{
_locationService = locationService;
_logger = logger;
_locationDataService = locationDataService;
}
[HttpGet("sites")]
@ -33,9 +34,9 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpGet("GetLocationList")]
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10)
{
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, cancellationToken);
var pagedResult = await _locationService.GetLocationsPagedAsync(page, pageSize, search);
var data = pagedResult.Items.Select(l => new
{
@ -67,9 +68,9 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpGet("{id}")]
public async Task<IActionResult> GetLocationById(int id, CancellationToken cancellationToken)
public async Task<IActionResult> GetLocationById(int id)
{
var location = await _locationService.GetLocationDetailAsync(id, cancellationToken);
var location = await _locationDataService.GetByIdAsync(id);
if (location == null)
return NotFound(new Response { Status = "Error", Message = "Location not found" });
@ -95,11 +96,25 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpPost]
public async Task<IActionResult> AddLocation([FromBody] Location_DTO model, CancellationToken cancellationToken)
public async Task<IActionResult> AddLocation([FromBody] Location_DTO model)
{
try
{
await _locationService.CreateLocationFromRequestAsync(MapToCreateRequest(model), cancellationToken);
var location = new Locations
{
Name = model.Name,
Title = model.Title,
Address1 = model.Address,
City = model.City,
State = model.State,
Zip = model.ZipCode,
PhoneNumber = model.Phone,
Email = model.ContactEmail,
Status = model.Status,
CreatedDate = DateTime.Now
};
await _locationDataService.AddAsync(location);
return Ok(new DataResponse { Message = "Location Created Successfully", Status = "200" });
}
catch (ValidationException vex)
@ -109,16 +124,31 @@ namespace Api.SeaHavenIndustries.Controllers
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
}
}
[HttpPut("{id}")]
public async Task<IActionResult> EditLocation(int id, [FromBody] EditLocation_DTO model, CancellationToken cancellationToken)
public async Task<IActionResult> EditLocation(int id, [FromBody] EditLocation_DTO model)
{
try
{
await _locationService.UpdateLocationFromRequestAsync(id, MapToUpdateRequest(model), cancellationToken);
var exist = await _locationDataService.GetByIdAsync(id);
if (exist == null)
return NotFound(new Response { Status = "Error", Message = "Location not found" });
exist.Name = model.Name;
exist.Title = model.Title;
exist.Address1 = model.Address;
exist.City = model.City;
exist.State = model.State;
exist.Zip = model.ZipCode;
exist.PhoneNumber = model.Phone;
exist.Email = model.ContactEmail;
exist.Status = model.Status;
exist.LastModificationTime = DateTime.Now;
await _locationDataService.UpdateAsync(exist);
return Ok(new DataResponse { Message = "Location Updated Successfully", Status = "200" });
}
catch (ValidationException vex)
@ -132,19 +162,20 @@ namespace Api.SeaHavenIndustries.Controllers
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteLocation(int id, CancellationToken cancellationToken)
public async Task<IActionResult> DeleteLocation(int id)
{
try
{
var found = await _locationService.DeleteLocationByIdAsync(id, cancellationToken);
if (!found)
var exist = await _locationDataService.GetByIdAsync(id);
if (exist == null)
return NotFound(new Response { Status = "Error", Message = "Location not found" });
await _locationDataService.DeleteAsync(id);
return Ok(new DataResponse { Message = "Location Deleted Successfully", Status = "200" });
}
catch (KeyNotFoundException)
@ -153,46 +184,14 @@ namespace Api.SeaHavenIndustries.Controllers
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
}
}
[HttpGet("DeleteLocation")]
public async Task<IActionResult> DeleteLocationByQuery(int id, CancellationToken cancellationToken)
public async Task<IActionResult> DeleteLocationByQuery(int id)
{
return await DeleteLocation(id, cancellationToken);
}
private static LocationCreateRequestDTO MapToCreateRequest(Location_DTO model)
{
return new LocationCreateRequestDTO
{
Name = model.Name,
Title = model.Title,
Address = model.Address,
City = model.City,
State = model.State,
ZipCode = model.ZipCode,
Phone = model.Phone,
ContactEmail = model.ContactEmail,
Status = model.Status
};
}
private static LocationUpdateRequestDTO MapToUpdateRequest(EditLocation_DTO model)
{
return new LocationUpdateRequestDTO
{
Name = model.Name,
Title = model.Title,
Address = model.Address,
City = model.City,
State = model.State,
ZipCode = model.ZipCode,
Phone = model.Phone,
ContactEmail = model.ContactEmail,
Status = model.Status
};
return await DeleteLocation(id);
}
}
}

View file

@ -1,11 +1,12 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using static System.Runtime.InteropServices.JavaScript.JSType;
namespace Api.SeaHavenIndustries.Controllers
{
@ -14,82 +15,178 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/Quotes")]
public class QuotesController : Controller
{
private readonly IQuotesService _quotesService;
private readonly ILogger<QuotesController> _logger;
public QuotesController(IQuotesService quotesService, ILogger<QuotesController> logger)
private readonly UserManager<ApplicationUser> _userManager;
private readonly ApplicationDbContext _db;
private readonly IWebHostEnvironment _webHostEnvironment;
IHttpContextAccessor _httpContext;
public QuotesController(UserManager<ApplicationUser> userManager, ApplicationDbContext db, IWebHostEnvironment webHostEnvironment, IHttpContextAccessor httpContext)
{
_quotesService = quotesService;
_logger = logger;
_userManager = userManager;
_db = db;
_webHostEnvironment = webHostEnvironment;
_httpContext = httpContext;
}
[HttpPost]
[Route("AddQuote")]
public async Task<IActionResult> AddQuote([FromBody] Quote_DTO model, CancellationToken cancellationToken)
public async Task<IActionResult> AddQuote([FromBody] Quote_DTO model)
{
try
{
await _quotesService.CreateQuoteAsync(MapToCreateInput(model), cancellationToken);
Random rnd = new Random();
int id = rnd.Next(1, 99999);
Quotes quotes = new Quotes();
quotes.QuotId = id.ToString();
quotes.ContactId = model.ContactId;
quotes.PO = model.PO;
quotes.TT = model.TT;
quotes.ProjectName = model.ProjectName;
quotes.Date = DateTime.Parse(model.Date);
quotes.WorkorderId = model.WorkorderId;
quotes.SiteName = model.SiteName;
quotes.SiteId = model.SiteId;
quotes.BillingAddress = model.BillingAddress;
quotes.LineItems = quotes.LineItems == null ? new List<QuotesLineItems>() : quotes.LineItems;
if (model.LineItems != null && model.LineItems.Any())
{
foreach (var lineItemDto in model.LineItems)
{
QuotesLineItems lineItem = new QuotesLineItems()
{
LineitemNumber = lineItemDto.LineitemNumber,
Lineitem = lineItemDto.Lineitem,
Descriptions = lineItemDto.Descriptions
};
quotes.LineItems.Add(lineItem);
}
}
_db.Quotes.Add(quotes);
await _db.SaveChangesAsync();
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpPut]
[Route("EditQuote")]
public async Task<IActionResult> EditQuote([FromBody] EditQuote_DTO model, CancellationToken cancellationToken)
public async Task<IActionResult> EditQuote([FromBody] EditQuote_DTO model)
{
try
{
var updated = await _quotesService.UpdateQuoteAsync(MapToUpdateInput(model), cancellationToken);
if (!updated)
return BadRequest("ID not matched!");
var quotes = _db.Quotes.Where(w => w.Id == model.id).FirstOrDefault();
if (quotes != null)
{
quotes.QuotId = model.QuotId;
quotes.ContactId = model.ContactId;
quotes.PO = model.PO;
quotes.TT = model.TT;
quotes.ProjectName = model.ProjectName;
quotes.Date = DateTime.Parse(model.Date);
quotes.WorkorderId = model.WorkorderId;
quotes.SiteName = model.SiteName;
quotes.SiteId = model.SiteId;
quotes.BillingAddress = model.BillingAddress;
var existQuoteLine = _db.QuotesLines.Where(w => w.QuoteId == model.id).ToList();
if (existQuoteLine.Count() > 0)
{
_db.QuotesLines.RemoveRange(existQuoteLine);
}
quotes.LineItems = quotes.LineItems == null ? new List<QuotesLineItems>() : quotes.LineItems;
if (model.LineItems != null && model.LineItems.Any())
{
foreach (var lineItemDto in model.LineItems)
{
QuotesLineItems lineItem = new QuotesLineItems()
{
LineitemNumber = lineItemDto.LineitemNumber,
Lineitem = lineItemDto.Lineitem,
Descriptions = lineItemDto.Descriptions
};
quotes.LineItems.Add(lineItem);
}
}
_db.Update(quotes);
}
else
{
return BadRequest("ID not matched!");
}
await _db.SaveChangesAsync();
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpDelete]
[Route("DeleteQuote")]
public async Task<IActionResult> DeleteQuote(EditQuote_DTO model, CancellationToken cancellationToken)
public IActionResult DeleteQuote(EditQuote_DTO model)
{
int id = model.id;
try
try
{
var deleted = await _quotesService.DeleteQuoteAsync(id, cancellationToken);
if (!deleted)
var data = _db.Quotes.Where(w => w.Id == id).FirstOrDefault();
if (data != null)
{
var existlineitem = _db.QuotesLines.Where(w => w.QuoteId == id).ToList();
if (existlineitem.Any())
{
_db.QuotesLines.RemoveRange(existlineitem);
}
_db.Quotes.Remove(data);
}
else
{
return BadRequest("ID not matched!");
}
_db.SaveChanges();
return Ok(new DataResponse { Message = "Removed Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpGet]
[Route("GetQuoutes")]
public async Task<IActionResult> GetQuotes(string search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
public IActionResult GetQuotes(string search = "", int page = 1, int pageSize = 10)
{
var result = await _quotesService.GetQuotesPagedAsync(page, pageSize, search, cancellationToken);
var totalCount = _db.Quotes.Count();
var data = _db.Quotes.Include(s => s.WorkOrder).AsNoTracking().Where(w => (w.QuotId ?? "").ToLower().Contains(search.ToLower()) || (w.SiteName ?? "").ToLower().Contains(search.ToLower()) || (w.WorkOrder != null && (w.WorkOrder.WorkerOrderTitle ?? "").ToLower().Contains(search.ToLower()))).Select(s => new
{
s.Id,
QuoteId = "QO-" + s.QuotId,
WorkOrder = s.WorkOrder != null ? s.WorkOrder.WorkerOrderNumber : "",
s.ProjectName,
TotalAmount = "$" + (s.LineItems != null ? s.LineItems.Sum(l => l.Lineitem ?? 0) : 0).ToString(),
s.SiteName,
s.SiteId,
s.PO,
s.TT,
s.BillingAddress,
s.Status,
s.ContactId,
s.Date,
lineitems = s.LineItems.Select(q => new { q.Descriptions, q.Lineitem, q.LineitemNumber}),
}).OrderByDescending(d => d.Id).Skip((page - 1) * pageSize)
.Take(pageSize).ToList();
var totalPages = (int)Math.Ceiling(result.TotalCount / (double)pageSize);
// Calculate the number of pages
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
// Create a view model containing the paged items and pagination metadata
var viewModel = new Pagination_DTO
{
Data = result.Items,
Data = data,
PageNumber = page,
PageSize = pageSize,
TotalCount = result.TotalCount,
TotalCount = totalCount,
TotalPages = totalPages
};
return Ok(viewModel);
@ -97,85 +194,66 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet]
[Route("GetQuoutesdetailsAgainstWorkOrder")]
public async Task<IActionResult> GetQuoutesdetailsAgainstWorkOrder(int woId, CancellationToken cancellationToken)
public async Task<IActionResult> GetQuoutesdetailsAgainstWorkOrder(int woId)
{
var data = await _quotesService.GetWorkOrderQuoteDetailsAsync(woId, cancellationToken);
var data = await _db.workOrders.Include(s => s.Locations).Where(w => w.Id == woId).Select(s => new
{
s.Id,
s.PO,
s.TT,
Title = s.Locations != null ? s.Locations.Title : "",
Name = s.Locations != null ? s.Locations.Name : ""
}).ToListAsync();
return Ok(data);
}
[HttpGet]
[Route("GetQuoutesById")]
public async Task<IActionResult> GetQuoutesById(int id, CancellationToken cancellationToken)
public async Task<IActionResult> GetQuoutesById(int id)
{
var data = await _quotesService.GetQuoteDetailsByIdAsync(id, cancellationToken);
var data = await _db.Quotes.Where(s => s.Id == id).Include(s => s.WorkOrder).Select(s => new
{
s.Id,
s.QuotId,
s.Date,
Name = s.Contacts != null ? (s.Contacts.FirstName + " " + s.Contacts.LastName).Trim() : "",
s.PO,
s.TT,
s.SiteName,
s.ProjectName,
s.SiteId,
s.BillingAddress,
s.ShippingAddress,
lineitems = s.LineItems.Select(q => new { q.Descriptions, q.Lineitem }),
TotalAmount = "$" + (s.LineItems != null ? s.LineItems.Sum(l => l.Lineitem ?? 0) : 0).ToString()
}).FirstOrDefaultAsync();
return Ok(data);
}
[HttpGet]
[Route("GetReporting")]
public async Task<IActionResult> GetReporting(CancellationToken cancellationToken)
public async Task<IActionResult> GetReporting()
{
var data = await _quotesService.GetReportingAsync(cancellationToken);
var totalCreatedWorkOrder = await _db.workOrders.CountAsync();
var totalCompleted = _db.workOrders.Count(w => w.Status == "Done");
var totalSev1 = _db.workOrders.Count(w => w.Priority == "Sev 1");
var totalSev2 = _db.workOrders.Count(w => w.Priority == "Sev 2");
var totalSev3 = _db.workOrders.Count(w => w.Priority == "Sev 3");
var totalSev4 = _db.workOrders.Count(w => w.Priority == "Sev 4");
var totalSev5 = _db.workOrders.Count(w => w.Priority == "Sev 5");
var reportingDto = new Reporting_DTO
var data = new Reporting_DTO
{
TotalCreatedWorkOrder = data.TotalCreatedWorkOrder,
TotalCompleted = data.TotalCompleted,
TotalSev1 = data.TotalSev1,
TotalSev2 = data.TotalSev2,
TotalSev3 = data.TotalSev3,
TotalSev4 = data.TotalSev4,
TotalSev5 = data.TotalSev5
TotalCreatedWorkOrder = totalCreatedWorkOrder,
TotalCompleted = totalCompleted,
TotalSev1 = totalSev1,
TotalSev2 = totalSev2,
TotalSev3 = totalSev3,
TotalSev4 = totalSev4,
TotalSev5 = totalSev5
};
return Ok(reportingDto);
}
private static CreateQuoteInput MapToCreateInput(Quote_DTO dto)
{
return new CreateQuoteInput
{
QuotId = dto.QuotId,
ContactId = dto.ContactId,
PO = dto.PO,
TT = dto.TT,
ProjectName = dto.ProjectName,
Date = dto.Date != null ? DateTime.Parse(dto.Date) : null,
WorkorderId = dto.WorkorderId,
SiteName = dto.SiteName,
SiteId = dto.SiteId,
BillingAddress = dto.BillingAddress,
LineItems = dto.LineItems?.Select(li => new QuoteLineItemInput
{
LineitemNumber = li.LineitemNumber,
Lineitem = li.Lineitem,
Descriptions = li.Descriptions
}).ToList()
};
}
private static UpdateQuoteInput MapToUpdateInput(EditQuote_DTO dto)
{
return new UpdateQuoteInput
{
Id = dto.id,
QuotId = dto.QuotId,
ContactId = dto.ContactId,
PO = dto.PO,
TT = dto.TT,
ProjectName = dto.ProjectName,
Date = dto.Date != null ? DateTime.Parse(dto.Date) : null,
WorkorderId = dto.WorkorderId,
SiteName = dto.SiteName,
SiteId = dto.SiteId,
BillingAddress = dto.BillingAddress,
LineItems = dto.LineItems?.Select(li => new QuoteLineItemInput
{
LineitemNumber = li.LineitemNumber,
Lineitem = li.Lineitem,
Descriptions = li.Descriptions
}).ToList()
};
return Ok(data);
}
}
}

View file

@ -1,11 +1,11 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Api.SeaHavenIndustries.Helper.ViewModel;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using static System.Runtime.InteropServices.JavaScript.JSType;
namespace Api.SeaHavenIndustries.Controllers
{
@ -14,78 +14,107 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("[controller]")]
public class SettingsController : Controller
{
private readonly ISettingsService _settingsService;
private readonly ILogger<SettingsController> _logger;
public SettingsController(ISettingsService settingsService, ILogger<SettingsController> logger)
private readonly UserManager<ApplicationUser> _userManager;
private readonly ApplicationDbContext _db;
public SettingsController(UserManager<ApplicationUser> userManager, ApplicationDbContext db)
{
_settingsService = settingsService;
_logger = logger;
_userManager = userManager;
_db = db;
}
[HttpPost]
[Route("AddCategory")]
public async Task<IActionResult> AddCategory(Category_DTO model, CancellationToken cancellationToken)
public async Task<IActionResult> AddCategory(Category_DTO model)
{
try
{
await _settingsService.AddCategoryAsync(model.Name, cancellationToken);
Category category = new Category();
category.Name = model.Name;
_db.Categories.Add(category);
await _db.SaveChangesAsync();
return Ok(new DataResponse { Message = "Successfully Saved Data", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpPost]
[Route("EditCategory")]
public async Task<IActionResult> EditCategory(EditCategory_DTO model, CancellationToken cancellationToken)
public async Task<IActionResult> EditCategory(EditCategory_DTO model)
{
try
{
var found = await _settingsService.UpdateCategoryAsync(model.Id, model.Name, cancellationToken);
if (!found)
var exist = _db.Categories.Where(w => w.Id == model.Id).FirstOrDefault();
if (exist == null)
return BadRequest(new Response { Status = "Error", Message = "Category not found" });
exist.Name = model.Name;
_db.Categories.Update(exist);
await _db.SaveChangesAsync();
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpGet]
[Route("DeleteCategory")]
public async Task<IActionResult> DeleteCategory(int id, CancellationToken cancellationToken)
public async Task<IActionResult> DeleteCategory(int id)
{
try
{
var found = await _settingsService.DeleteCategoryAsync(id, cancellationToken);
if (!found)
var exist = _db.Categories.Where(w => w.Id == id).FirstOrDefault();
if (exist != null)
{
var chkexist = _db.Templates.Where(w => w.CategoryId == id).ToList();
if (chkexist != null)
{
_db.RemoveRange(chkexist);
}
var chkworkordercategory = _db.workOrderCategories.Where(w => w.CategoryId == id).ToList();
if (chkworkordercategory != null)
{
_db.RemoveRange(chkworkordercategory);
}
_db.Remove(exist);
}
else
{
return BadRequest(new Response { Status = "Error", Message = "ID not matched!" });
}
await _db.SaveChangesAsync();
return Ok(new DataResponse { Message = "Deleted Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpGet]
[Route("GetCategoryList")]
public async Task<IActionResult> GetCategoryList(int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
public IActionResult GetCategoryList(int page = 1, int pageSize = 10)
{
var (items, total) = await _settingsService.GetCategoryListAsync(page, pageSize, cancellationToken);
var totalCount = _db.Categories.Count();
var data = _db.Categories.Select(s => new
{
s.Id,
s.Name
}).OrderByDescending(d => d.Id).Skip((page - 1) * pageSize)
.Take(pageSize).ToList();
var totalPages = (int)Math.Ceiling(total / (double)pageSize);
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
var viewModel = new Pagination_DTO
{
Data = items,
Data = data,
PageNumber = page,
PageSize = pageSize,
TotalCount = total,
TotalCount = totalCount,
TotalPages = totalPages
};
return Ok(viewModel);
@ -93,79 +122,117 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet]
[Route("GetTemplates")]
public async Task<IActionResult> GetTemplates(int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
public IActionResult GetTemplates(int page = 1, int pageSize = 10)
{
try
{
var (items, total) = await _settingsService.GetTemplatesAsync(page, pageSize, cancellationToken);
var totalCount = _db.workOrders.Where(w=> w.istemplate == true).Count();
var data = _db.workOrders.Where(w => w.istemplate == true).OrderByDescending(d => d.Id).Skip((page - 1) * pageSize)
.Take(pageSize).ToList();
var totalPages = (int)Math.Ceiling(total / (double)pageSize);
// Calculate the number of pages
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
// Create a view model containing the paged items and pagination metadata
var viewModel = new Pagination_DTO
{
Data = items,
Data = data,
PageNumber = page,
PageSize = pageSize,
TotalCount = total,
TotalCount = totalCount,
TotalPages = totalPages
};
return Ok(viewModel);
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpGet]
[Route("GetUsers")]
public async Task<IActionResult> GetUsers(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
public async Task<IActionResult> GetUsers(string? search = "", int page = 1, int pageSize = 10)
{
try
{
var (users, total) = await _settingsService.GetUsersAsync(search, page, pageSize, cancellationToken);
var totalCount = _db.Users.Count();
var allUsers = _db.Users.Where(w => (w.FirstName ?? "").ToLower().Contains((search ?? "").ToLower())).OrderByDescending(d => d.Id).Skip((page - 1) * pageSize)
.Take(pageSize).ToList();
var filteredUsers = users.Select(u => new UserRoleViewModel
// Calculate the number of pages
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
var filteredUsers = new List<UserRoleViewModel>();
foreach (var user in allUsers)
{
RoleName = u.RoleName,
Email = u.Email,
UserName = u.UserName,
Date = u.Date,
Status = u.Status,
Id = u.Id
}).ToList();
var totalPages = (int)Math.Ceiling(total / (double)pageSize);
var roles = await _userManager.GetRolesAsync(user);
if (!roles.Contains("Admin"))
{
filteredUsers.Add(new UserRoleViewModel
{
RoleName = roles.Count() > 0 ? roles.First() : "",
Email = user.Email,
UserName = user.FirstName,
Date = user.CreatedDate?.Date.ToString(),
Status = user.UniqueName,
Id = user.Id
});
}
}
// Create a view model containing the paged items and pagination metadata
var viewModel = new Pagination_DTO
{
Data = filteredUsers,
PageNumber = page,
PageSize = pageSize,
TotalCount = total,
TotalCount = totalCount,
TotalPages = totalPages
};
return Ok(viewModel);
}
catch (Exception ex)
{
return BadRequest(new { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new { Status = "Error", Message = ex.Message });
}
}
[HttpGet]
[Route("GetRoles")]
public async Task<IActionResult> GetRoles(CancellationToken cancellationToken)
public async Task<IActionResult> GetRoles()
{
try
{
var roles = await _settingsService.GetRolesAsync(cancellationToken);
var result = roles.Select(r => new Roles_DTO { Name = r.Name, Description = r.Description }).ToList();
return Ok(result);
var data = _db.Roles.ToList();
List<Roles_DTO> roles = new List<Roles_DTO>();
foreach (var role in data)
{
if (role.Name == "Admin")
{
roles.Add(new Roles_DTO { Name = role.Name, Description = "Full Access" });
}
else if(role.Name == "Supervisor")
{
roles.Add(new Roles_DTO { Name = role.Name, Description = "Access to Team's Work Orders Only" });
}
else if(role.Name == "Manager")
{
roles.Add(new Roles_DTO { Name = role.Name, Description = "Full access accept quotes" });
}
else if(role.Name =="User")
{
roles.Add(new Roles_DTO { Name = role.Name, Description = "Access to Assigned Work Orders Only" });
}
}
return Ok(roles);
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
}

View file

@ -1,8 +1,11 @@
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.Model;
using Api.SeaHavenIndustries.Options;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
@ -11,13 +14,18 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/[controller]")]
public class SyncController : Controller
{
private readonly ISyncService _syncService;
private readonly ApplicationDbContext _db;
private readonly AmazonDynamoDBClient _dynamo;
private readonly SyncOptions _syncOptions;
private const string WO_TABLE = "WorkOrders";
private const string COMMENTS_TABLE = "WorkOrderComments";
private const string VENDOR_REPLIES_TABLE = "VendorReplies";
public SyncController(ISyncService syncService, IOptions<SyncOptions> syncOptions)
public SyncController(ApplicationDbContext db, IOptions<SyncOptions> syncOptions)
{
_syncService = syncService;
_db = db;
_syncOptions = syncOptions.Value;
_dynamo = new AmazonDynamoDBClient(Amazon.RegionEndpoint.USEast1);
}
private IActionResult? SyncDisabledResult()
@ -32,73 +40,419 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpPost("WorkOrders")]
public async Task<IActionResult> SyncWorkOrders(CancellationToken cancellationToken)
public async Task<IActionResult> SyncWorkOrders()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var synced = 0;
var created = 0;
var updated = 0;
var locationsCreated = 0;
var result = await _syncService.SyncWorkOrdersAsync(cancellationToken);
return Ok(result);
var lastInternalWO = await _db.workOrders
.Where(w => w.InternalWONumber != null && w.InternalWONumber != "")
.OrderByDescending(w => w.InternalWONumber)
.Select(w => w.InternalWONumber)
.FirstOrDefaultAsync();
int nextInternal = 10000001;
if (lastInternalWO != null && int.TryParse(lastInternalWO, out var parsed))
nextInternal = parsed + 1;
Dictionary<string, AttributeValue>? lastKey = null;
do
{
var request = new ScanRequest
{
TableName = WO_TABLE,
Limit = 100,
ExclusiveStartKey = lastKey
};
var response = await _dynamo.ScanAsync(request);
foreach (var item in response.Items)
{
var externalId = GetString(item, "work_order_id");
if (string.IsNullOrEmpty(externalId)) continue;
var existing = await _db.workOrders
.FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalId);
var (locationId, locationWasCreated) = await ResolveLocationId(
GetString(item, "site_code"),
GetString(item, "building"),
GetString(item, "address"));
if (locationWasCreated)
locationsCreated++;
if (existing == null)
{
var wo = new WorkOrder
{
InternalWONumber = (nextInternal++).ToString("D8"),
ExternalWorkOrderId = externalId,
WorkerOrderNumber = externalId,
WorkerOrderTitle = GetString(item, "description"),
Description = GetString(item, "description"),
Status = MapStatus(GetString(item, "wo_status")),
Priority = MapSeverityToPriority(GetString(item, "severity")),
Severity = GetString(item, "severity"),
Customer = GetString(item, "customer"),
SiteCode = GetString(item, "site_code"),
Building = GetString(item, "building"),
LocationId = locationId,
DueDate = ParseDate(GetString(item, "due_date")),
DateReported = ParseDate(GetString(item, "date_reported")),
ScheduledStart = ParseDate(GetString(item, "scheduled_start")),
AssignTo = null,
SourceEmailS3Key = GetString(item, "source_email_s3_key"),
CreatedDate = ParseDate(GetString(item, "created_at")) ?? DateTime.UtcNow,
istemplate = false
};
_db.workOrders.Add(wo);
created++;
}
else
{
existing.WorkerOrderTitle = GetString(item, "description") ?? existing.WorkerOrderTitle;
existing.Description = GetString(item, "description") ?? existing.Description;
existing.Status = MapStatus(GetString(item, "wo_status")) ?? existing.Status;
existing.Priority = MapSeverityToPriority(GetString(item, "severity")) ?? existing.Priority;
existing.Severity = GetString(item, "severity") ?? existing.Severity;
existing.SiteCode = GetString(item, "site_code") ?? existing.SiteCode;
existing.Building = GetString(item, "building") ?? existing.Building;
existing.LocationId = locationId ?? existing.LocationId;
existing.DueDate = ParseDate(GetString(item, "due_date")) ?? existing.DueDate;
existing.DateReported = ParseDate(GetString(item, "date_reported")) ?? existing.DateReported;
existing.ScheduledStart = ParseDate(GetString(item, "scheduled_start")) ?? existing.ScheduledStart;
existing.SourceEmailS3Key = GetString(item, "source_email_s3_key") ?? existing.SourceEmailS3Key;
updated++;
}
synced++;
if (synced % 100 == 0)
await _db.SaveChangesAsync();
}
lastKey = response.LastEvaluatedKey;
} while (lastKey != null && lastKey.Count > 0);
await _db.SaveChangesAsync();
return Ok(new
{
synced,
created,
updated,
locationsCreated = await _db.Locations.CountAsync()
});
}
[HttpPost("Comments")]
public async Task<IActionResult> SyncComments(CancellationToken cancellationToken)
public async Task<IActionResult> SyncComments()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.SyncCommentsAsync(cancellationToken);
return Ok(result);
var synced = 0;
var created = 0;
var skipped = 0;
Dictionary<string, AttributeValue>? lastKey = null;
do
{
var request = new ScanRequest
{
TableName = COMMENTS_TABLE,
Limit = 100,
ExclusiveStartKey = lastKey
};
var response = await _dynamo.ScanAsync(request);
foreach (var item in response.Items)
{
var externalCommentId = GetString(item, "comment_id");
var externalWoId = GetString(item, "work_order_id");
if (string.IsNullOrEmpty(externalCommentId) || string.IsNullOrEmpty(externalWoId))
continue;
var alreadyExists = await _db.Comments
.AnyAsync(c => c.ExternalCommentId == externalCommentId);
if (alreadyExists)
{
skipped++;
synced++;
continue;
}
var workOrder = await _db.workOrders
.FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalWoId);
if (workOrder == null)
{
skipped++;
continue;
}
var comment = new Comments
{
ExternalCommentId = externalCommentId,
WorkerOrderId = workOrder.Id,
Commenttext = GetString(item, "text"),
Commenter = GetString(item, "commenter"),
RecordType = GetString(item, "record_type"),
CommentType = "customer",
CreatedDate = ParseDate(GetString(item, "created_at")) ?? DateTime.UtcNow,
};
_db.Comments.Add(comment);
created++;
synced++;
if (synced % 100 == 0)
await _db.SaveChangesAsync();
}
lastKey = response.LastEvaluatedKey;
} while (lastKey != null && lastKey.Count > 0);
await _db.SaveChangesAsync();
return Ok(new { synced, created, skipped });
}
[HttpPost("BackfillInternalWONumbers")]
public async Task<IActionResult> BackfillInternalWONumbers(CancellationToken cancellationToken)
public async Task<IActionResult> BackfillInternalWONumbers()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.BackfillInternalWONumbersAsync(cancellationToken);
return Ok(result);
var wosMissing = await _db.workOrders
.Where(w => w.InternalWONumber == null || w.InternalWONumber == "")
.OrderBy(w => w.Id)
.ToListAsync();
if (wosMissing.Count == 0)
return Ok(new { updated = 0 });
var lastWO = await _db.workOrders
.Where(w => w.InternalWONumber != null && w.InternalWONumber != "")
.OrderByDescending(w => w.InternalWONumber)
.Select(w => w.InternalWONumber)
.FirstOrDefaultAsync();
int next = 10000001;
if (lastWO != null && int.TryParse(lastWO, out var lastNum))
next = lastNum + 1;
foreach (var wo in wosMissing)
{
wo.InternalWONumber = next.ToString("D8");
next++;
}
await _db.SaveChangesAsync();
return Ok(new { updated = wosMissing.Count });
}
[HttpPost("BackfillDispatchNumbers")]
public async Task<IActionResult> BackfillDispatchNumbers(CancellationToken cancellationToken)
public async Task<IActionResult> BackfillDispatchNumbers()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.BackfillDispatchNumbersAsync(cancellationToken);
return Ok(result);
var missing = await _db.Dispatches
.Where(d => d.DispatchNumber == null || d.DispatchNumber == "")
.OrderBy(d => d.Id)
.ToListAsync();
if (missing.Count == 0) return Ok(new { updated = 0 });
int next = 1;
var last = await _db.Dispatches
.Where(d => d.DispatchNumber != null && d.DispatchNumber != "")
.OrderByDescending(d => d.DispatchNumber)
.Select(d => d.DispatchNumber)
.FirstOrDefaultAsync();
if (last != null && last.StartsWith("DSP-") && int.TryParse(last.Substring(4), out var n))
next = n + 1;
foreach (var d in missing)
{
d.DispatchNumber = $"DSP-{next:D5}";
next++;
}
await _db.SaveChangesAsync();
return Ok(new { updated = missing.Count });
}
[HttpPost("VendorReplies")]
public async Task<IActionResult> SyncVendorReplies(CancellationToken cancellationToken)
public async Task<IActionResult> SyncVendorReplies()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.SyncVendorRepliesAsync(cancellationToken);
return Ok(result);
var synced = 0;
var created = 0;
var skipped = 0;
Dictionary<string, AttributeValue>? lastKey = null;
do
{
var request = new ScanRequest
{
TableName = VENDOR_REPLIES_TABLE,
Limit = 100,
ExclusiveStartKey = lastKey
};
var response = await _dynamo.ScanAsync(request);
foreach (var item in response.Items)
{
var replyId = GetString(item, "reply_id");
var woNumber = GetString(item, "internal_wo_number");
var dispatchNumber = GetString(item, "dispatch_number");
if (string.IsNullOrEmpty(replyId) || string.IsNullOrEmpty(woNumber))
continue;
var alreadyExists = await _db.Comments
.AnyAsync(c => c.ExternalCommentId == replyId);
if (alreadyExists) { skipped++; synced++; continue; }
var workOrder = await _db.workOrders
.FirstOrDefaultAsync(w => w.InternalWONumber == woNumber);
if (workOrder == null) { skipped++; continue; }
int? dispatchId = null;
if (!string.IsNullOrWhiteSpace(dispatchNumber))
{
var dispatch = await _db.Dispatches
.FirstOrDefaultAsync(d => d.DispatchNumber == dispatchNumber && d.WorkOrderId == workOrder.Id);
dispatchId = dispatch?.Id;
}
var comment = new Comments
{
ExternalCommentId = replyId,
WorkerOrderId = workOrder.Id,
DispatchId = dispatchId,
Commenttext = GetString(item, "reply_text"),
Commenter = GetString(item, "sender_email"),
CommentType = "vendor",
RecordType = "vendor_reply",
CreatedDate = ParseDate(GetString(item, "received_at")) ?? DateTime.UtcNow,
};
_db.Comments.Add(comment);
created++;
synced++;
if (synced % 50 == 0)
await _db.SaveChangesAsync();
}
lastKey = response.LastEvaluatedKey;
} while (lastKey != null && lastKey.Count > 0);
await _db.SaveChangesAsync();
return Ok(new { synced, created, skipped });
}
[HttpPost("BackfillCommentTypes")]
public async Task<IActionResult> BackfillCommentTypes(CancellationToken cancellationToken)
public async Task<IActionResult> BackfillCommentTypes()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.BackfillCommentTypesAsync(cancellationToken);
return Ok(result);
var updated = await _db.Database.ExecuteSqlRawAsync(
"UPDATE Comments SET CommentType = 'customer' WHERE ExternalCommentId IS NOT NULL AND (CommentType IS NULL OR CommentType = '')");
return Ok(new { updated });
}
[HttpPost("All")]
public async Task<IActionResult> SyncAll(CancellationToken cancellationToken)
public async Task<IActionResult> SyncAll()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var result = await _syncService.SyncAllAsync(cancellationToken);
return Ok(result);
var woResult = await SyncWorkOrders() as OkObjectResult;
var commentResult = await SyncComments() as OkObjectResult;
return Ok(new
{
workOrders = woResult?.Value,
comments = commentResult?.Value
});
}
private async Task<(int? LocationId, bool Created)> ResolveLocationId(string? siteCode, string? building, string? address)
{
if (string.IsNullOrWhiteSpace(siteCode) && string.IsNullOrWhiteSpace(building))
return (null, false);
var matchCode = siteCode ?? building;
var existing = await _db.Locations
.FirstOrDefaultAsync(l => l.Name == matchCode || l.Title == matchCode);
if (existing != null)
return (existing.Id, false);
var location = new Locations
{
Name = matchCode,
Title = building,
Address1 = address,
Status = "Active"
};
_db.Locations.Add(location);
await _db.SaveChangesAsync();
return (location.Id, true);
}
private static string? GetString(Dictionary<string, AttributeValue> item, string key)
{
if (item.TryGetValue(key, out var val) && val.S != null)
return val.S;
return null;
}
private static DateTime? ParseDate(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (DateTime.TryParse(value, out var dt)) return dt;
return null;
}
private static string? MapStatus(string? dynamoStatus)
{
return dynamoStatus switch
{
"new" => "Open",
"assigned" => "Open",
"in_progress" => "In Progress",
"on_hold" => "On Hold",
"completed" => "Done",
"cancelled" => "Cancelled",
"unknown" => "Open",
_ => "Open"
};
}
private static string? MapSeverityToPriority(string? severity)
{
if (string.IsNullOrWhiteSpace(severity)) return null;
return $"Sev {severity}";
}
}
}

View file

@ -1,11 +1,8 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace Api.SeaHavenIndustries.Controllers
{
@ -15,26 +12,57 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/tasklisttemplates")]
public class TaskListTemplateController : Controller
{
private readonly ITaskListTemplateService _taskListTemplateService;
private readonly ILogger<TaskListTemplateController> _logger;
private readonly ApplicationDbContext _db;
public TaskListTemplateController(ITaskListTemplateService taskListTemplateService, ILogger<TaskListTemplateController> logger)
public TaskListTemplateController(ApplicationDbContext db)
{
_taskListTemplateService = taskListTemplateService;
_logger = logger;
_db = db;
}
[HttpGet]
public async Task<IActionResult> GetAll(CancellationToken cancellationToken)
public async Task<IActionResult> GetAll()
{
var data = await _taskListTemplateService.GetAllAsync(cancellationToken);
var data = await _db.TaskListTemplates
.Where(t => t.IsActive)
.Include(t => t.Items.OrderBy(i => i.SortOrder))
.OrderBy(t => t.Name)
.Select(t => new
{
t.Id,
t.Name,
t.Description,
t.IsActive,
items = t.Items.OrderBy(i => i.SortOrder).Select(i => new
{
i.Id,
i.ItemText,
i.SortOrder
}).ToList()
})
.ToListAsync();
return Ok(data);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
public async Task<IActionResult> GetById(int id)
{
var template = await _taskListTemplateService.GetByIdAsync(id, cancellationToken);
var template = await _db.TaskListTemplates
.Include(t => t.Items.OrderBy(i => i.SortOrder))
.Where(t => t.Id == id)
.Select(t => new
{
t.Id,
t.Name,
t.Description,
t.IsActive,
items = t.Items.OrderBy(i => i.SortOrder).Select(i => new
{
i.Id,
i.ItemText,
i.SortOrder
}).ToList()
})
.FirstOrDefaultAsync();
if (template == null)
return NotFound(new Response { Status = "Error", Message = "Template not found" });
@ -43,61 +71,76 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] TaskListTemplate_DTO model, CancellationToken cancellationToken)
public async Task<IActionResult> Create([FromBody] TaskListTemplate_DTO model)
{
try
{
var input = MapToInput(model);
var id = await _taskListTemplateService.CreateAsync(input, cancellationToken);
return Ok(new { message = "Template created", id });
var template = new TaskListTemplate
{
Name = model.Name,
Description = model.Description,
IsActive = model.IsActive,
Items = model.Items.Select((item, idx) => new TaskListTemplateItem
{
ItemText = item.ItemText,
SortOrder = item.SortOrder > 0 ? item.SortOrder : idx
}).ToList()
};
_db.TaskListTemplates.Add(template);
await _db.SaveChangesAsync();
return Ok(new { message = "Template created", id = template.Id });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, [FromBody] TaskListTemplate_DTO model, CancellationToken cancellationToken)
public async Task<IActionResult> Update(int id, [FromBody] TaskListTemplate_DTO model)
{
try
{
var input = MapToInput(model);
var updated = await _taskListTemplateService.UpdateAsync(id, input, cancellationToken);
if (!updated)
var template = await _db.TaskListTemplates
.Include(t => t.Items)
.FirstOrDefaultAsync(t => t.Id == id);
if (template == null)
return NotFound(new Response { Status = "Error", Message = "Template not found" });
template.Name = model.Name;
template.Description = model.Description;
template.IsActive = model.IsActive;
_db.TaskListTemplateItems.RemoveRange(template.Items);
template.Items = model.Items.Select((item, idx) => new TaskListTemplateItem
{
TaskListTemplateId = id,
ItemText = item.ItemText,
SortOrder = item.SortOrder > 0 ? item.SortOrder : idx
}).ToList();
await _db.SaveChangesAsync();
return Ok(new { message = "Template updated" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
public async Task<IActionResult> Delete(int id)
{
var deleted = await _taskListTemplateService.DeleteAsync(id, cancellationToken);
if (!deleted)
var template = await _db.TaskListTemplates.FindAsync(id);
if (template == null)
return NotFound(new Response { Status = "Error", Message = "Template not found" });
template.IsActive = false;
await _db.SaveChangesAsync();
return Ok(new { message = "Template deleted" });
}
private static TaskListTemplateInput MapToInput(TaskListTemplate_DTO model)
{
return new TaskListTemplateInput
{
Name = model.Name,
Description = model.Description,
IsActive = model.IsActive,
Items = model.Items.Select(item => new TaskListTemplateInputItem
{
ItemText = item.ItemText,
SortOrder = item.SortOrder
}).ToList()
};
}
}
}

View file

@ -1,10 +1,8 @@
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
{
@ -14,77 +12,225 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/uplifts")]
public class UpliftController : Controller
{
private readonly IUpliftService _upliftService;
private readonly ILogger<UpliftController> _logger;
private readonly ApplicationDbContext _db;
private readonly IConfiguration _config;
public UpliftController(IUpliftService upliftService, ILogger<UpliftController> logger)
public UpliftController(ApplicationDbContext db, IConfiguration config)
{
_upliftService = upliftService;
_logger = logger;
_db = db;
_config = config;
}
[HttpGet]
public async Task<IActionResult> List([FromQuery] string? status = "Pending", [FromQuery] int? tier = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 25, CancellationToken cancellationToken = default)
public async Task<IActionResult> List([FromQuery] string? status = "Pending", [FromQuery] int? tier = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 25)
{
var result = await _upliftService.ListAsync(User, status, tier, page, pageSize, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
var query = from u in _db.DispatchUpliftRequests
join d in _db.Dispatches on u.DispatchId equals d.Id
join v in _db.Vendors on d.VendorId equals v.Id into vendors
from v in vendors.DefaultIfEmpty()
where (u.IsDeleted == null || u.IsDeleted == false)
select new { u, d, v };
if (!string.IsNullOrWhiteSpace(status))
query = query.Where(x => x.u.Status == status);
if (tier.HasValue)
query = query.Where(x => x.u.RequiredTier == tier.Value);
var total = await query.CountAsync();
var raw = await query
.OrderByDescending(x => x.u.CreatedDate)
.Skip((Math.Max(page, 1) - 1) * pageSize)
.Take(pageSize)
.Select(x => new
{
x.u.Id,
DispatchId = x.d.Id,
x.d.DispatchNumber,
x.d.PONumber,
WorkOrderId = x.d.WorkOrderId,
VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName,
x.u.CurrentNTE,
x.u.RequestedNTE,
x.u.VendorReason,
x.u.RequiredTier,
x.u.Status,
RequestedAt = x.u.CreatedDate,
x.u.DecidedAt,
x.u.DecisionNote
})
.ToListAsync();
var items = raw.Select(r => new
{
r.Id,
r.DispatchId,
r.DispatchNumber,
r.PONumber,
r.WorkOrderId,
r.VendorCompanyName,
r.CurrentNTE,
r.RequestedNTE,
Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m),
r.VendorReason,
r.RequiredTier,
r.Status,
r.RequestedAt,
r.DecidedAt,
r.DecisionNote,
CanDecide = UserCanApprove(r.RequiredTier)
}).ToList();
return Ok(new DataResponse
{
Status = "Success",
Data = new { total, page, pageSize, items }
});
}
[HttpGet("dispatch/{dispatchId:int}")]
public async Task<IActionResult> ListForDispatch(int dispatchId, CancellationToken cancellationToken = default)
public async Task<IActionResult> ListForDispatch(int dispatchId)
{
var items = await _upliftService.ListForDispatchAsync(User, dispatchId, cancellationToken);
var rows = await (from u in _db.DispatchUpliftRequests
where u.DispatchId == dispatchId && (u.IsDeleted == null || u.IsDeleted == false)
join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs
from dec in decs.DefaultIfEmpty()
orderby u.CreatedDate descending
select new
{
u.Id,
u.DispatchId,
u.CurrentNTE,
u.RequestedNTE,
Delta = u.RequestedNTE - (u.CurrentNTE ?? 0m),
u.VendorReason,
u.Status,
u.RequiredTier,
u.RequestedByVendorName,
RequestedAt = u.CreatedDate,
u.DecidedAt,
u.DecisionNote,
DecidedByFirstName = dec != null ? dec.FirstName : null,
DecidedByLastName = dec != null ? dec.LastName : null
}).ToListAsync();
var items = rows.Select(r => new
{
r.Id,
r.DispatchId,
r.CurrentNTE,
r.RequestedNTE,
r.Delta,
r.VendorReason,
r.Status,
r.RequiredTier,
r.RequestedByVendorName,
r.RequestedAt,
r.DecidedAt,
r.DecisionNote,
DecidedByName = string.Join(" ", new[] { r.DecidedByFirstName, r.DecidedByLastName }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(),
CanDecide = UserCanApprove(r.RequiredTier)
}).ToList();
return Ok(new DataResponse { Status = "Success", Data = items });
}
[HttpPost("{id:int}/approve")]
public async Task<IActionResult> Approve(int id, [FromBody] DecisionRequest? body, CancellationToken cancellationToken = default)
public async Task<IActionResult> Approve(int id, [FromBody] DecisionRequest? body)
{
try
var req = await _db.DispatchUpliftRequests.FirstOrDefaultAsync(u => u.Id == id);
if (req == null) return NotFound(new Response { Status = "Error", Message = "Uplift request not found" });
if (req.Status != "Pending") return BadRequest(new Response { Status = "Error", Message = $"Cannot approve a '{req.Status}' request" });
if (!UserCanApprove(req.RequiredTier))
return StatusCode(403, new Response { Status = "Error", Message = $"Approval requires a Tier {req.RequiredTier} role" });
var dispatch = await _db.Dispatches.FirstOrDefaultAsync(d => d.Id == req.DispatchId);
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
return BadRequest(new Response { Status = "Error", Message = $"Cannot approve uplift on a '{dispatch.Status}' dispatch" });
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var now = DateTime.UtcNow;
var oldNTE = dispatch.NTEAmount ?? 0m;
dispatch.NTEAmount = req.RequestedNTE;
dispatch.LastModificationTime = now;
req.Status = "Approved";
req.DecidedAt = now;
req.DecidedByUserId = userId;
req.DecisionNote = string.IsNullOrWhiteSpace(body?.Note) ? null : body!.Note!.Trim();
req.LastModificationTime = now;
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
var result = await _upliftService.ApproveAsync(User, id, body?.Note, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
}
catch (KeyNotFoundException ex)
{
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Uplift request not found") });
}
catch (UpliftForbiddenException ex)
{
return StatusCode(403, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to perform this action on this uplift request") });
}
catch (InvalidOperationException ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "This uplift request cannot be modified in its current state") });
}
WorkOrderId = dispatch.WorkOrderId ?? 0,
UserId = userId,
FieldName = $"Dispatch {dispatch.DispatchNumber} NTE",
OldValue = $"${oldNTE:F2}",
NewValue = $"${req.RequestedNTE:F2}",
Action = "uplift_approved",
CreatedAt = now
});
await _db.SaveChangesAsync();
return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status, dispatch.NTEAmount } });
}
[HttpPost("{id:int}/deny")]
public async Task<IActionResult> Deny(int id, [FromBody] DecisionRequest? body, CancellationToken cancellationToken = default)
public async Task<IActionResult> Deny(int id, [FromBody] DecisionRequest body)
{
try
if (body == null || string.IsNullOrWhiteSpace(body.Note))
return BadRequest(new Response { Status = "Error", Message = "A note is required when denying" });
var req = await _db.DispatchUpliftRequests.FirstOrDefaultAsync(u => u.Id == id);
if (req == null) return NotFound(new Response { Status = "Error", Message = "Uplift request not found" });
if (req.Status != "Pending") return BadRequest(new Response { Status = "Error", Message = $"Cannot deny a '{req.Status}' request" });
if (!UserCanApprove(req.RequiredTier))
return StatusCode(403, new Response { Status = "Error", Message = $"Decision requires a Tier {req.RequiredTier} role" });
var dispatch = await _db.Dispatches.FirstOrDefaultAsync(d => d.Id == req.DispatchId);
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var now = DateTime.UtcNow;
req.Status = "Denied";
req.DecidedAt = now;
req.DecidedByUserId = userId;
req.DecisionNote = body.Note!.Trim();
req.LastModificationTime = now;
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
var result = await _upliftService.DenyAsync(User, id, body?.Note, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
}
catch (KeyNotFoundException ex)
{
return NotFound(new Response { Status = "Error", Message = _logger.Sanitize(ex, "Uplift request not found") });
}
catch (UpliftForbiddenException ex)
{
return StatusCode(403, new Response { Status = "Error", Message = _logger.Sanitize(ex, "You are not authorized to perform this action on this uplift request") });
}
catch (InvalidOperationException ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "This uplift request cannot be modified in its current state") });
}
WorkOrderId = dispatch?.WorkOrderId ?? 0,
UserId = userId,
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
OldValue = "Pending",
NewValue = "Denied",
Action = "uplift_denied",
CreatedAt = now
});
await _db.SaveChangesAsync();
return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } });
}
[HttpGet("can-approve")]
public IActionResult CanApprove([FromQuery] int tier)
{
return Ok(new DataResponse { Status = "Success", Data = new { canApprove = _upliftService.CanApprove(User, tier) } });
return Ok(new DataResponse { Status = "Success", Data = new { canApprove = UserCanApprove(tier) } });
}
private bool UserCanApprove(int requiredTier)
{
var key = $"Approvals:Tier{requiredTier}Roles";
var roles = _config.GetSection(key).Get<string[]>() ?? Array.Empty<string>();
foreach (var r in roles)
{
if (!string.IsNullOrWhiteSpace(r) && User.IsInRole(r)) return true;
}
return false;
}
public class DecisionRequest

View file

@ -1,12 +1,15 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Api.SeaHavenIndustries.Models;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using PasswordGenerator;
using SendGrid.Helpers.Mail;
using System.Security.Claims;
using System.Security.Cryptography;
namespace Api.SeaHavenIndustries.Controllers
{
@ -15,123 +18,287 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/User")]
public class UserController : Controller
{
private readonly IUserService _userService;
private readonly ILogger<UserController> _logger;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ApplicationDbContext _db;
private readonly SendMessage _sendemail;
public UserController(IUserService userService, ILogger<UserController> logger)
public UserController(UserManager<ApplicationUser> userManager, ApplicationDbContext db, SendMessage sendemail)
{
_userService = userService;
_logger = logger;
_userManager = userManager;
_db = db;
_sendemail = sendemail;
}
[Route("")]
[HttpGet]
public async Task<IActionResult> GetUsers(CancellationToken cancellationToken)
public async Task<IActionResult> GetUsers()
{
var users = await _userService.GetUsersAsync(cancellationToken);
return Ok(users);
_db.ChangeTracker.Clear();
var allUsers = _db.Users.Select(s => new UserRoleViewModel
{
RoleName = s.PhoneNumber,
Email = s.Email,
Name = s.FirstName,
Date = s.CreatedDate != null ? s.CreatedDate.Value.Date.ToString("MMM dd yyyy") : "",
Status = s.UniqueName,
Id = s.Id
}).AsQueryable();
return Ok(allUsers);
}
[HttpPost]
[Route("AddUser")]
public async Task<IActionResult> AddUser(User_DTO user, CancellationToken cancellationToken)
public async Task<IActionResult> AddUser(User_DTO user)
{
var model = new ApplicationUser
{
UserName = user.Email,
FirstName = user.Name,
Email = user.Email,
};
try
{
var dto = new AddUserRequestDTO
_db.ChangeTracker.Clear();
var exist = _db.Set<ApplicationUser>().Find(model.Id);
if (exist == null)
{
Name = user.Name,
Email = user.Email,
Contact = user.Contact,
Role = user.Role
};
var outcome = await _userService.AddUserAsync(dto, cancellationToken);
if (!outcome.Success)
model.EmailConfirmed = true;
model.UserName = model.Email;
model.CreatedDate = DateTime.Now;
model.UniqueName = "Active";
model.PhoneNumber = user.Role;
var pwd = new Password(8).IncludeLowercase().IncludeUppercase().IncludeSpecial().IncludeNumeric();
var password = pwd.Next();
var result = await _userManager.CreateAsync(model, password);
if (result.Succeeded)
{
await _userManager.AddToRoleAsync(model, user.Role ?? "");
}
else
{
return BadRequest(new Response { Status = "Error", Message = result.Errors.FirstOrDefault()?.Description ?? "error" });
}
var request = HttpContext?.Request;
//var domain = $"{request?.Scheme}://{request?.Host}";
var domain = "http://shoc-ui-app.s3-website-us-east-1.amazonaws.com/#";
string subject = "Login Information";
string greeting = $"Dear {model.FirstName},\n\n";
string loginLink = $"Click here to login:<a href='{domain}/Login'>Login</a>\n";
string passwordInfo = $"Your password is: {password}\n\n";
string body = $"{greeting}\n\n{loginLink}\n\n{passwordInfo}";
await _sendemail.SendEMail(model?.Email ?? "", subject, body);
}
else
{
return BadRequest(new Response { Status = "Error", Message = outcome.Error });
var exist1 = await _userManager.Users.FirstOrDefaultAsync(w => w.Id == model.Id);
if (exist1 == null)
return BadRequest(new Response { Status = "Error", Message = "User not found" });
var existingRole = await _userManager.GetRolesAsync(exist1);
// Remove from roles if any exist
if (existingRole != null && existingRole.Any() && existingRole.FirstOrDefault() != exist1.PhoneNumber)
{
await _userManager.RemoveFromRolesAsync(exist1, existingRole);
}
// Update user details
exist1.FirstName = model.FirstName;
exist1.LastName = model.LastName;
exist1.Contact = model.Contact;
exist1.PhoneNumber = model.PhoneNumber;
// Optionally, detach the entity if you're about to attach it again
_db.Entry(exist1).State = EntityState.Detached;
// Add to role after setting properties
await _userManager.AddToRoleAsync(exist1, user.Role ?? "");
var result = await _userManager.UpdateAsync(exist1);
//await _context.SaveChangesAsync();
}
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpPut]
[Route("EditUser")]
public async Task<IActionResult> EditUser(EditUser_DTO user, CancellationToken cancellationToken)
public async Task<IActionResult> EditUser(EditUser_DTO user)
{
try
{
var dto = new EditUserRequestDTO
{
Id = user.Id,
Name = user.Name,
Email = user.Email,
Role = user.Role
};
var succeeded = await _userService.EditUserAsync(dto, cancellationToken);
if (!succeeded)
var exist = _db.Users.Where(w => w.Id == user.Id).FirstOrDefault();
if (exist == null)
return BadRequest(new Response { Status = "Error", Message = "User not found" });
exist.FirstName = user.Name;
exist.EmailConfirmed = true;
exist.UserName = user.Email;
exist.Email = user.Email;
exist.NormalizedEmail = user.Email.ToUpper();
exist.NormalizedUserName = user.Email.ToUpper();
exist.CreatedDate = DateTime.Now;
exist.UniqueName = "Active";
exist.PhoneNumber = user.Role;
var existingRole = await _userManager.GetRolesAsync(exist);
if (existingRole != null && existingRole.Any())
{
await _userManager.RemoveFromRolesAsync(exist, existingRole);
}
await _userManager.AddToRoleAsync(exist, user.Role ?? "");
await _db.SaveChangesAsync();
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
public class DeleteUserRequest
{
public string Id { get; set; }
}
[HttpDelete]
[Route("DeleteUser")]
public async Task<IActionResult> DeleteUser(EditUser_DTO req, CancellationToken cancellationToken)
public async Task<IActionResult> DeleteUser(EditUser_DTO req)
{
string id = req.Id ?? "";
string id = req.Id;
try
{
var found = await _userService.DeleteUserAsync(id, cancellationToken);
if (!found)
var data = await _db.Users.Where(w => w.Id == id).FirstOrDefaultAsync();
if (data != null)
{
var existworkorder = _db.workOrders.Where(w => w.AssignTo.Contains(data.Id)).ToList();
foreach (var workorder in existworkorder)
{
var existdata = _db.workOrders.Where(w => w.Id == workorder.Id).FirstOrDefault();
existdata.AssignTo = null;
}
var existworkordercomments = _db.Comments.Where(w => w.UserId.Contains(data.Id)).ToList();
foreach (var workorderc in existworkordercomments)
{
var existdatacomm = _db.Comments.Where(w => w.Id == workorderc.Id).FirstOrDefault();
existdatacomm.UserId = null;
}
var existtemplate = _db.Templates.Where(w => w.AssignTo.Contains(data.Id)).ToList();
foreach (var workordert in existtemplate)
{
var existdattem = _db.Templates.Where(w => w.Id == workordert.Id).FirstOrDefault();
existdattem.AssignTo = null;
}
var existtempexistuserrolelate = _db.UserRoles.Where(w => w.UserId.Contains(data.Id)).ToList();
if (existtempexistuserrolelate.Count() > 0)
{
_db.UserRoles.RemoveRange(existtempexistuserrolelate);
}
_db.Users.Remove(data);
}
else
{
return BadRequest(new Response { Status = "Error", Message = "ID not matched!" });
}
await _db.SaveChangesAsync();
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpPost]
[Route("DeleteCurrentUser")]
public async Task<IActionResult> DeleteCurrentUser(CancellationToken cancellationToken)
public async Task<IActionResult> DeleteCurrentUser()
{
try
{
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
await _userService.DeleteCurrentUserAsync(userid, cancellationToken);
var exist = _db.Users.Where(w => w.Id == userid).FirstOrDefault();
if(exist != null)
{
exist.IsDeleted = true;
}
await _db.SaveChangesAsync();
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpGet]
[Route("UserProfile")]
public async Task<IActionResult> UserProfile(CancellationToken cancellationToken)
public async Task<IActionResult> UserProfile()
{
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
var data = await _userService.GetUserProfileAsync(userid, cancellationToken);
return Ok(data.Select(d => new
var data = await _db.Users.Where(w => w.Id == userid).Select(s => new
{
d.Id,
Name = d.Name,
addeddate = d.AddedDate,
d.Email,
d.Contact
}));
s.Id,
Name = s.FirstName,
addeddate = s.CreatedDate,
s.Email,
s.Contact
}).ToListAsync();
return Ok(data);
}
public static string GenerateRandomPassword(int length = 8)
{
const string validChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*()_-+=<>?";
const string capitalLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const string digits = "1234567890";
const string symbols = "!@#$%^&*()_-+=<>?";
using (var rng = RandomNumberGenerator.Create())
{
var passwordChars = new char[length];
// Ensure at least one capital letter
passwordChars[0] = capitalLetters[GetNextNonNegativeIndex(rng, capitalLetters.Length)];
// Ensure at least one digit
passwordChars[1] = digits[GetNextNonNegativeIndex(rng, digits.Length)];
// Ensure at least one symbol
passwordChars[2] = symbols[GetNextNonNegativeIndex(rng, symbols.Length)];
// Fill the rest of the password with random characters
for (int i = 3; i < length; i++)
{
passwordChars[i] = validChars[GetNextNonNegativeIndex(rng, validChars.Length)];
}
// Shuffle the password characters, excluding the initial characters
Random random = new Random();
for (int i = length - 1; i > 2; i--)
{
int j = random.Next(2, i + 1);
(passwordChars[i], passwordChars[j]) = (passwordChars[j], passwordChars[i]);
}
return new string(passwordChars);
}
}
private static int GetNextNonNegativeIndex(RandomNumberGenerator rng, int maxValue)
{
byte[] randomBytes = new byte[4];
rng.GetBytes(randomBytes);
int nextIndex = BitConverter.ToInt32(randomBytes, 0) % maxValue;
return (nextIndex + maxValue) % maxValue; // Ensure the index is non-negative
}
}
}

View file

@ -1,10 +1,9 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.DTOs;
using System.Security.Claims;
@ -18,54 +17,31 @@ namespace Api.SeaHavenIndustries.Controllers
public class VendorController : Controller
{
private readonly IVendorService _vendorService;
private readonly ILogger<VendorController> _logger;
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(IVendorService vendorService, ILogger<VendorController> logger)
public VendorController(IVendorService vendorService, ApplicationDbContext db, Helper.ZipCodeDistance zipDistance, Helper.VendorPortalTokenService vendorTokens, IConfiguration config)
{
_vendorService = vendorService;
_logger = logger;
_db = db;
_zipDistance = zipDistance;
_vendorTokens = vendorTokens;
_config = config;
}
[HttpGet("GetVendorList")]
public async Task<IActionResult> GetVendorList(
string? search = "",
int page = 1,
int pageSize = 10,
bool? isActive = true,
[FromQuery] string[]? companies = null,
[FromQuery] string[]? trades = null,
[FromQuery] string[]? locations = null,
[FromQuery] string[]? jobBuckets = null)
public async Task<IActionResult> GetVendorList(string? search = "", int page = 1, int pageSize = 10)
{
var pagedResult = await _vendorService.GetVendorsPagedAsync(
page,
pageSize,
search,
isActive,
companies,
trades,
locations,
jobBuckets);
var pagedResult = await _vendorService.GetVendorsPagedAsync(page, pageSize, search);
var data = pagedResult.Items.Select(v => new
{
v.Id,
CompanyName = v.Name,
v.CompanyId,
v.ContactName,
v.Email,
v.Phone,
v.CompanyPhone,
v.PreferredContact,
v.Address,
v.City,
v.State,
Zip = v.Zipcode,
v.TradeSpecialties,
v.GoogleMapsUrl,
v.Notes,
v.IsActive,
v.TotalJobs
v.Phone
});
return Ok(new Pagination_DTO
@ -80,37 +56,17 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet("{id}")]
[HttpGet("GetById")]
public async Task<IActionResult> GetVendorById([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId)
public async Task<IActionResult> GetVendorById([FromQuery] int? id)
{
var vendorId = id ?? queryId;
if (vendorId == null)
if (id == null)
return BadRequest(new Response { Status = "Error", Message = "Id is required" });
var vendor = await _vendorService.GetVendorByIdAsync(vendorId.Value);
var vendor = await _vendorService.GetVendorByIdAsync(id.Value);
if (vendor == null)
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
return Ok(new
{
vendor.Id,
CompanyName = vendor.Name,
vendor.CompanyId,
vendor.ContactName,
vendor.Email,
vendor.Phone,
vendor.CompanyPhone,
vendor.PreferredContact,
vendor.Address,
vendor.City,
vendor.State,
Zip = vendor.Zipcode,
vendor.TradeSpecialties,
vendor.GoogleMapsUrl,
vendor.Notes,
vendor.IsActive,
vendor.TotalJobs
});
return Ok(vendor);
}
[HttpPost]
@ -121,21 +77,7 @@ namespace Api.SeaHavenIndustries.Controllers
{
var createDto = new CreateVendorDTO
{
Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required"),
CompanyId = model.CompanyId,
ContactName = model.ContactName,
Email = model.Email,
Phone = model.Phone,
CompanyPhone = model.CompanyPhone,
PreferredContact = model.PreferredContact,
Address = model.Address,
City = model.City,
State = model.State,
Zipcode = model.Zip,
TradeSpecialties = model.TradeSpecialties,
GoogleMapsUrl = model.GoogleMapsUrl,
Notes = model.Notes,
IsActive = model.IsActive ?? true
Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required")
};
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
@ -152,7 +94,7 @@ namespace Api.SeaHavenIndustries.Controllers
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
}
}
@ -169,21 +111,7 @@ namespace Api.SeaHavenIndustries.Controllers
var updateDto = new UpdateVendorDTO
{
Name = model.CompanyName,
CompanyId = model.CompanyId,
ContactName = model.ContactName,
Email = model.Email,
Phone = model.Phone,
CompanyPhone = model.CompanyPhone,
PreferredContact = model.PreferredContact,
Address = model.Address,
City = model.City,
State = model.State,
Zipcode = model.Zip,
TradeSpecialties = model.TradeSpecialties,
GoogleMapsUrl = model.GoogleMapsUrl,
Notes = model.Notes,
IsActive = model.IsActive
Name = model.CompanyName
};
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
@ -198,28 +126,19 @@ namespace Api.SeaHavenIndustries.Controllers
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (VendorDeactivationBlockedException dbex)
{
return Conflict(new
{
Status = "Conflict",
Message = _logger.Sanitize(dbex, "Vendor cannot be deactivated because it has open work orders"),
OpenWorkOrders = dbex.OpenWorkOrders
});
}
catch (InvalidOperationException)
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
}
}
[HttpDelete("{id}")]
[HttpPost("Delete")]
public async Task<IActionResult> Delete([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId = null)
public async Task<IActionResult> Delete([FromRoute] int? id, [FromQuery] int? queryId = null)
{
try
{
@ -233,121 +152,109 @@ namespace Api.SeaHavenIndustries.Controllers
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
await _vendorService.DeleteVendorAsync(vendorId, userId);
return Ok(new DataResponse { Message = "Vendor Deactivated", Status = "200" });
return Ok(new DataResponse { Message = "Vendor Deleted", Status = "200" });
}
catch (VendorDeactivationBlockedException dbex)
{
return Conflict(new
{
Status = "Conflict",
Message = _logger.Sanitize(dbex, "Vendor cannot be deactivated because it has open work orders"),
OpenWorkOrders = dbex.OpenWorkOrders
});
}
catch (InvalidOperationException)
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpGet("{id:int}/deactivation-impact")]
public async Task<IActionResult> GetDeactivationImpact(int id)
{
try
{
var impact = await _vendorService.GetDeactivationImpactAsync(id);
return Ok(impact);
}
catch (InvalidOperationException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPut("{id:int}/work-order-update")]
public async Task<IActionResult> UpdateFromWorkOrder(int id, [FromBody] WorkOrderVendorUpdate_DTO model)
{
try
{
if (model == null || model.WorkOrderId <= 0)
return BadRequest(new Response { Status = "Error", Message = "A valid workOrderId is required" });
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
var dto = new WorkOrderVendorUpdateDTO
{
WorkOrderId = model.WorkOrderId,
ContactName = model.ContactName,
PreferredContact = model.PreferredContact,
Phone = model.Phone,
Email = model.Email,
Notes = model.Notes
};
var result = await _vendorService.UpdateVendorFromWorkOrderAsync(id, dto, userId);
return Ok(result);
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (InvalidOperationException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor or work order link not found" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
}
}
[HttpGet("Dropdown")]
public async Task<IActionResult> GetDropdown([FromQuery] string? trade = null, [FromQuery] string? siteZip = null, CancellationToken cancellationToken = default)
public async Task<IActionResult> GetDropdown([FromQuery] string? trade = null, [FromQuery] string? siteZip = null)
{
var result = await _vendorService.GetDropdownAsync(trade, siteZip, cancellationToken);
return Ok(result);
}
var query = _db.Vendors.Where(v => v.IsActive);
var vendors = await query.OrderBy(v => v.CompanyName)
.Select(v => new
{
v.Id,
v.CompanyName,
v.ContactName,
v.TradeSpecialties,
v.Address,
v.City,
v.State,
v.Zip
})
.ToListAsync();
var result = vendors.Select(v =>
{
var distance = _zipDistance.GetDistanceMiles(siteZip, v.Zip);
var addr = new[] { v.Address, v.City, v.State, v.Zip }
.Where(s => !string.IsNullOrWhiteSpace(s));
return new
{
v.Id,
v.CompanyName,
v.ContactName,
v.TradeSpecialties,
address = string.Join(", ", addr),
distanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null
};
}).ToList();
if (!string.IsNullOrWhiteSpace(trade))
{
var tradeMatched = result.Where(v => (v.TradeSpecialties ?? "").Contains(trade)).ToList();
var rest = result.Where(v => !(v.TradeSpecialties ?? "").Contains(trade)).ToList();
result = tradeMatched.Concat(rest).ToList();
}
if (!string.IsNullOrWhiteSpace(siteZip))
{
result = result.OrderBy(v => v.distanceMiles ?? 99999).ToList();
}
[HttpGet("facets")]
public async Task<IActionResult> GetFacets([FromQuery] bool? isActive = null, CancellationToken cancellationToken = default)
{
var result = await _vendorService.GetFacetsAsync(isActive, cancellationToken);
return Ok(result);
}
[HttpGet("{id:int}/portal-token")]
public async Task<IActionResult> GetPortalToken(int id, CancellationToken cancellationToken = default)
public async Task<IActionResult> GetPortalToken(int id)
{
var result = await _vendorService.GetPortalTokenAsync(id, cancellationToken);
if (result == null) return NotFound();
return Ok(result);
var vendor = await _db.Vendors.FindAsync(id);
if (vendor == null) return NotFound();
var token = await _vendorTokens.GetOrCreateActiveTokenAsync(id);
return Ok(BuildPortalTokenResponse(token));
}
[HttpPost("{id:int}/portal-token/rotate")]
public async Task<IActionResult> RotatePortalToken(int id, CancellationToken cancellationToken = default)
public async Task<IActionResult> RotatePortalToken(int id)
{
var result = await _vendorService.RotatePortalTokenAsync(id, cancellationToken);
if (result == null) return NotFound();
return Ok(result);
var vendor = await _db.Vendors.FindAsync(id);
if (vendor == null) return NotFound();
var token = await _vendorTokens.RotateAsync(id);
return Ok(BuildPortalTokenResponse(token));
}
[HttpPost("{id:int}/portal-token/revoke")]
public async Task<IActionResult> RevokePortalToken(int id, CancellationToken cancellationToken = default)
public async Task<IActionResult> RevokePortalToken(int id)
{
var success = await _vendorService.RevokePortalTokenAsync(id, cancellationToken);
if (!success) return NotFound();
var vendor = await _db.Vendors.FindAsync(id);
if (vendor == null) return NotFound();
await _vendorTokens.RevokeAllAsync(id);
return Ok(new { revoked = true });
}
private object BuildPortalTokenResponse(VendorAccessToken token)
{
var frontendBase = _config.GetValue<string>("FrontendBaseUrl")?.TrimEnd('/') ?? "";
return new
{
token.Token,
token.IssuedAt,
token.ExpiresAt,
token.LastUsedAt,
PortalUrl = $"{frontendBase}/v/{token.Token}/dashboard"
};
}
}
}

View file

@ -1,210 +0,0 @@
using System.Text;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/vendor-operations")]
public class VendorOperationsController : ControllerBase
{
private readonly IVendorOperationsService _service;
public VendorOperationsController(IVendorOperationsService service)
{
_service = service;
}
private string Actor() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.Identity?.Name
?? "unknown";
private IActionResult Map<T>(VendorOperationOutcome<T> outcome) => outcome.Kind switch
{
VendorOperationOutcomeKind.Ok => Ok(outcome.Value),
VendorOperationOutcomeKind.NotFound => outcome.Message is null
? NotFound()
: NotFound(new Response { Status = "Error", Message = outcome.Message }),
VendorOperationOutcomeKind.BadRequest => BadRequest(new Response { Status = "Error", Message = outcome.Message }),
VendorOperationOutcomeKind.Conflict => Conflict(new Response { Status = "Conflict", Message = outcome.Message }),
_ => StatusCode(StatusCodes.Status500InternalServerError)
};
[HttpGet("notifications")]
public async Task<IActionResult> GetNotifications(CancellationToken cancellationToken)
{
var result = await _service.GetNotificationsAsync(cancellationToken);
return Ok(new { result.GeneratedAt, result.Items });
}
[HttpGet("availability")]
public async Task<IActionResult> GetAvailability(
[FromQuery] DateTime? start,
[FromQuery] DateTime? end,
[FromQuery] int? locationId,
[FromQuery] string? trade,
CancellationToken cancellationToken)
{
var outcome = await _service.GetAvailabilityAsync(start, end, locationId, trade, cancellationToken);
return Map(outcome);
}
[HttpPut("vendors/{vendorId:int}/availability")]
public async Task<IActionResult> UpdateAvailability(int vendorId, [FromBody] VendorAvailabilityRequest body, CancellationToken cancellationToken)
{
var outcome = await _service.UpdateAvailabilityAsync(vendorId, body?.Status, Actor(), cancellationToken);
return Map(outcome);
}
[HttpGet("sites/{locationId:int}/preferred-vendors")]
public async Task<IActionResult> GetSitePreferences(int locationId, CancellationToken cancellationToken)
{
var preferences = await _service.GetSitePreferencesAsync(locationId, cancellationToken);
return Ok(preferences);
}
[HttpPut("sites/{locationId:int}/preferred-vendors")]
public async Task<IActionResult> ReplaceSitePreferences(int locationId, [FromBody] List<SitePreferenceRequest> body, CancellationToken cancellationToken)
{
var outcome = await _service.ReplaceSitePreferencesAsync(locationId, body ?? new List<SitePreferenceRequest>(), Actor(), cancellationToken);
return Map(outcome);
}
[HttpPut("documents/{documentId:int}/review")]
public async Task<IActionResult> ReviewDocument(int documentId, [FromBody] DocumentReviewRequest body, CancellationToken cancellationToken)
{
var outcome = await _service.ReviewDocumentAsync(documentId, body ?? new DocumentReviewRequest(), Actor(), cancellationToken);
return Map(outcome);
}
[HttpPut("dispatches/{dispatchId:int}/commercial-status")]
public async Task<IActionResult> UpdateCommercialStatus(int dispatchId, [FromBody] CommercialStatusRequest body, CancellationToken cancellationToken)
{
var outcome = await _service.UpdateCommercialStatusAsync(dispatchId, body ?? new CommercialStatusRequest(), Actor(), cancellationToken);
return Map(outcome);
}
[HttpPut("work-orders/{workOrderId:int}/assignment")]
public async Task<IActionResult> AssignVendor(int workOrderId, [FromBody] VendorAssignmentRequest body, CancellationToken cancellationToken)
{
var outcome = await _service.AssignVendorAsync(workOrderId, body ?? new VendorAssignmentRequest(), Actor(), cancellationToken);
return Map(outcome);
}
[HttpGet("insights")]
public async Task<IActionResult> GetInsights(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int? locationId,
[FromQuery] string? trade,
[FromQuery] int? vendorId,
CancellationToken cancellationToken)
{
var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken);
return Ok(new
{
GeneratedAt = _service.UtcNow,
Filters = new { from, to, locationId, trade, vendorId },
MetricDefinitions = MetricDefinitions(),
Data = rows
});
}
[HttpGet("insights.csv")]
public async Task<IActionResult> ExportInsightsCsv(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int? locationId,
[FromQuery] string? trade,
[FromQuery] int? vendorId,
CancellationToken cancellationToken)
{
var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken);
var csv = new StringBuilder("VendorId,Vendor,CompletedJobs,CancellationRefusalRate,OnTimeArrivalRate,OnTimeCompletionRate,AverageCycleHours\n");
foreach (var row in rows)
{
csv.AppendLine(string.Join(",",
row.VendorId,
Csv(row.Vendor),
row.CompletedJobs,
row.CancellationRefusalRate.ToString("0.####"),
row.OnTimeArrivalRate.ToString("0.####"),
row.OnTimeCompletionRate.ToString("0.####"),
row.AverageCycleHours?.ToString("0.##") ?? ""));
}
return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"vendor-insights-{_service.UtcNow:yyyyMMddHHmmss}.csv");
}
[HttpGet("insights.pdf")]
public async Task<IActionResult> ExportInsightsPdf(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] int? locationId,
[FromQuery] string? trade,
[FromQuery] int? vendorId,
CancellationToken cancellationToken)
{
var rows = await _service.GetInsightRowsAsync(from, to, locationId, trade, vendorId, cancellationToken);
var lines = new List<string>
{
"Vendor Performance Insights",
$"Generated: {_service.UtcNow:u}",
$"Filters: from={from:u}; to={to:u}; site={locationId}; trade={trade}; vendor={vendorId}",
"Definitions: completed jobs; cancellation/refusal rate; on-time acknowledgement; on-time completion; average dispatch-to-completion hours."
};
lines.AddRange(rows.Take(35).Select(row =>
$"{row.Vendor}: completed {row.CompletedJobs}; cancel/refusal {row.CancellationRefusalRate:P1}; arrival {row.OnTimeArrivalRate:P1}; completion {row.OnTimeCompletionRate:P1}; cycle {row.AverageCycleHours:0.0}h"));
return File(SimplePdf(lines), "application/pdf", $"vendor-insights-{_service.UtcNow:yyyyMMddHHmmss}.pdf");
}
private static string Csv(string value)
{
var spreadsheetSafe = value.Length > 0 && "=+-@\t\r".Contains(value[0])
? $"'{value}"
: value;
return $"\"{spreadsheetSafe.Replace("\"", "\"\"")}\"";
}
private static object MetricDefinitions() => new
{
CompletedJobs = "Dispatches with Completed or Verified status.",
CancellationRefusalRate = "Cancelled, Canceled, or Refused dispatches divided by all dispatches.",
OnTimeArrivalRate = "Acknowledgements at or before scheduled time divided by dispatches with both timestamps.",
OnTimeCompletionRate = "Completions at or before the work-order due date divided by completed dispatches with a due date.",
AverageCycleHours = "Average elapsed hours from dispatch to completion."
};
private static byte[] SimplePdf(IEnumerable<string> lines)
{
var escaped = lines.Select(line => line.Replace("\\", "\\\\").Replace("(", "\\(").Replace(")", "\\)"));
var content = "BT /F1 10 Tf 40 760 Td 13 TL "
+ string.Join(" T* ", escaped.Select(line => $"({line}) Tj"))
+ " ET";
var objects = new[]
{
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
$"<< /Length {Encoding.ASCII.GetByteCount(content)} >>\nstream\n{content}\nendstream",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
};
var builder = new StringBuilder("%PDF-1.4\n");
var offsets = new List<int> { 0 };
for (var index = 0; index < objects.Length; index++)
{
offsets.Add(Encoding.ASCII.GetByteCount(builder.ToString()));
builder.Append($"{index + 1} 0 obj\n{objects[index]}\nendobj\n");
}
var xrefOffset = Encoding.ASCII.GetByteCount(builder.ToString());
builder.Append($"xref\n0 {objects.Length + 1}\n0000000000 65535 f \n");
foreach (var offset in offsets.Skip(1)) builder.Append($"{offset:D10} 00000 n \n");
builder.Append($"trailer << /Size {objects.Length + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF");
return Encoding.ASCII.GetBytes(builder.ToString());
}
}
}

View file

@ -1,10 +1,8 @@
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace Api.SeaHavenIndustries.Controllers
{
@ -15,260 +13,650 @@ namespace Api.SeaHavenIndustries.Controllers
{
private const string TokenHeader = "X-Vendor-Token";
private readonly IVendorPortalService _portalService;
private readonly ILogger<VendorPortalController> _logger;
private static readonly string[] VendorViewableStatuses =
new[] { "Sent", "Acknowledged", "In Progress", "Completed", "Verified", "Cancelled" };
public VendorPortalController(IVendorPortalService portalService, ILogger<VendorPortalController> logger)
private readonly ApplicationDbContext _db;
private readonly VendorPortalTokenService _tokens;
private readonly IConfiguration _config;
private readonly SendMessage _sendMessage;
public VendorPortalController(ApplicationDbContext db, VendorPortalTokenService tokens, IConfiguration config, SendMessage sendMessage)
{
_portalService = portalService;
_logger = logger;
_db = db;
_tokens = tokens;
_config = config;
_sendMessage = sendMessage;
}
[HttpGet("session")]
public async Task<IActionResult> Session(CancellationToken cancellationToken = default)
public async Task<IActionResult> Session()
{
var session = await ResolveSessionAsync(cancellationToken);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
return Ok(new DataResponse
{
Status = "Success",
Data = session
Data = new
{
vendor.Id,
vendor.CompanyName,
vendor.ContactName,
vendor.Email,
vendor.Phone
}
});
}
[HttpGet("dispatches")]
public async Task<IActionResult> ListDispatches([FromQuery] string? status = null, CancellationToken cancellationToken = default)
public async Task<IActionResult> ListDispatches([FromQuery] string? status = null)
{
var session = await ResolveSessionAsync(cancellationToken);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var query = _db.Dispatches
.Include(d => d.WorkOrder).ThenInclude(w => w!.Locations)
.Where(d => d.VendorId == vendor.Id && (d.IsDeleted == null || d.IsDeleted == false));
if (!string.IsNullOrWhiteSpace(status))
{
query = query.Where(d => d.Status == status);
}
var dispatches = await query
.OrderByDescending(d => d.DispatchedAt ?? d.CreatedDate)
.Select(d => new
{
d.Id,
d.DispatchNumber,
d.PONumber,
d.Status,
d.NTEAmount,
d.ScheduledDate,
d.CompletedDate,
d.DispatchedAt,
d.AcknowledgedAt,
WorkOrderTitle = d.WorkOrder!.WorkerOrderTitle,
InternalWONumber = d.WorkOrder!.InternalWONumber,
LocationName = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.Name : null,
LocationCity = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.City : null,
LocationState = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.State : null
})
.ToListAsync();
var dispatches = await _portalService.ListDispatchesAsync(session, status, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = dispatches });
}
[HttpGet("dispatches/{id:int}")]
public async Task<IActionResult> GetDispatch(int id, CancellationToken cancellationToken = default)
public async Task<IActionResult> GetDispatch(int id)
{
var session = await ResolveSessionAsync(cancellationToken);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var detail = await _portalService.GetDispatchDetailAsync(session, id, cancellationToken);
if (detail == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
return Ok(new DataResponse { Status = "Success", Data = detail });
var checklist = await _db.DispatchChecklistItems
.Where(c => c.DispatchId == id && (c.IsDeleted == null || c.IsDeleted == false))
.OrderBy(c => c.SortOrder)
.Select(c => new { c.Id, c.ItemText, c.IsCompleted, c.CompletedBy, c.CompletedAt })
.ToListAsync();
var signoffs = await _db.DispatchSignoffs
.Where(s => s.DispatchId == id && (s.IsDeleted == null || s.IsDeleted == false))
.Select(s => new { s.Id, s.SignoffType, s.Name, s.SignatureMethod, s.SignedAt })
.ToListAsync();
var rawUplifts = await (from u in _db.DispatchUpliftRequests
where u.DispatchId == id && (u.IsDeleted == null || u.IsDeleted == false)
join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs
from dec in decs.DefaultIfEmpty()
orderby u.CreatedDate descending
select new
{
u.Id,
u.CurrentNTE,
u.RequestedNTE,
u.VendorReason,
u.Status,
u.RequiredTier,
u.RequestedByVendorName,
u.CreatedDate,
u.DecidedAt,
u.DecisionNote,
DecidedByFirstName = dec != null ? dec.FirstName : null,
DecidedByLastName = dec != null ? dec.LastName : null
})
.ToListAsync();
var upliftRequests = rawUplifts.Select(u => new
{
u.Id,
u.CurrentNTE,
u.RequestedNTE,
u.VendorReason,
u.Status,
u.RequiredTier,
u.RequestedByVendorName,
RequestedAt = u.CreatedDate,
u.DecidedAt,
u.DecisionNote,
DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim()
}).ToList();
var rawComments = await (from c in _db.Comments
where c.DispatchId == id
&& c.CommentType != "internal"
&& (c.IsDeleted == null || c.IsDeleted == false)
join u in _db.Users on c.UserId equals u.Id into users
from u in users.DefaultIfEmpty()
orderby c.CreatedDate
select new
{
c.Id,
c.Commenttext,
c.Commenter,
c.CommentType,
c.CreatedDate,
c.UserId,
UserFirstName = u != null ? u.FirstName : null,
UserLastName = u != null ? u.LastName : null
})
.ToListAsync();
var comments = rawComments.Select(c =>
{
var hasShocUser = !string.IsNullOrWhiteSpace(c.UserId);
var userName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim();
string resolvedType;
string resolvedCommenter;
if (hasShocUser)
{
resolvedType = "dispatcher";
resolvedCommenter = !string.IsNullOrWhiteSpace(userName) ? userName
: !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter!
: "Dispatcher";
}
else if (c.CommentType == "customer")
{
resolvedType = "customer";
resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : "Customer";
}
else
{
resolvedType = "vendor";
resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : vendor.CompanyName ?? "Vendor";
}
return new
{
c.Id,
c.Commenttext,
Commenter = resolvedCommenter,
CommentType = resolvedType,
c.CreatedDate
};
}).ToList();
return Ok(new DataResponse
{
Status = "Success",
Data = new
{
dispatch.Id,
dispatch.DispatchNumber,
dispatch.PONumber,
dispatch.Status,
dispatch.NTEAmount,
dispatch.Description,
dispatch.ScheduledDate,
dispatch.CompletedDate,
dispatch.DispatchedAt,
dispatch.AcknowledgedAt,
WorkOrder = dispatch.WorkOrder == null ? null : new
{
dispatch.WorkOrder.Id,
dispatch.WorkOrder.InternalWONumber,
dispatch.WorkOrder.WorkerOrderTitle,
dispatch.WorkOrder.Description,
dispatch.WorkOrder.Priority,
dispatch.WorkOrder.DueDate,
dispatch.WorkOrder.Trade,
dispatch.WorkOrder.SubTrade,
dispatch.WorkOrder.Problem
},
Location = dispatch.WorkOrder?.Locations == null ? null : new
{
dispatch.WorkOrder.Locations.Name,
dispatch.WorkOrder.Locations.Address1,
dispatch.WorkOrder.Locations.City,
dispatch.WorkOrder.Locations.State,
dispatch.WorkOrder.Locations.Zip
},
Checklist = checklist,
Signoffs = signoffs,
Comments = comments,
UpliftRequests = upliftRequests
}
});
}
[HttpPost("dispatches/{id:int}/accept")]
public async Task<IActionResult> Accept(int id, CancellationToken cancellationToken = default)
public async Task<IActionResult> Accept(int id)
{
var session = await ResolveSessionAsync(cancellationToken);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
try
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
if (dispatch.Status != "Sent")
{
var result = await _portalService.AcceptDispatchAsync(session, id, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
return BadRequest(new Response { Status = "Error", Message = $"Cannot accept a dispatch with status '{dispatch.Status}'" });
}
catch (KeyNotFoundException)
var now = DateTime.UtcNow;
dispatch.Status = "Acknowledged";
dispatch.AcknowledgedAt = now;
dispatch.LastModificationTime = now;
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
}
catch (InvalidOperationException ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") });
}
WorkOrderId = dispatch.WorkOrderId ?? 0,
FieldName = $"Dispatch {dispatch.DispatchNumber} Status",
OldValue = "Sent",
NewValue = "Acknowledged",
Action = "vendor_accept",
CreatedAt = now
});
await _db.SaveChangesAsync();
return Ok(new DataResponse { Status = "Success", Data = new { dispatch.Id, dispatch.Status, dispatch.AcknowledgedAt } });
}
[HttpPost("dispatches/{id:int}/status")]
public async Task<IActionResult> ChangeStatus(int id, [FromBody] ChangeStatusRequest body, CancellationToken cancellationToken = default)
public async Task<IActionResult> ChangeStatus(int id, [FromBody] ChangeStatusRequest body)
{
var session = await ResolveSessionAsync(cancellationToken);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
try
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
var from = dispatch.Status;
var to = body.Status;
if (!IsAllowedVendorTransition(from, to))
{
var result = await _portalService.ChangeStatusAsync(session, id, body?.Status, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
return BadRequest(new Response { Status = "Error", Message = $"Transition from '{from}' to '{to}' is not allowed" });
}
catch (KeyNotFoundException)
var now = DateTime.UtcNow;
dispatch.Status = to;
dispatch.LastModificationTime = now;
if (to == "Completed") dispatch.CompletedDate = now;
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
}
catch (InvalidOperationException ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") });
}
WorkOrderId = dispatch.WorkOrderId ?? 0,
FieldName = $"Dispatch {dispatch.DispatchNumber} Status",
OldValue = from,
NewValue = to,
Action = "vendor_status_change",
CreatedAt = now
});
await _db.SaveChangesAsync();
return Ok(new DataResponse { Status = "Success", Data = new { dispatch.Id, dispatch.Status, dispatch.CompletedDate } });
}
[HttpPost("dispatches/{id:int}/request-cancel")]
public async Task<IActionResult> RequestCancel(int id, [FromBody] RequestCancelRequest body, CancellationToken cancellationToken = default)
public async Task<IActionResult> RequestCancel(int id, [FromBody] RequestCancelRequest body)
{
var session = await ResolveSessionAsync(cancellationToken);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
try
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
{
await _portalService.RequestCancelAsync(session, id, body?.Reason, cancellationToken);
return Ok(new DataResponse { Status = "Success", Message = "Cancel request sent to dispatcher" });
return BadRequest(new Response { Status = "Error", Message = $"Cannot request cancel on a '{dispatch.Status}' dispatch" });
}
catch (KeyNotFoundException)
var now = DateTime.UtcNow;
var reason = string.IsNullOrWhiteSpace(body?.Reason) ? "(no reason provided)" : body!.Reason!.Trim();
_db.Comments.Add(new Comments
{
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
}
catch (InvalidOperationException ex)
DispatchId = id,
WorkerOrderId = dispatch.WorkOrderId,
Commenter = vendor.CompanyName,
CommentType = "vendor",
RecordType = "cancel_request",
Commenttext = $"Vendor requested cancellation: {reason}",
CreatedDate = now
});
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") });
}
WorkOrderId = dispatch.WorkOrderId ?? 0,
FieldName = $"Dispatch {dispatch.DispatchNumber}",
OldValue = dispatch.Status,
NewValue = "Cancel Requested",
Action = "vendor_request_cancel",
CreatedAt = now
});
await _db.SaveChangesAsync();
return Ok(new DataResponse { Status = "Success", Message = "Cancel request sent to dispatcher" });
}
[HttpPost("dispatches/{id:int}/checklist/{itemId:int}")]
public async Task<IActionResult> UpdateChecklistItem(int id, int itemId, [FromBody] ChecklistUpdateRequest body, CancellationToken cancellationToken = default)
public async Task<IActionResult> UpdateChecklistItem(int id, int itemId, [FromBody] ChecklistUpdateRequest body)
{
var session = await ResolveSessionAsync(cancellationToken);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
try
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
{
var result = await _portalService.UpdateChecklistItemAsync(session, id, itemId, body.IsCompleted, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
return BadRequest(new Response { Status = "Error", Message = "Dispatch is locked" });
}
catch (KeyNotFoundException)
var item = await _db.DispatchChecklistItems
.FirstOrDefaultAsync(c => c.Id == itemId && c.DispatchId == id);
if (item == null) return NotFound(new Response { Status = "Error", Message = "Checklist item not found" });
var now = DateTime.UtcNow;
item.IsCompleted = body.IsCompleted;
item.CompletedBy = body.IsCompleted ? vendor.CompanyName : null;
item.CompletedAt = body.IsCompleted ? now : null;
item.LastModificationTime = now;
await _db.SaveChangesAsync();
return Ok(new DataResponse
{
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
}
catch (InvalidOperationException ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") });
}
Status = "Success",
Data = new { item.Id, item.IsCompleted, item.CompletedBy, item.CompletedAt }
});
}
[HttpPost("dispatches/{id:int}/signoff")]
public async Task<IActionResult> AddSignoff(int id, [FromBody] SignoffRequest body, CancellationToken cancellationToken = default)
public async Task<IActionResult> AddSignoff(int id, [FromBody] SignoffRequest body)
{
var session = await ResolveSessionAsync(cancellationToken);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
try
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
if (dispatch.Status != "In Progress" && dispatch.Status != "Completed")
{
var result = await _portalService.AddSignoffAsync(session, id, body?.SignoffType, body?.Name, body?.Signature, body?.SignatureMethod, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
return BadRequest(new Response { Status = "Error", Message = "Signoffs only allowed on In Progress or Completed dispatches" });
}
catch (KeyNotFoundException)
if (string.IsNullOrWhiteSpace(body?.Name) || string.IsNullOrWhiteSpace(body?.Signature))
{
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
return BadRequest(new Response { Status = "Error", Message = "Name and signature are required" });
}
catch (InvalidOperationException ex)
var signoffType = (body?.SignoffType ?? "vendor").Trim().ToLowerInvariant();
if (signoffType != "vendor" && signoffType != "customer")
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") });
return BadRequest(new Response { Status = "Error", Message = "signoffType must be 'vendor' or 'customer'" });
}
var existing = await _db.DispatchSignoffs
.FirstOrDefaultAsync(s => s.DispatchId == id && s.SignoffType == signoffType);
if (existing != null)
{
return BadRequest(new Response { Status = "Error", Message = $"A {signoffType} signoff already exists for this dispatch" });
}
var now = DateTime.UtcNow;
var signoff = new DispatchSignoff
{
DispatchId = id,
SignoffType = signoffType,
Name = body!.Name,
Signature = body.Signature,
SignatureMethod = body.SignatureMethod ?? "drawn",
SignedAt = now,
CreatedDate = now
};
_db.DispatchSignoffs.Add(signoff);
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
FieldName = $"Dispatch {dispatch.DispatchNumber} Signoff",
OldValue = null,
NewValue = $"{(signoffType == "customer" ? "Customer" : "Vendor")}: {body.Name}",
Action = signoffType == "customer" ? "customer_signoff" : "vendor_signoff",
CreatedAt = now
});
await _db.SaveChangesAsync();
return Ok(new DataResponse { Status = "Success", Data = new { signoff.Id, signoff.SignedAt } });
}
[HttpPost("dispatches/{id:int}/comments")]
public async Task<IActionResult> AddComment(int id, [FromBody] CommentRequest body, CancellationToken cancellationToken = default)
public async Task<IActionResult> AddComment(int id, [FromBody] CommentRequest body)
{
var session = await ResolveSessionAsync(cancellationToken);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
try
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
if (string.IsNullOrWhiteSpace(body?.CommentText))
{
var result = await _portalService.AddCommentAsync(session, id, body?.CommentText, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
return BadRequest(new Response { Status = "Error", Message = "Comment cannot be empty" });
}
catch (KeyNotFoundException)
var now = DateTime.UtcNow;
var comment = new Comments
{
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
}
catch (InvalidOperationException ex)
DispatchId = id,
WorkerOrderId = dispatch.WorkOrderId,
Commenter = vendor.CompanyName,
CommentType = "vendor",
RecordType = "comment",
Commenttext = body!.CommentText,
CreatedDate = now
};
_db.Comments.Add(comment);
await _db.SaveChangesAsync();
return Ok(new DataResponse
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") });
}
Status = "Success",
Data = new { comment.Id, comment.Commenttext, comment.Commenter, comment.CreatedDate }
});
}
[HttpPost("dispatches/{id:int}/uplift-request")]
public async Task<IActionResult> RequestUplift(int id, [FromBody] UpliftRequestBody body, CancellationToken cancellationToken = default)
public async Task<IActionResult> RequestUplift(int id, [FromBody] UpliftRequestBody body)
{
var session = await ResolveSessionAsync(cancellationToken);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
try
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
{
var result = await _portalService.RequestUpliftAsync(session, id, body?.RequestedNTE ?? 0m, body?.Reason, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
return BadRequest(new Response { Status = "Error", Message = $"Cannot request uplift on a '{dispatch.Status}' dispatch" });
}
catch (KeyNotFoundException)
if (body == null || body.RequestedNTE <= 0)
{
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
return BadRequest(new Response { Status = "Error", Message = "Requested NTE must be greater than zero" });
}
catch (InvalidOperationException ex)
var current = dispatch.NTEAmount ?? 0m;
if (body.RequestedNTE <= current)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") });
return BadRequest(new Response { Status = "Error", Message = "Requested NTE must be greater than the current NTE" });
}
var pendingExists = await _db.DispatchUpliftRequests
.AnyAsync(u => u.DispatchId == id && u.Status == "Pending" && (u.IsDeleted == null || u.IsDeleted == false));
if (pendingExists)
{
return BadRequest(new Response { Status = "Error", Message = "A pending uplift request already exists for this dispatch" });
}
var tier1Max = _config.GetValue<decimal?>("Approvals:UpliftTier1MaxUsd") ?? 2500m;
var delta = body.RequestedNTE - current;
var requiredTier = delta > tier1Max ? 2 : 1;
var now = DateTime.UtcNow;
var req = new DispatchUpliftRequest
{
DispatchId = id,
CurrentNTE = current,
RequestedNTE = body.RequestedNTE,
VendorReason = string.IsNullOrWhiteSpace(body.Reason) ? null : body.Reason!.Trim(),
Status = "Pending",
RequiredTier = requiredTier,
RequestedByVendorName = vendor.CompanyName,
CreatedDate = now
};
_db.DispatchUpliftRequests.Add(req);
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
OldValue = $"${current:F2}",
NewValue = $"${body.RequestedNTE:F2}",
Action = "uplift_requested",
CreatedAt = now
});
await _db.SaveChangesAsync();
await NotifyDispatcherOfUpliftAsync(dispatch, req, vendor);
return Ok(new DataResponse
{
Status = "Success",
Data = new { req.Id, req.Status, req.RequiredTier, req.CurrentNTE, req.RequestedNTE }
});
}
[HttpPost("dispatches/{id:int}/uplift-request/{requestId:int}/cancel")]
public async Task<IActionResult> CancelUpliftRequest(int id, int requestId, CancellationToken cancellationToken = default)
public async Task<IActionResult> CancelUpliftRequest(int id, int requestId)
{
var session = await ResolveSessionAsync(cancellationToken);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
var req = await _db.DispatchUpliftRequests
.FirstOrDefaultAsync(u => u.Id == requestId && u.DispatchId == id);
if (req == null) return NotFound(new Response { Status = "Error", Message = "Uplift request not found" });
if (req.Status != "Pending")
{
return BadRequest(new Response { Status = "Error", Message = $"Cannot cancel a '{req.Status}' uplift request" });
}
var now = DateTime.UtcNow;
req.Status = "Cancelled";
req.DecidedAt = now;
req.LastModificationTime = now;
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
{
WorkOrderId = dispatch.WorkOrderId ?? 0,
FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift",
OldValue = "Pending",
NewValue = "Cancelled",
Action = "uplift_cancelled",
CreatedAt = now
});
await _db.SaveChangesAsync();
return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } });
}
private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, Vendor vendor)
{
try
{
var result = await _portalService.CancelUpliftRequestAsync(session, id, requestId, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
var dispatchAudit = await _db.WorkOrderAuditLogs
.Where(a => a.WorkOrderId == (dispatch.WorkOrderId ?? 0) && a.Action == "dispatch" && !string.IsNullOrEmpty(a.UserId))
.OrderByDescending(a => a.CreatedAt)
.FirstOrDefaultAsync();
if (dispatchAudit == null || string.IsNullOrWhiteSpace(dispatchAudit.UserId)) return;
var dispatcher = await _db.Users.FindAsync(dispatchAudit.UserId);
if (dispatcher == null || string.IsNullOrWhiteSpace(dispatcher.Email)) return;
var frontendBase = _config.GetValue<string>("FrontendBaseUrl")?.TrimEnd('/') ?? "";
var workOrderLink = dispatch.WorkOrderId.HasValue
? $"{frontendBase}/workorders/{dispatch.WorkOrderId.Value}"
: frontendBase;
var vendorName = System.Net.WebUtility.HtmlEncode(vendor.CompanyName ?? "Vendor");
var reason = System.Net.WebUtility.HtmlEncode(req.VendorReason ?? "(no reason provided)");
var dispatchNum = System.Net.WebUtility.HtmlEncode(dispatch.DispatchNumber ?? "");
var tierText = req.RequiredTier == 2 ? "Tier 2 (Manager approval required)" : "Tier 1";
var subject = $"[Uplift Request] {dispatch.DispatchNumber} — {vendor.CompanyName} requests ${req.RequestedNTE:F2} (was ${req.CurrentNTE ?? 0m:F2})";
var body = $@"
<h2>Vendor Uplift Request</h2>
<p><strong>Dispatch:</strong> {dispatchNum}</p>
<p><strong>Vendor:</strong> {vendorName}</p>
<table style='border-collapse:collapse;font-family:Arial,sans-serif;'>
<tr><td style='padding:4px 10px;'><strong>Current NTE</strong></td><td style='padding:4px 10px;'>${req.CurrentNTE ?? 0m:F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Requested NTE</strong></td><td style='padding:4px 10px;'>${req.RequestedNTE:F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Delta</strong></td><td style='padding:4px 10px;'>${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
</table>
<h3>Vendor Reason</h3>
<p>{reason}</p>
<div style='margin:20px 0;'>
<a href='{workOrderLink}' style='display:inline-block;padding:10px 22px;background:#2563eb;color:white;text-decoration:none;border-radius:6px;font-weight:bold;'>Review in SHOC</a>
</div>";
await _sendMessage.SendEMail(dispatcher.Email, subject, body);
}
catch (KeyNotFoundException)
catch
{
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
}
catch (InvalidOperationException ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") });
}
}
[HttpPost("dispatches/{id:int}/completion-documents")]
[RequestSizeLimit(10_000_000)]
public async Task<IActionResult> UploadCompletionDocument(int id, [FromForm] IFormFile file, CancellationToken cancellationToken = default)
{
var session = await ResolveSessionAsync(cancellationToken);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
try
{
var result = await _portalService.UploadCompletionDocumentAsync(session, id, file, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
}
catch (InvalidOperationException ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The completion document could not be uploaded") });
}
}
[HttpGet("dispatches/{id:int}/completion-documents/{documentId:int}")]
public async Task<IActionResult> DownloadCompletionDocument(int id, int documentId, CancellationToken cancellationToken = default)
{
var session = await ResolveSessionAsync(cancellationToken);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var result = await _portalService.DownloadCompletionDocumentAsync(session, id, documentId, cancellationToken);
return result.Outcome switch
{
VendorDocumentDownloadOutcome.Ok => File(result.Content!, result.ContentType!, result.FileName!),
VendorDocumentDownloadOutcome.Locked => StatusCode(StatusCodes.Status423Locked, new Response { Status = "Locked", Message = "The completion document is not available for download yet." }),
_ => NotFound(new Response { Status = "Error", Message = "Completion document not found" })
};
}
private async Task<VendorPortalSession?> ResolveSessionAsync(CancellationToken cancellationToken)
private async Task<Vendor?> ResolveVendorAsync()
{
if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null;
var token = values.ToString();
return await _portalService.ResolveSessionAsync(token, cancellationToken);
return await _tokens.ResolveVendorAsync(token);
}
private Task<Dispatch?> LoadVendorDispatchAsync(int id, int vendorId)
{
return _db.Dispatches
.Include(d => d.WorkOrder).ThenInclude(w => w!.Locations)
.FirstOrDefaultAsync(d => d.Id == id
&& d.VendorId == vendorId
&& (d.IsDeleted == null || d.IsDeleted == false));
}
private static bool IsAllowedVendorTransition(string? from, string? to)
{
if (from == "Acknowledged" && to == "In Progress") return true;
if (from == "In Progress" && to == "Completed") return true;
return false;
}
public class ChangeStatusRequest

View file

@ -1,218 +0,0 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/WorkOrder")]
[Route("api/workorders")]
public class WorkOrderBoardController : Controller
{
private readonly IWorkOrderBoardService _workOrderBoardService;
private readonly IWorkOrderBoardUpdateService _boardUpdateService;
private readonly IWorkOrderBoardCreateService _boardCreateService;
private readonly IWorkOrderBoardCancelService _boardCancelService;
private readonly IWorkOrderAdvancedSearchService _advancedSearchService;
public WorkOrderBoardController(
IWorkOrderBoardService workOrderBoardService,
IWorkOrderBoardUpdateService boardUpdateService,
IWorkOrderBoardCreateService boardCreateService,
IWorkOrderBoardCancelService boardCancelService,
IWorkOrderAdvancedSearchService advancedSearchService)
{
_workOrderBoardService = workOrderBoardService;
_boardUpdateService = boardUpdateService;
_boardCreateService = boardCreateService;
_boardCancelService = boardCancelService;
_advancedSearchService = advancedSearchService;
}
/// <summary>
/// Weekly board payload.
/// When <paramref name="myWorkOrders"/> is true, the authenticated user filter wins and
/// <paramref name="dispatchers"/> is ignored (not AND-combined).
/// Weekend WOs in a Mon–Sun window may have <c>dayGroup = null</c>; FE should handle that.
/// </summary>
[HttpGet("board")]
public async Task<IActionResult> GetBoard(
[FromQuery, BindRequired] DateOnly weekStart,
[FromQuery] DateOnly? weekEnd = null,
[FromQuery] List<string>? dispatchers = null,
[FromQuery] bool myWorkOrders = false,
[FromQuery] List<WorkOrderType>? types = null,
[FromQuery] bool overdue = false,
[FromQuery] string? search = null)
{
var resolvedWeekEnd = weekEnd ?? weekStart.AddDays(4);
var weekValidationError = WorkOrderOperationalWeek.ValidateWeekWindow(weekStart, resolvedWeekEnd);
if (weekValidationError != null)
return BadRequest(weekValidationError);
try
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var query = new WorkOrderBoardQueryDto
{
WeekStart = weekStart,
WeekEnd = weekEnd,
Dispatchers = dispatchers,
MyWorkOrders = myWorkOrders,
Types = types,
Overdue = overdue,
Search = search
};
var result = await _workOrderBoardService.GetBoardAsync(query, userId);
return Ok(result);
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
}
[HttpGet("board/search")]
public async Task<IActionResult> SearchBoard([FromQuery] WorkOrderAdvancedSearchQueryDto query)
{
try
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var result = await _advancedSearchService.SearchAsync(query, userId);
return Ok(result);
}
catch (ArgumentException ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
/// <summary>
/// Dispatcher dropdown options. Currently returns all non-deleted users
/// (role-filtered assignee list deferred until roles settle).
/// </summary>
[HttpGet("lookups/dispatchers")]
public async Task<IActionResult> GetDispatcherLookups()
{
var result = await _workOrderBoardService.GetDispatcherLookupsAsync();
return Ok(result);
}
[HttpPatch("{id:int}/board")]
public async Task<IActionResult> PatchBoardField(int id, [FromBody] WorkOrderBoardPatchRequestDto request)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var row = await _boardUpdateService.PatchFieldAsync(id, request, actorId);
return Ok(row);
}
catch (WorkOrderBoardConcurrencyException ex)
{
return Conflict(new WorkOrderBoardConflictDto
{
CurrentState = ex.CurrentState
});
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (ArgumentException ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
}
}
[HttpPost("board")]
public async Task<IActionResult> CreateBoardWorkOrder([FromBody] WorkOrderBoardCreateRequestDto request)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var row = await _boardCreateService.CreateAsync(request, actorId);
return Ok(row);
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "DuplicateWoNumber")
{
return Conflict(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
}
[HttpPost("{id:int}/cancel")]
public async Task<IActionResult> CancelBoardWorkOrder(int id)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var row = await _boardCancelService.CancelAsync(id, actorId);
return Ok(row);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto
{
Code = ex.Code,
Message = ex.Message
});
}
}
}
}

View file

@ -1,139 +0,0 @@
using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/WorkOrder")]
[Route("api/workorders")]
public class WorkOrderCompletionController : Controller
{
private readonly IWorkOrderCompletionService _workOrderCompletionService;
private readonly IFileStoragePort _fileStorage;
public WorkOrderCompletionController(
IWorkOrderCompletionService workOrderCompletionService,
IFileStoragePort fileStorage)
{
_workOrderCompletionService = workOrderCompletionService;
_fileStorage = fileStorage;
}
[HttpGet("completion-templates")]
public async Task<IActionResult> GetCompletionTemplates(
[FromQuery] string? serviceKey = null,
[FromQuery] WorkOrderType? workOrderType = null)
{
var templates = await _workOrderCompletionService.GetTemplatesAsync(serviceKey, workOrderType);
return Ok(templates);
}
[HttpGet("completion-templates/{id:int}")]
public async Task<IActionResult> GetCompletionTemplate(int id)
{
var template = await _workOrderCompletionService.GetTemplateByIdAsync(id);
if (template == null)
return NotFound(new Response { Status = "Error", Message = "Template not found." });
return Ok(template);
}
[Authorize(Roles = "Admin")]
[HttpPost("completion-templates")]
public async Task<IActionResult> CreateCompletionTemplate([FromBody] CompletionDocTemplateCreateDto request)
{
try
{
var created = await _workOrderCompletionService.CreateTemplateAsync(request);
return Ok(created);
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[Authorize(Roles = "Admin")]
[HttpPut("completion-templates/{id:int}")]
public async Task<IActionResult> UpdateCompletionTemplate(int id, [FromBody] CompletionDocTemplateCreateDto request)
{
try
{
var updated = await _workOrderCompletionService.UpdateTemplateAsync(id, request);
return Ok(updated);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[Authorize(Roles = "Admin")]
[HttpDelete("completion-templates/{id:int}")]
public async Task<IActionResult> DeleteCompletionTemplate(int id)
{
try
{
await _workOrderCompletionService.DeleteTemplateAsync(id);
return NoContent();
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[HttpPost("{id:int}/completion-doc")]
[RequestSizeLimit(30_000_000)]
public async Task<IActionResult> UploadCompletionDoc(
int id,
[FromForm] IFormFile file,
[FromForm] string? signOffName = null,
[FromForm] string? signOffSignature = null,
[FromForm] string? workOrderVersion = null)
{
if (file == null || file.Length == 0)
return BadRequest(new Response { Status = "Error", Message = "file is required." });
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var fileUrl = await _fileStorage.SaveFileAsync(file);
var result = await _workOrderCompletionService.UploadCompletionDocAsync(
id,
new WorkOrderCompletionDocUploadDto
{
SignOffName = signOffName,
SignOffSignature = signOffSignature,
WorkOrderVersion = workOrderVersion
},
fileUrl,
actorId);
return Ok(result);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code is "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code is "ConcurrencyConflict")
{
return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,103 +0,0 @@
using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/WorkOrder")]
[Route("api/workorders")]
public class WorkOrderDetailController : Controller
{
private readonly IWorkOrderDetailService _workOrderDetailService;
private readonly IWorkOrderCommentService _workOrderCommentService;
public WorkOrderDetailController(
IWorkOrderDetailService workOrderDetailService,
IWorkOrderCommentService workOrderCommentService)
{
_workOrderDetailService = workOrderDetailService;
_workOrderCommentService = workOrderCommentService;
}
[HttpGet("{id:int}/detail")]
public async Task<IActionResult> GetDetail(int id)
{
var detail = await _workOrderDetailService.GetDetailAsync(id);
if (detail == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(detail);
}
[HttpGet("{id:int}/audit")]
public async Task<IActionResult> GetAudit(int id, [FromQuery] int limit = 50)
{
var audit = await _workOrderDetailService.GetAuditAsync(id, limit);
if (audit == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(audit);
}
[HttpGet("{id:int}/comments")]
public async Task<IActionResult> GetBoardComments(int id)
{
var comments = await _workOrderCommentService.GetCommentsAsync(id);
if (comments == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(comments);
}
[HttpPost("{id:int}/comments")]
public async Task<IActionResult> AddBoardComment(int id, [FromBody] WorkOrderCommentCreateDto request)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var comment = await _workOrderCommentService.AddCommentAsync(id, request, actorId);
return Ok(comment);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[HttpPatch("{id:int}/comments/{commentId:int}")]
public async Task<IActionResult> UpdateBoardComment(
int id,
int commentId,
[FromBody] WorkOrderCommentCreateDto request)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var comment = await _workOrderCommentService.UpdateCommentAsync(
id, commentId, request, actorId);
return Ok(comment);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden,
new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
}
}

View file

@ -1,214 +0,0 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/WorkOrder")]
[Route("api/workorders")]
public class WorkOrderDispatchController : Controller
{
private readonly IWorkOrderDispatchService _dispatchService;
private readonly ILogger<WorkOrderDispatchController> _logger;
public WorkOrderDispatchController(
IWorkOrderDispatchService dispatchService,
ILogger<WorkOrderDispatchController> logger)
{
_dispatchService = dispatchService;
_logger = logger;
}
[HttpPost]
[Route("DispatchToVendor")]
public async Task<IActionResult> DispatchToVendor([FromBody] Dispatch_DTO model, CancellationToken cancellationToken)
{
try
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var input = new DispatchToVendorInput
{
WorkOrderIds = model.WorkOrderIds,
VendorIds = model.VendorIds,
NTEAmount = model.NTEAmount,
Description = model.Description,
ScheduledDate = model.ScheduledDate,
TaskListTemplateId = model.TaskListTemplateId,
CustomChecklistItems = model.CustomChecklistItems,
UserId = userId
};
var result = await _dispatchService.DispatchToVendorAsync(input, cancellationToken);
return Ok(result);
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpGet("GetDispatches/{workOrderId}")]
public async Task<IActionResult> GetDispatches(int workOrderId)
{
var data = await _dispatchService.GetDispatchesAsync(workOrderId);
return Ok(data);
}
[HttpGet("GetDispatch/{id}")]
public async Task<IActionResult> GetDispatchById(int id)
{
var d = await _dispatchService.GetDispatchDetailAsync(id);
if (d == null)
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
return Ok(d);
}
[HttpPost("UpdateDispatch")]
public async Task<IActionResult> UpdateDispatch([FromBody] UpdateDispatch_DTO model)
{
try
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var input = new UpdateDispatchInput
{
Id = model.Id,
Status = model.Status,
NTEAmount = model.NTEAmount,
ScheduledDate = model.ScheduledDate,
CompletedDate = model.CompletedDate,
Description = model.Description,
UserId = userId
};
var success = await _dispatchService.UpdateDispatchAsync(input);
if (!success)
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
return Ok(new { message = "Dispatch updated" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPost("AddDispatchComment")]
public async Task<IActionResult> AddDispatchComment([FromBody] DispatchComment_DTO model)
{
try
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var input = new AddDispatchCommentInput
{
DispatchId = model.DispatchId,
Text = model.Text,
SendEmail = model.SendEmail,
UserId = userId
};
var result = await _dispatchService.AddDispatchCommentAsync(input);
if (result == null)
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
return Ok(result);
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPost("VerifyDispatch")]
public async Task<IActionResult> VerifyDispatch(int dispatchId)
{
try
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var result = await _dispatchService.VerifyDispatchAsync(dispatchId, userId!);
if (result == null)
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
return Ok(result);
}
catch (DispatchVerificationException ex)
{
return BadRequest(new { status = "Error", message = "Cannot verify", missing = ex.Missing });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPost("AddDispatchSignoff")]
public async Task<IActionResult> AddDispatchSignoff([FromBody] DispatchSignoff_DTO model)
{
try
{
var input = new AddDispatchSignoffInput
{
DispatchId = model.DispatchId,
SignoffType = model.SignoffType,
Name = model.Name,
Signature = model.Signature,
SignatureMethod = model.SignatureMethod
};
var result = await _dispatchService.AddDispatchSignoffAsync(input);
if (result == null)
return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" });
return Ok(result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The dispatch signoff could not be added") });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPost("UpdateChecklistItem")]
public async Task<IActionResult> UpdateChecklistItem([FromBody] ChecklistItemUpdate_DTO model)
{
var input = new ChecklistItemUpdateInput
{
Id = model.Id,
IsCompleted = model.IsCompleted,
CompletedBy = model.CompletedBy
};
var result = await _dispatchService.UpdateChecklistItemAsync(input);
if (result == null)
return NotFound(new Response { Status = "Error", Message = "Checklist item not found" });
return Ok(result);
}
[HttpPost("AddChecklistItem")]
public async Task<IActionResult> AddChecklistItem([FromBody] AddChecklistItem_DTO model)
{
var input = new AddChecklistItemInput
{
DispatchId = model.DispatchId,
WorkOrderId = model.WorkOrderId,
ItemText = model.ItemText
};
var result = await _dispatchService.AddChecklistItemAsync(input);
return Ok(result);
}
}
}

View file

@ -1,85 +0,0 @@
using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("api/WorkOrder")]
[Route("api/workorders")]
public class WorkOrderMediaController : Controller
{
private readonly IWorkOrderMediaService _workOrderMediaService;
private readonly IFileStoragePort _fileStorage;
public WorkOrderMediaController(
IWorkOrderMediaService workOrderMediaService,
IFileStoragePort fileStorage)
{
_workOrderMediaService = workOrderMediaService;
_fileStorage = fileStorage;
}
[HttpGet("{id:int}/media")]
public async Task<IActionResult> GetMedia(int id)
{
var media = await _workOrderMediaService.GetMediaAsync(id);
if (media == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(media);
}
[HttpPost("{id:int}/media")]
[RequestSizeLimit(30_000_000)]
public async Task<IActionResult> AddMedia(
int id,
[FromForm] WorkOrderMediaCategory category,
[FromForm] IFormFile file)
{
if (file == null || file.Length == 0)
return BadRequest(new Response { Status = "Error", Message = "file is required." });
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var fileUrl = await _fileStorage.SaveFileAsync(file);
var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId);
return Ok(media);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[HttpDelete("{id:int}/media/{mediaId:int}")]
public async Task<IActionResult> DeleteMedia(int id, int mediaId)
{
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
await _workOrderMediaService.DeleteMediaAsync(id, mediaId, actorId);
return NoContent();
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
}
}

View file

@ -6,8 +6,6 @@ namespace Api.SeaHavenIndustries.DTOs
public List<int> VendorIds { get; set; } = new();
public decimal? NTEAmount { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateTime? EstimatedArrivalAt { get; set; }
public bool EtaManualOverride { get; set; }
public string? Description { get; set; }
public int? TaskListTemplateId { get; set; }
public List<string>? CustomChecklistItems { get; set; }

View file

@ -1,12 +0,0 @@
namespace Api.SeaHavenIndustries.DTOs
{
public class DropdownOption_DTO
{
public int Id { get; set; }
public string Category { get; set; } = "";
public string Value { get; set; } = "";
public string? ParentValue { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
}

View file

@ -6,8 +6,6 @@ namespace Api.SeaHavenIndustries.DTOs
public string? Status { get; set; }
public decimal? NTEAmount { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateTime? EstimatedArrivalAt { get; set; }
public bool EtaManualOverride { get; set; }
public DateTime? CompletedDate { get; set; }
public string? Description { get; set; }
}

View file

@ -3,34 +3,19 @@ namespace Api.SeaHavenIndustries.DTOs
public class Vendor_DTO
{
public string? CompanyName { get; set; }
public int? CompanyId { get; set; }
public string? ContactName { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? CompanyPhone { get; set; }
public string? PreferredContact { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? TradeSpecialties { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public bool? IsActive { get; set; }
public bool IsActive { get; set; } = true;
}
public class EditVendor_DTO : Vendor_DTO
{
public int Id { get; set; }
}
public class WorkOrderVendorUpdate_DTO
{
public int WorkOrderId { get; set; }
public string? ContactName { get; set; }
public string? PreferredContact { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? Notes { get; set; }
}
}

View file

@ -1,21 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace Api.SeaHavenIndustries.Filters
{
public class ArgumentExceptionFilter : IExceptionFilter
{
public void OnException(ExceptionContext context)
{
if (context.Exception is not ArgumentException ex) return;
context.Result = new BadRequestObjectResult(new
{
status = "Error",
message = ex.Message,
code = 400
});
context.ExceptionHandled = true;
}
}
}

View file

@ -1,70 +0,0 @@
using System.Buffers.Binary;
using System.Net.Sockets;
using System.Text;
namespace Api.SeaHavenIndustries.Helper
{
public enum DocumentScanResult
{
Passed,
Infected,
Unavailable
}
public interface IDocumentScanner
{
Task<DocumentScanResult> ScanAsync(string path, CancellationToken cancellationToken);
}
public class ClamAvDocumentScanner : IDocumentScanner
{
private readonly IConfiguration _configuration;
public ClamAvDocumentScanner(IConfiguration configuration)
{
_configuration = configuration;
}
public async Task<DocumentScanResult> ScanAsync(string path, CancellationToken cancellationToken)
{
var host = _configuration["ClamAV:Host"];
var port = _configuration.GetValue<int?>("ClamAV:Port") ?? 3310;
if (string.IsNullOrWhiteSpace(host) || !File.Exists(path))
return DocumentScanResult.Unavailable;
try
{
using var client = new TcpClient();
await client.ConnectAsync(host, port, cancellationToken);
await using var network = client.GetStream();
await network.WriteAsync(Encoding.ASCII.GetBytes("zINSTREAM\0"), cancellationToken);
await using var file = File.OpenRead(path);
var buffer = new byte[64 * 1024];
while (true)
{
var count = await file.ReadAsync(buffer, cancellationToken);
if (count == 0) break;
var length = new byte[4];
BinaryPrimitives.WriteInt32BigEndian(length, count);
await network.WriteAsync(length, cancellationToken);
await network.WriteAsync(buffer.AsMemory(0, count), cancellationToken);
}
await network.WriteAsync(new byte[4], cancellationToken);
await network.FlushAsync(cancellationToken);
var responseBuffer = new byte[1024];
var responseLength = await network.ReadAsync(responseBuffer, cancellationToken);
var response = Encoding.ASCII.GetString(responseBuffer, 0, responseLength);
if (response.Contains("FOUND", StringComparison.OrdinalIgnoreCase))
return DocumentScanResult.Infected;
return response.Contains("OK", StringComparison.OrdinalIgnoreCase)
? DocumentScanResult.Passed
: DocumentScanResult.Unavailable;
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
return DocumentScanResult.Unavailable;
}
}
}
}

View file

@ -1,27 +0,0 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
namespace Api.SeaHavenIndustries.Helper;
internal static class SanitizedErrors
{
private const string DefaultPublicMessage = "An unexpected error occurred";
public static string Sanitize(
this ILogger logger,
Exception exception,
string? publicMessage = null,
[CallerMemberName] string operation = "")
{
var correlationId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
logger.LogError(
exception,
"Unhandled exception in {Operation}. CorrelationId: {CorrelationId}",
operation,
correlationId);
return $"{publicMessage ?? DefaultPublicMessage} (reference {correlationId}).";
}
}

View file

@ -1,20 +0,0 @@
using Api.SeaHavenIndustries.Helper;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Helper
{
public class SendGridEmailSender : IEmailSender
{
private readonly SendMessage _sendMessage;
public SendGridEmailSender(SendMessage sendMessage)
{
_sendMessage = sendMessage;
}
public Task<bool> SendEmailAsync(string emailTo, string subject, string body)
{
return _sendMessage.SendEMail(emailTo, subject, body);
}
}
}

View file

@ -1,12 +1,11 @@

using SeaHaven.Services.Interfaces;
using SendGrid;
using SendGrid.Helpers.Mail;
namespace Api.SeaHavenIndustries.Helper
{
public class SendMessage : IEmailSender
public class SendMessage
{
private IConfiguration _configuration;
//string keysapi = "";
@ -59,8 +58,6 @@ namespace Api.SeaHavenIndustries.Helper
return false;
}
}
public async Task<bool> SendEmailAsync(string emailTo, string subject, string htmlBody)
=> await SendEMail(emailTo, subject, htmlBody);
public async Task<bool> SendEMailAttachment(string emailTo, string subject, byte[] pdfBytes)
{
try

View file

@ -1,78 +0,0 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
namespace Api.SeaHavenIndustries.Helper
{
public class VendorDocumentScanWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IWebHostEnvironment _environment;
private readonly ILogger<VendorDocumentScanWorker> _logger;
public VendorDocumentScanWorker(
IServiceScopeFactory scopeFactory,
IWebHostEnvironment environment,
ILogger<VendorDocumentScanWorker> logger)
{
_scopeFactory = scopeFactory;
_environment = environment;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await ScanPendingAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
private async Task ScanPendingAsync(CancellationToken cancellationToken)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var scanner = scope.ServiceProvider.GetRequiredService<IDocumentScanner>();
var pending = await db.VendorCompletionDocuments
.Where(document => document.ScanStatus == "Pending")
.OrderBy(document => document.CreatedDate)
.Take(20)
.ToListAsync(cancellationToken);
foreach (var document in pending)
{
var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document);
var result = await scanner.ScanAsync(path, cancellationToken);
if (result == DocumentScanResult.Unavailable)
{
_logger.LogWarning("Vendor document scan service unavailable; document {DocumentId} remains quarantined", document.Id);
continue;
}
document.ScanStatus = result == DocumentScanResult.Passed ? "Passed" : "Rejected";
document.ReviewStatus = result == DocumentScanResult.Passed ? "Processing" : "Rejected";
document.RejectionReason = result == DocumentScanResult.Infected
? "The upload failed malware scanning."
: document.RejectionReason;
document.ScannedAt = DateTime.UtcNow;
document.LastModificationTime = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
if (result == DocumentScanResult.Infected && File.Exists(path))
File.Delete(path);
}
}
}
public static class VendorDocumentStorage
{
public static string Root(string contentRoot) =>
Path.Combine(contentRoot, "App_Data", "vendor-documents");
public static string ResolvePath(string contentRoot, int vendorId, int dispatchId, string storedFileName) =>
Path.Combine(Root(contentRoot), vendorId.ToString(), dispatchId.ToString(), storedFileName);
public static string ResolvePath(string contentRoot, VendorCompletionDocument document) =>
ResolvePath(contentRoot, document.VendorId, document.DispatchId, document.StoredFileName);
}
}

View file

@ -0,0 +1,92 @@
using System.Security.Cryptography;
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
namespace Api.SeaHavenIndustries.Helper
{
public class VendorPortalTokenService
{
private readonly ApplicationDbContext _db;
private readonly int _lifetimeDays;
public VendorPortalTokenService(ApplicationDbContext db, IConfiguration config)
{
_db = db;
_lifetimeDays = config.GetValue<int?>("VendorPortal:TokenLifetimeDays") ?? 365;
}
public async Task<VendorAccessToken> GetOrCreateActiveTokenAsync(int vendorId)
{
var now = DateTime.UtcNow;
var existing = await _db.VendorAccessTokens
.Where(t => t.VendorId == vendorId
&& t.RevokedAt == null
&& t.ExpiresAt > now
&& (t.IsDeleted == null || t.IsDeleted == false))
.OrderByDescending(t => t.IssuedAt)
.FirstOrDefaultAsync();
if (existing != null) return existing;
var token = new VendorAccessToken
{
VendorId = vendorId,
Token = GenerateToken(),
IssuedAt = now,
ExpiresAt = now.AddDays(_lifetimeDays),
CreatedDate = now
};
_db.VendorAccessTokens.Add(token);
await _db.SaveChangesAsync();
return token;
}
public async Task<Vendor?> ResolveVendorAsync(string? token)
{
if (string.IsNullOrWhiteSpace(token)) return null;
var now = DateTime.UtcNow;
var record = await _db.VendorAccessTokens
.Include(t => t.Vendor)
.FirstOrDefaultAsync(t => t.Token == token
&& t.RevokedAt == null
&& t.ExpiresAt > now
&& (t.IsDeleted == null || t.IsDeleted == false));
if (record?.Vendor == null) return null;
record.LastUsedAt = now;
await _db.SaveChangesAsync();
return record.Vendor;
}
public async Task<VendorAccessToken> RotateAsync(int vendorId)
{
var now = DateTime.UtcNow;
var active = await _db.VendorAccessTokens
.Where(t => t.VendorId == vendorId && t.RevokedAt == null)
.ToListAsync();
foreach (var t in active) t.RevokedAt = now;
await _db.SaveChangesAsync();
return await GetOrCreateActiveTokenAsync(vendorId);
}
public async Task RevokeAllAsync(int vendorId)
{
var now = DateTime.UtcNow;
var active = await _db.VendorAccessTokens
.Where(t => t.VendorId == vendorId && t.RevokedAt == null)
.ToListAsync();
foreach (var t in active) t.RevokedAt = now;
await _db.SaveChangesAsync();
}
private static string GenerateToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes)
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('=');
}
}
}

View file

@ -1,8 +1,6 @@
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Helper
{
public class ZipCodeDistance : IZipCodeDistance
public class ZipCodeDistance
{
private readonly Dictionary<string, (double lat, double lng)> _coords = new();
private bool _loaded;

View file

@ -1,90 +0,0 @@
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.Model;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Infrastructure
{
public class DynamoSyncExternalSource : ISyncExternalSource
{
private readonly AmazonDynamoDBClient _dynamo;
private const string WO_TABLE = "WorkOrders";
private const string COMMENTS_TABLE = "WorkOrderComments";
private const string VENDOR_REPLIES_TABLE = "VendorReplies";
public DynamoSyncExternalSource()
{
_dynamo = new AmazonDynamoDBClient(Amazon.RegionEndpoint.USEast1);
}
public async IAsyncEnumerable<Dictionary<string, string?>> ScanWorkOrdersAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
Dictionary<string, AttributeValue>? lastKey = null;
do
{
cancellationToken.ThrowIfCancellationRequested();
var request = new ScanRequest
{
TableName = WO_TABLE,
Limit = 100,
ExclusiveStartKey = lastKey
};
var response = await _dynamo.ScanAsync(request);
foreach (var item in response.Items)
yield return ConvertItem(item);
lastKey = response.LastEvaluatedKey;
} while (lastKey != null && lastKey.Count > 0);
}
public async IAsyncEnumerable<Dictionary<string, string?>> ScanCommentsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
Dictionary<string, AttributeValue>? lastKey = null;
do
{
cancellationToken.ThrowIfCancellationRequested();
var request = new ScanRequest
{
TableName = COMMENTS_TABLE,
Limit = 100,
ExclusiveStartKey = lastKey
};
var response = await _dynamo.ScanAsync(request);
foreach (var item in response.Items)
yield return ConvertItem(item);
lastKey = response.LastEvaluatedKey;
} while (lastKey != null && lastKey.Count > 0);
}
public async IAsyncEnumerable<Dictionary<string, string?>> ScanVendorRepliesAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
Dictionary<string, AttributeValue>? lastKey = null;
do
{
cancellationToken.ThrowIfCancellationRequested();
var request = new ScanRequest
{
TableName = VENDOR_REPLIES_TABLE,
Limit = 100,
ExclusiveStartKey = lastKey
};
var response = await _dynamo.ScanAsync(request);
foreach (var item in response.Items)
yield return ConvertItem(item);
lastKey = response.LastEvaluatedKey;
} while (lastKey != null && lastKey.Count > 0);
}
private static Dictionary<string, string?> ConvertItem(Dictionary<string, AttributeValue> item)
{
var result = new Dictionary<string, string?>();
foreach (var kvp in item)
{
if (kvp.Value.S != null)
result[kvp.Key] = kvp.Value.S;
}
return result;
}
}
}

View file

@ -1,108 +0,0 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Infrastructure
{
public class FileStorageAdapter : IFileStoragePort
{
private readonly IWebHostEnvironment _webHostEnvironment;
private readonly IHttpContextAccessor _httpContext;
public FileStorageAdapter(IWebHostEnvironment webHostEnvironment, IHttpContextAccessor httpContext)
{
_webHostEnvironment = webHostEnvironment;
_httpContext = httpContext;
}
public async Task<string> SaveFileAsync(IFormFile file)
{
var fileName = Path.GetFileName(file.FileName);
if (string.IsNullOrWhiteSpace(fileName))
throw new InvalidDataException("The uploaded file must have a valid name.");
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
var uploadPath = Path.Combine("Assets", "Documents");
var webRoot = _webHostEnvironment.WebRootPath
?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot");
var fullPath = Path.Combine(webRoot, uploadPath, uniqueFileName);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
using (var stream = System.IO.File.Create(fullPath))
{
await file.CopyToAsync(stream);
}
var request = _httpContext.HttpContext?.Request
?? throw new InvalidOperationException("An active HTTP request is required to build the file URL.");
var domain = $"{request.Scheme}://{request.Host}";
return $"{domain}/{uploadPath.Replace("\\", "/")}/{uniqueFileName}";
}
}
public class DispatchEmailAdapter : IDispatchEmailPort
{
private readonly Helper.SendMessage _sendMessage;
public DispatchEmailAdapter(Helper.SendMessage sendMessage)
{
_sendMessage = sendMessage;
}
public async Task<bool> SendDispatchEmailAsync(string emailTo, string subject, string htmlBody, string? replyTo)
{
return await _sendMessage.SendDispatchEmail(emailTo, subject, htmlBody, replyTo);
}
}
public class VendorTokenAdapter : IVendorTokenPort
{
private readonly IVendorPortalTokenService _tokenService;
public VendorTokenAdapter(IVendorPortalTokenService tokenService)
{
_tokenService = tokenService;
}
public async Task<string> GetOrCreateActiveTokenAsync(int vendorId)
{
var token = await _tokenService.GetOrCreateActiveTokenAsync(
vendorId, CancellationToken.None);
return token.Token;
}
}
public class VendorDocumentStorageAdapter : IVendorDocumentStoragePort
{
private readonly IWebHostEnvironment _environment;
public VendorDocumentStorageAdapter(IWebHostEnvironment environment)
{
_environment = environment;
}
public async Task SaveAsync(int vendorId, int dispatchId, string storedFileName, Stream content, CancellationToken cancellationToken)
{
var path = Helper.VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, vendorId, dispatchId, storedFileName);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await using var file = File.Create(path);
await content.CopyToAsync(file, cancellationToken);
}
public Stream OpenRead(int vendorId, int dispatchId, string storedFileName)
{
var path = Helper.VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, vendorId, dispatchId, storedFileName);
return File.OpenRead(path);
}
public void Delete(int vendorId, int dispatchId, string storedFileName)
{
var path = Helper.VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, vendorId, dispatchId, storedFileName);
if (File.Exists(path))
{
File.Delete(path);
}
}
}
}

View file

@ -13,7 +13,6 @@ using System.Text;
using SeaHaven.DataServices.DependencyInjection;
using SeaHaven.Services.DependencyInjection;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
var builder = WebApplication.CreateBuilder(args);
ConfigurationManager configuration = builder.Configuration;
@ -30,7 +29,6 @@ builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
builder.Services.AddControllers(options =>
{
options.Filters.Add<Api.SeaHavenIndustries.Filters.ConcurrencyExceptionFilter>();
options.Filters.Add<Api.SeaHavenIndustries.Filters.ArgumentExceptionFilter>();
}).AddJsonOptions(options =>
{
options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value
@ -44,11 +42,7 @@ builder.Services.AddCors(option =>
option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
builder.Services.AddScoped<SendMessage>();
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.SendGridEmailSender>();
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IEmailSender, Api.SeaHavenIndustries.Helper.SendGridEmailSender>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<IDocumentScanner, ClamAvDocumentScanner>();
builder.Services.AddHostedService<VendorDocumentScanWorker>();
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.VendorPortalTokenService>();
builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp =>
{
var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance();
@ -57,16 +51,9 @@ builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp
if (File.Exists(csvPath)) zipService.Load(csvPath);
return zipService;
});
builder.Services.AddSingleton<SeaHaven.Services.Interfaces.IZipCodeDistance>(
serviceProvider => serviceProvider.GetRequiredService<Api.SeaHavenIndustries.Helper.ZipCodeDistance>());
builder.Services.AddDataServices();
builder.Services.AddBusinessServices(configuration);
builder.Services.AddScoped<SeaHaven.Services.Interfaces.ISyncExternalSource, Api.SeaHavenIndustries.Infrastructure.DynamoSyncExternalSource>();
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IFileStoragePort, Api.SeaHavenIndustries.Infrastructure.FileStorageAdapter>();
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IDispatchEmailPort, Api.SeaHavenIndustries.Infrastructure.DispatchEmailAdapter>();
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IVendorTokenPort, Api.SeaHavenIndustries.Infrastructure.VendorTokenAdapter>();
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IVendorDocumentStoragePort, Api.SeaHavenIndustries.Infrastructure.VendorDocumentStorageAdapter>();
builder.Services.AddBusinessServices();
builder.Services.Configure<WorkOrderJobsOptions>(
builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
@ -84,8 +71,6 @@ builder.Services.Configure<SyncOptions>(
builder.Services.Configure<LegacyEndpointOptions>(
builder.Configuration.GetSection(LegacyEndpointOptions.SectionName));
builder.Services.AddSingleton<WorkOrderJobRunStateAccessor>();
builder.Services.AddSingleton<IWorkOrderJobRunStateAccessor>(
sp => sp.GetRequiredService<WorkOrderJobRunStateAccessor>());
builder.Services.AddSingleton(sp => sp.GetRequiredService<WorkOrderJobRunStateAccessor>().State);
builder.Services.AddScoped<Api.SeaHavenIndustries.Filters.IngestApiKeyFilter>();
builder.Services.AddHostedService<WorkOrderWeekRolledHostedService>();
@ -118,9 +103,7 @@ builder.Services.AddAuthentication(options =>
ValidateAudience = true,
ValidAudience = configuration["JWT:ValidAudience"],
ValidIssuer = configuration["JWT:ValidIssuer"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
configuration["JWT:Secret"]
?? throw new InvalidOperationException("JWT:Secret configuration is required")))
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JWT:Secret"]))
};
});
builder.Services.AddEndpointsApiExplorer();

View file

@ -14,12 +14,6 @@
// Provide the real value via environment variable SendGrid__ApiKey or the instance secret store.
"ApiKey": "${SENDGRID_API_KEY}"
},
// Production must supply a reachable ClamAV Host (environment variable ClamAV__Host).
// While Host is empty the scanner is unavailable and uploads stay quarantined (423 Locked).
"ClamAV": {
"Host": "",
"Port": 3310
},
"AllowedHosts": "*",
"JWT": {
"ValidAudience": "http://console.seahavenind.com",

View file

@ -17,13 +17,6 @@
// Provide the real value via environment variable SendGrid__ApiKey or user-secrets.
"ApiKey": "${SENDGRID_API_KEY}"
},
// Malware scanner for vendor completion document uploads. While Host is empty the
// scanner is considered unavailable and uploaded files stay quarantined (download
// returns 423 Locked until a scan passes). Production must supply a real Host.
"ClamAV": {
"Host": "",
"Port": 3310
},
"AllowedHosts": "*",
"JWT": {
"ValidAudience": "http://localhost:4200",
@ -48,3 +41,4 @@
},
"FrontendBaseUrl": "https://shoc.seahaven.com"
}

View file

@ -1,106 +1,564 @@
# Backend Architecture and Quality Rules
# Backend Architecture Guide
This repository uses a feature-oriented service boundary over Entity Framework Core:
## 🏗️ Clean Architecture Overview
```text
HTTP controller -> I{Feature}Service -> I{Feature}DataService -> ApplicationDbContext
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 │
└─────────────────────────────────────────────────────────────┘
```
The interfaces are architectural seams, not a requirement to wrap every class or every
EF method. Add an interface when it separates HTTP, business, persistence, or external
I/O responsibilities and enables behavior-focused testing.
---
## Layer responsibilities
## 📁 Project Structure
### API controllers
```
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
```
- Parse HTTP input, authorize the caller, invoke a business-service interface, and map
the result to the established HTTP contract.
- Do not inject `ApplicationDbContext`, concrete service implementations, or concrete
data-service implementations.
- Do not contain persistence queries or business workflows.
- Do not expose exception messages to clients. Map known failures explicitly and let the
centralized exception boundary handle unexpected failures.
---
### Business services
## 🔄 Data Flow Example (Creating a Contact)
- Own validation, business decisions, orchestration, and transaction intent.
- Depend on interfaces for persistence and external I/O.
- Use `IOptions<T>` for typed configuration. Do not inject `IConfiguration`.
- Keep feature responsibilities cohesive. Split a service when it has independently
changing business reasons, not because it crossed an arbitrary line count.
- Preserve existing API and board-backed behavior unless the accepted requirement
explicitly changes it.
### 1️⃣ **API Layer** (`ContactController.cs`)
```csharp
[HttpPost("Create")]
public async Task<IActionResult> 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" });
### Data services
// 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 });
}
}
```
- Own EF Core query shape, persistence, batching, and transaction implementation.
- Accept cancellation tokens on new I/O methods and pass them to EF Core.
- Use `AsNoTracking()` for read-only queries.
- Project only required columns for list/read models; include full graphs only when the
caller needs them.
- Page unbounded collections at the database.
- Avoid query-in-loop and save-in-loop patterns. Prefer set-based reads and batched writes
while preserving the workflow's failure and transaction semantics.
- Do not introduce a generic repository over EF Core. Feature data services should expose
intent-revealing operations.
**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
## Performance review
---
For each changed hot path, document and test:
### 2️⃣ **Service Layer** (`ContactService.cs`)
- input size variables;
- CPU and memory complexity;
- database round trips;
- whether filtering, ordering, and paging execute in SQL;
- whether tracking is necessary;
- whether repeated work can be hoisted out of loops;
- the partial-failure and transaction behavior.
```csharp
public class ContactService : IContactService
{
private readonly IContactDataService _dataService;
private readonly IValidator<CreateContactDTO> _createValidator;
public ContactService(
IContactDataService dataService,
IValidator<CreateContactDTO> createValidator)
{
_dataService = dataService;
_createValidator = createValidator;
}
public async Task<ContactDTO> 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
};
}
}
```
Prefer dictionary or hash-set reconstruction when database results must follow caller
order. A query followed by dictionary reconstruction is normally `O(n)` CPU and memory;
repeated `First`/`Single` scans over the result are `O(n²)`.
**Responsibilities:**
- ✅ Validate input using FluentValidation
- ✅ Apply business rules
- ✅ Orchestrate operations
- ✅ Transform data between layers
- ❌ NO HTTP concerns
- ❌ NO direct database access
Do not optimize by weakening correctness. In particular, batching all writes into one
final save can change partial-success behavior, and adding a transaction can change
locking and retry semantics. Those are business decisions, not mechanical cleanup.
---
## Configuration
### 3️⃣ **Data Service Layer** (`ContactDataService.cs`)
Configuration is bound once during composition in
`SeaHaven.Services/DependencyInjection/ServicesModule.cs`. Business services receive
typed options such as `FrontendOptions`, `JwtOptions`, `ApprovalsOptions`, and
`VendorPortalOptions`.
```csharp
public class ContactDataService : IContactDataService
{
private readonly ApplicationDbContext _db;
public ContactDataService(ApplicationDbContext db)
{
_db = db;
}
public async Task<ContactDataDTO> 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
};
Defaults must preserve current behavior. Startup validation should be introduced only
when every deployed environment is known to provide the required value.
// STEP 2: Add to database
_db.Contacts.Add(contact);
await _db.SaveChangesAsync();
## Enforcement
// 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
};
}
}
```
`ArchitectureTests` enforces mechanical dependency rules:
**Responsibilities:**
- ✅ Direct database operations (CRUD)
- ✅ Entity mapping
- ✅ Set audit fields (CreatedBy, CreatedDate, etc.)
- ✅ Transaction management
- ❌ NO validation
- ❌ NO business logic
- controllers cannot inject EF contexts or concrete service/data-service classes;
- business services cannot inject EF contexts or raw `IConfiguration`.
---
The pull-request quality workflow runs those tests and verifies formatting for changed
C# files. The normal CI workflow builds the solution and runs the full test suite.
## 🔌 Dependency Injection (DI)
Architecture tests are appropriate for dependency direction. Behavior belongs in tests
through public interfaces. Do not add tests merely to prove a linter or analyzer itself
works; test the product behavior the rule protects.
### Automatic Assembly Scanning
## Review checklist
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<CreateContactDTO>
{
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<ContactDTO> CreateContactAsync(CreateContactDTO dto, string userId);
Task<ContactDTO> UpdateContactAsync(int id, UpdateContactDTO dto, string userId);
Task DeleteContactAsync(int id, string userId);
Task<ContactDTO> GetContactByIdAsync(int id);
Task<PagedResult<ContactDTO>> GetContactsPagedAsync(int page, int pageSize, string? search);
}
```
### Data Service Interface (`IContactDataService.cs`)
```csharp
public interface IContactDataService
{
Task<ContactDataDTO> CreateAsync(CreateContactDataDTO dto, string userId);
Task<ContactDataDTO> UpdateAsync(int id, UpdateContactDataDTO dto, string userId);
Task DeleteAsync(int id, string userId);
Task<ContactDataDTO?> GetByIdAsync(int id);
Task<PagedResult<ContactDataDTO>> 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<IActionResult> 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**.
- Controller depends only on service abstractions.
- Business logic is in a cohesive feature service.
- EF operations are behind a feature data-service abstraction.
- No raw `IConfiguration` in a business service.
- No unbounded load followed by in-memory filtering.
- No query/save loop where a set-based operation preserves semantics.
- Read-only EF queries use no tracking.
- New async I/O accepts and propagates cancellation where the public contract permits.
- Errors do not leak internal exception details.
- Tests cover behavior, authorization, data contracts, ordering, duplicates, missing
records, and relevant failure boundaries.
- Changed behavior has been checked against the authoritative ticket board. Missing board
access blocks readiness and merge claims.

View file

@ -45,22 +45,10 @@ namespace Data.SeaHavenIndustries
builder.Entity<CompletionDocTemplate>()
.HasIndex(t => new { t.ServiceKey, t.IsActive });
builder.Entity<WorkOrder>()
.Property(w => w.RowVersion)
.IsRowVersion();
// Bounded lengths required for SQL Server index keys (nvarchar(max) is not indexable).
// SiteCode: board create contract MaximumLength(32). InternalWONumber: normalized to 11 digits.
builder.Entity<WorkOrder>()
.Property(w => w.SiteCode)
.HasMaxLength(32);
builder.Entity<WorkOrder>()
.Property(w => w.InternalWONumber)
.HasMaxLength(11);
builder.Entity<WorkOrder>()
.HasIndex(w => w.SiteCode)
.HasFilter("[istemplate] = 0");
@ -82,7 +70,6 @@ namespace Data.SeaHavenIndustries
.IsUnique()
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
builder.Entity<Dispatch>()
.Property(d => d.RowVersion)
.IsRowVersion();
@ -92,26 +79,6 @@ namespace Data.SeaHavenIndustries
.WithMany()
.HasForeignKey(w => w.PrimaryDispatchId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<VendorCompany>()
.HasIndex(c => c.NormalizedName)
.IsUnique();
builder.Entity<Vendor>()
.HasOne(v => v.Company)
.WithMany(c => c.Vendors)
.HasForeignKey(v => v.CompanyId)
.OnDelete(DeleteBehavior.Restrict);
builder.Entity<SitePreferredVendor>()
.HasIndex(p => new { p.LocationId, p.VendorId, p.Trade })
.IsUnique()
.HasFilter("[Trade] IS NOT NULL");
builder.Entity<SitePreferredVendor>()
.HasIndex(p => new { p.LocationId, p.VendorId })
.IsUnique()
.HasFilter("[Trade] IS NULL");
}
public DbSet<Category> Categories { get; set; }
public DbSet<Locations> Locations { get; set; }
@ -131,11 +98,7 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderWeekRolledLedger> WorkOrderWeekRolledLedgers { get; set; }
public DbSet<DropdownOption> DropdownOptions { get; set; }
public DbSet<Vendor> Vendors { get; set; }
public DbSet<VendorCompany> VendorCompanies { get; set; }
public DbSet<VendorAuditLog> VendorAuditLogs { get; set; }
public DbSet<VendorAccessToken> VendorAccessTokens { get; set; }
public DbSet<VendorCompletionDocument> VendorCompletionDocuments { get; set; }
public DbSet<SitePreferredVendor> SitePreferredVendors { get; set; }
public DbSet<Dispatch> Dispatches { get; set; }
public DbSet<DispatchWorkOrder> DispatchWorkOrders { get; set; }
public DbSet<DispatchChecklistItem> DispatchChecklistItems { get; set; }
@ -206,7 +169,7 @@ namespace Data.SeaHavenIndustries
return bytes;
}
}
public class ApplicationUser : IdentityUser
{
@ -222,8 +185,8 @@ namespace Data.SeaHavenIndustries
public string? Initials { get; set; }
public string? Color { get; set; }
public int? Type { get; set; } // 1 for users 0 for admin
public ICollection<Template>? Templates { get; set; }
public ICollection<WorkOrder>? WorkOrders { get; set; }
public ICollection<Template>? Templates { get; set; }
public ICollection<WorkOrder>? WorkOrders { get; set; }
}
public class FullAuditEntity
{

View file

@ -1,14 +0,0 @@
namespace Data.SeaHavenIndustries.Enums
{
/// <summary>
/// Shared terminal lifecycle statuses for EF-translatable filters and board rules.
/// </summary>
public static class LifecycleStatusSets
{
public static readonly LifecycleStatus?[] Terminal =
{
LifecycleStatus.Completed,
LifecycleStatus.Canceled
};
}
}

View file

@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
public class AddWorkOrderTypeAndScheduledDateIndex : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
IF COL_LENGTH('workOrders', 'WorkOrderType') IS NULL
ALTER TABLE workOrders ADD WorkOrderType varchar(50) NULL;
""");
migrationBuilder.Sql("""
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = 'IX_workOrders_ScheduledDate'
AND object_id = OBJECT_ID('workOrders'))
BEGIN
CREATE INDEX IX_workOrders_ScheduledDate ON workOrders (ScheduledDate);
END
""");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
IF EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = 'IX_workOrders_ScheduledDate'
AND object_id = OBJECT_ID('workOrders'))
DROP INDEX IX_workOrders_ScheduledDate ON workOrders;
""");
migrationBuilder.Sql("""
IF COL_LENGTH('workOrders', 'WorkOrderType') IS NOT NULL
ALTER TABLE workOrders DROP COLUMN WorkOrderType;
""");
}
}
}

View file

@ -1911,8 +1911,7 @@ namespace Data.SeaHavenIndustries.Migrations
.HasColumnType("nvarchar(max)");
b.Property<string>("InternalWONumber")
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
.HasColumnType("nvarchar(max)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
@ -1990,8 +1989,7 @@ namespace Data.SeaHavenIndustries.Migrations
.HasColumnType("nvarchar(max)");
b.Property<string>("SiteCode")
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
.HasColumnType("nvarchar(max)");
b.Property<string>("Source")
.HasColumnType("nvarchar(max)");

View file

@ -10,24 +10,6 @@ namespace Data.SeaHavenIndustries.Migrations
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "SiteCode",
table: "workOrders",
type: "nvarchar(32)",
maxLength: 32,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)");
migrationBuilder.AlterColumn<string>(
name: "InternalWONumber",
table: "workOrders",
type: "nvarchar(11)",
maxLength: 11,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)");
migrationBuilder.CreateIndex(
name: "IX_workOrders_SiteCode",
table: "workOrders",
@ -61,24 +43,6 @@ namespace Data.SeaHavenIndustries.Migrations
migrationBuilder.DropIndex(
name: "IX_workOrders_LifecycleStatus_ScheduledDate",
table: "workOrders");
migrationBuilder.AlterColumn<string>(
name: "SiteCode",
table: "workOrders",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(32)",
oldMaxLength: 32);
migrationBuilder.AlterColumn<string>(
name: "InternalWONumber",
table: "workOrders",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(11)",
oldMaxLength: 11);
}
}
}

View file

@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class Phase1_BoardIndexes : Migration
public class Phase1_BoardIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)

View file

@ -1911,8 +1911,7 @@ namespace Data.SeaHavenIndustries.Migrations
.HasColumnType("nvarchar(max)");
b.Property<string>("InternalWONumber")
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
.HasColumnType("nvarchar(max)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
@ -1990,8 +1989,7 @@ namespace Data.SeaHavenIndustries.Migrations
.HasColumnType("nvarchar(max)");
b.Property<string>("SiteCode")
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
.HasColumnType("nvarchar(max)");
b.Property<string>("Source")
.HasColumnType("nvarchar(max)");

View file

@ -1,41 +0,0 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260720220000_AddVendorDirectoryDetails")]
public partial class AddVendorDirectoryDetails : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CompanyPhone",
table: "Vendors",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "GoogleMapsUrl",
table: "Vendors",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Notes",
table: "Vendors",
type: "nvarchar(max)",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "CompanyPhone", table: "Vendors");
migrationBuilder.DropColumn(name: "GoogleMapsUrl", table: "Vendors");
migrationBuilder.DropColumn(name: "Notes", table: "Vendors");
}
}
}

View file

@ -1,185 +0,0 @@
using System;
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260723194013_AddVendorCompanyAndAudit")]
public partial class AddVendorCompanyAndAudit : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "CompanyId",
table: "Vendors",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PreferredContact",
table: "Vendors",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.CreateTable(
name: "VendorCompanies",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
NormalizedName = table.Column<string>(type: "nvarchar(450)", nullable: false),
CompanyPhone = table.Column<string>(type: "nvarchar(max)", nullable: true),
Email = table.Column<string>(type: "nvarchar(max)", nullable: true),
Address = table.Column<string>(type: "nvarchar(max)", nullable: true),
City = table.Column<string>(type: "nvarchar(max)", nullable: true),
State = table.Column<string>(type: "nvarchar(max)", nullable: true),
Zip = table.Column<string>(type: "nvarchar(max)", nullable: true),
GoogleMapsUrl = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_VendorCompanies", x => x.Id);
});
migrationBuilder.CreateTable(
name: "VendorAuditLogs",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
VendorId = table.Column<int>(type: "int", nullable: false),
WorkOrderId = table.Column<int>(type: "int", nullable: true),
FieldName = table.Column<string>(type: "nvarchar(max)", nullable: true),
OldValue = table.Column<string>(type: "nvarchar(max)", nullable: true),
NewValue = table.Column<string>(type: "nvarchar(max)", nullable: true),
Actor = table.Column<string>(type: "nvarchar(max)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VendorAuditLogs", x => x.Id);
table.ForeignKey(
name: "FK_VendorAuditLogs_Vendors_VendorId",
column: x => x.VendorId,
principalTable: "Vendors",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
// Backfill: create one VendorCompany per distinct (case-insensitive, trimmed) legacy name.
// Keep all company-owned fields from the same deterministic source vendor row.
migrationBuilder.Sql(@"
WITH RankedVendors AS (
SELECT
v.[CompanyName] AS [Name],
LOWER(LTRIM(RTRIM(v.[CompanyName]))) AS [NormalizedName],
v.[CompanyPhone],
v.[Email],
v.[Address],
v.[City],
v.[State],
v.[Zip],
v.[GoogleMapsUrl],
ROW_NUMBER() OVER (
PARTITION BY LOWER(LTRIM(RTRIM(v.[CompanyName])))
ORDER BY v.[Id]
) AS [RowNumber]
FROM [Vendors] v
WHERE v.[CompanyName] IS NOT NULL AND LTRIM(RTRIM(v.[CompanyName])) <> ''
)
INSERT INTO [VendorCompanies] (
[Name], [NormalizedName], [CompanyPhone], [Email], [Address], [City], [State], [Zip], [GoogleMapsUrl], [CreatedDate]
)
SELECT
ranked.[Name],
ranked.[NormalizedName],
ranked.[CompanyPhone],
ranked.[Email],
ranked.[Address],
ranked.[City],
ranked.[State],
ranked.[Zip],
ranked.[GoogleMapsUrl],
GETUTCDATE() AS [CreatedDate]
FROM RankedVendors ranked
WHERE ranked.[RowNumber] = 1;
UPDATE v
SET v.[CompanyId] = vc.[Id]
FROM [Vendors] v
INNER JOIN [VendorCompanies] vc
ON vc.[NormalizedName] = LOWER(LTRIM(RTRIM(v.[CompanyName])))
WHERE v.[CompanyName] IS NOT NULL AND LTRIM(RTRIM(v.[CompanyName])) <> '';
");
migrationBuilder.CreateIndex(
name: "IX_Vendors_CompanyId",
table: "Vendors",
column: "CompanyId");
migrationBuilder.CreateIndex(
name: "IX_VendorAuditLogs_VendorId",
table: "VendorAuditLogs",
column: "VendorId");
migrationBuilder.CreateIndex(
name: "IX_VendorCompanies_NormalizedName",
table: "VendorCompanies",
column: "NormalizedName",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_Vendors_VendorCompanies_CompanyId",
table: "Vendors",
column: "CompanyId",
principalTable: "VendorCompanies",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Vendors_VendorCompanies_CompanyId",
table: "Vendors");
migrationBuilder.DropIndex(
name: "IX_Vendors_CompanyId",
table: "Vendors");
migrationBuilder.DropIndex(
name: "IX_VendorAuditLogs_VendorId",
table: "VendorAuditLogs");
migrationBuilder.DropIndex(
name: "IX_VendorCompanies_NormalizedName",
table: "VendorCompanies");
migrationBuilder.DropTable(
name: "VendorAuditLogs");
migrationBuilder.DropTable(
name: "VendorCompanies");
migrationBuilder.DropColumn(
name: "CompanyId",
table: "Vendors");
migrationBuilder.DropColumn(
name: "PreferredContact",
table: "Vendors");
}
}
}

View file

@ -1,230 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class AddVendorRoadmapOperations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AvailabilityStatus",
table: "Vendors",
type: "nvarchar(max)",
nullable: false,
defaultValue: "Unknown");
migrationBuilder.AddColumn<DateTime>(
name: "AvailabilityUpdatedAt",
table: "Vendors",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CommercialStatusUpdatedAt",
table: "Dispatches",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "EstimatedArrivalAt",
table: "Dispatches",
type: "datetime2",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "EtaManualOverride",
table: "Dispatches",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "InvoiceNumber",
table: "Dispatches",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "InvoiceStatus",
table: "Dispatches",
type: "nvarchar(max)",
nullable: false,
defaultValue: "Not Submitted");
migrationBuilder.AddColumn<string>(
name: "PaymentStatus",
table: "Dispatches",
type: "nvarchar(max)",
nullable: false,
defaultValue: "Unavailable");
migrationBuilder.CreateTable(
name: "SitePreferredVendors",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
LocationId = table.Column<int>(type: "int", nullable: false),
VendorId = table.Column<int>(type: "int", nullable: false),
Trade = table.Column<string>(type: "nvarchar(450)", nullable: true),
SortOrder = table.Column<int>(type: "int", nullable: false),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SitePreferredVendors", x => x.Id);
table.ForeignKey(
name: "FK_SitePreferredVendors_Locations_LocationId",
column: x => x.LocationId,
principalTable: "Locations",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_SitePreferredVendors_Vendors_VendorId",
column: x => x.VendorId,
principalTable: "Vendors",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "VendorCompletionDocuments",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
VendorId = table.Column<int>(type: "int", nullable: false),
DispatchId = table.Column<int>(type: "int", nullable: false),
WorkOrderId = table.Column<int>(type: "int", nullable: false),
OriginalFileName = table.Column<string>(type: "nvarchar(max)", nullable: false),
StoredFileName = table.Column<string>(type: "nvarchar(max)", nullable: false),
ContentType = table.Column<string>(type: "nvarchar(max)", nullable: false),
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
ScanStatus = table.Column<string>(type: "nvarchar(max)", nullable: false),
ReviewStatus = table.Column<string>(type: "nvarchar(max)", nullable: false),
RejectionReason = table.Column<string>(type: "nvarchar(max)", nullable: true),
Version = table.Column<int>(type: "int", nullable: false),
ReplacesDocumentId = table.Column<int>(type: "int", nullable: true),
ScannedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
ReviewedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: true),
createdby = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeleterUserId = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletionTime = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifierUserId = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_VendorCompletionDocuments", x => x.Id);
table.ForeignKey(
name: "FK_VendorCompletionDocuments_Dispatches_DispatchId",
column: x => x.DispatchId,
principalTable: "Dispatches",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_VendorCompletionDocuments_Vendors_VendorId",
column: x => x.VendorId,
principalTable: "Vendors",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_VendorCompletionDocuments_workOrders_WorkOrderId",
column: x => x.WorkOrderId,
principalTable: "workOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_SitePreferredVendors_LocationId_VendorId",
table: "SitePreferredVendors",
columns: new[] { "LocationId", "VendorId" },
unique: true,
filter: "[Trade] IS NULL");
migrationBuilder.CreateIndex(
name: "IX_SitePreferredVendors_LocationId_VendorId_Trade",
table: "SitePreferredVendors",
columns: new[] { "LocationId", "VendorId", "Trade" },
unique: true,
filter: "[Trade] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_SitePreferredVendors_VendorId",
table: "SitePreferredVendors",
column: "VendorId");
migrationBuilder.CreateIndex(
name: "IX_VendorCompletionDocuments_DispatchId",
table: "VendorCompletionDocuments",
column: "DispatchId");
migrationBuilder.CreateIndex(
name: "IX_VendorCompletionDocuments_VendorId",
table: "VendorCompletionDocuments",
column: "VendorId");
migrationBuilder.CreateIndex(
name: "IX_VendorCompletionDocuments_WorkOrderId",
table: "VendorCompletionDocuments",
column: "WorkOrderId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SitePreferredVendors");
migrationBuilder.DropTable(
name: "VendorCompletionDocuments");
migrationBuilder.DropColumn(
name: "AvailabilityStatus",
table: "Vendors");
migrationBuilder.DropColumn(
name: "AvailabilityUpdatedAt",
table: "Vendors");
migrationBuilder.DropColumn(
name: "CommercialStatusUpdatedAt",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "EstimatedArrivalAt",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "EtaManualOverride",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "InvoiceNumber",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "InvoiceStatus",
table: "Dispatches");
migrationBuilder.DropColumn(
name: "PaymentStatus",
table: "Dispatches");
}
}
}

View file

@ -686,9 +686,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateTime?>("AcknowledgedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("CommercialStatusUpdatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("CompletedDate")
.HasColumnType("datetime2");
@ -713,19 +710,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<bool>("EmailSent")
.HasColumnType("bit");
b.Property<DateTime?>("EstimatedArrivalAt")
.HasColumnType("datetime2");
b.Property<bool>("EtaManualOverride")
.HasColumnType("bit");
b.Property<string>("InvoiceNumber")
.HasColumnType("nvarchar(max)");
b.Property<string>("InvoiceStatus")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
@ -741,10 +725,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("PONumber")
.HasColumnType("nvarchar(max)");
b.Property<string>("PaymentStatus")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ReplyToAddress")
.HasColumnType("nvarchar(max)");
@ -1230,86 +1210,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("EmployeePhones", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.Events", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("AllDay")
.HasColumnType("bit");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("EndDate")
.HasColumnType("datetime2");
b.Property<string>("EndTime")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("EventColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Location")
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<int?>("RecordID")
.HasColumnType("int");
b.Property<string>("RecordName")
.HasColumnType("nvarchar(max)");
b.Property<string>("RecordTargetType")
.HasColumnType("nvarchar(max)");
b.Property<int>("ReminderMinutes")
.HasColumnType("int");
b.Property<DateTime>("StartDate")
.HasColumnType("datetime2");
b.Property<string>("StartTime")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Events");
});
modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b =>
{
b.Property<int>("Id")
@ -1689,65 +1589,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("Regions", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<int>("LocationId")
.HasColumnType("int");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<string>("Trade")
.HasColumnType("nvarchar(450)");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("VendorId")
.HasColumnType("int");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("VendorId");
b.HasIndex("LocationId", "VendorId")
.IsUnique()
.HasFilter("[Trade] IS NULL");
b.HasIndex("LocationId", "VendorId", "Trade")
.IsUnique()
.HasFilter("[Trade] IS NOT NULL");
b.ToTable("SitePreferredVendors");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b =>
{
b.Property<int>("Id")
@ -1949,25 +1790,12 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Address")
.HasColumnType("nvarchar(max)");
b.Property<string>("AvailabilityStatus")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("AvailabilityUpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("City")
.HasColumnType("nvarchar(max)");
b.Property<int?>("CompanyId")
.HasColumnType("int");
b.Property<string>("CompanyName")
.HasColumnType("nvarchar(max)");
b.Property<string>("CompanyPhone")
.HasColumnType("nvarchar(max)");
b.Property<string>("ContactName")
.HasColumnType("nvarchar(max)");
@ -1983,9 +1811,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<string>("GoogleMapsUrl")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
@ -1998,15 +1823,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<string>("Phone")
.HasColumnType("nvarchar(max)");
b.Property<string>("PreferredContact")
.HasColumnType("nvarchar(max)");
b.Property<string>("State")
.HasColumnType("nvarchar(max)");
@ -2021,8 +1840,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.HasIndex("CompanyId");
b.ToTable("Vendors", (string)null);
});
@ -2085,195 +1902,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("VendorAccessTokens", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Actor")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("FieldName")
.HasColumnType("nvarchar(max)");
b.Property<string>("NewValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("OldValue")
.HasColumnType("nvarchar(max)");
b.Property<int>("VendorId")
.HasColumnType("int");
b.Property<int?>("WorkOrderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("VendorId");
b.ToTable("VendorAuditLogs");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Address")
.HasColumnType("nvarchar(max)");
b.Property<string>("City")
.HasColumnType("nvarchar(max)");
b.Property<string>("CompanyPhone")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<string>("GoogleMapsUrl")
.HasColumnType("nvarchar(max)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("NormalizedName")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("State")
.HasColumnType("nvarchar(max)");
b.Property<string>("Zip")
.HasColumnType("nvarchar(max)");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique();
b.ToTable("VendorCompanies");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("DeleterUserId")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<int>("DispatchId")
.HasColumnType("int");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModificationTime")
.HasColumnType("datetime2");
b.Property<int?>("LastModifierUserId")
.HasColumnType("int");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("RejectionReason")
.HasColumnType("nvarchar(max)");
b.Property<int?>("ReplacesDocumentId")
.HasColumnType("int");
b.Property<string>("ReviewStatus")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("ReviewedAt")
.HasColumnType("datetime2");
b.Property<string>("ScanStatus")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("ScannedAt")
.HasColumnType("datetime2");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<string>("StoredFileName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("VendorId")
.HasColumnType("int");
b.Property<int>("Version")
.HasColumnType("int");
b.Property<int>("WorkOrderId")
.HasColumnType("int");
b.Property<string>("createdby")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("DispatchId");
b.HasIndex("VendorId");
b.HasIndex("WorkOrderId");
b.ToTable("VendorCompletionDocuments");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>
{
b.Property<int>("Id")
@ -2352,8 +1980,7 @@ namespace Data.SeaHavenIndustries.Migrations
.HasColumnType("nvarchar(7)");
b.Property<string>("InternalWONumber")
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
.HasColumnType("nvarchar(max)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
@ -2447,8 +2074,7 @@ namespace Data.SeaHavenIndustries.Migrations
.HasColumnType("nvarchar(max)");
b.Property<string>("SiteCode")
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
.HasColumnType("nvarchar(max)");
b.Property<string>("Source")
.HasColumnType("nvarchar(max)");
@ -2771,35 +2397,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("WorkOrderWeekRolledLedgers", (string)null);
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("CorrelationId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("ProcessedAt")
.HasColumnType("datetime2");
b.Property<DateOnly>("SourceWeekStart")
.HasColumnType("date");
b.Property<int>("WorkOrderId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("WorkOrderId", "SourceWeekStart")
.IsUnique();
b.ToTable("WorkOrderWeekRolledLedgers");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
@ -3227,25 +2824,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Quotes");
});
modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b =>
{
b.HasOne("Data.SeaHavenIndustries.Locations", "Location")
.WithMany()
.HasForeignKey("LocationId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor")
.WithMany("SitePreferences")
.HasForeignKey("VendorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Location");
b.Navigation("Vendor");
});
modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b =>
{
b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template")
@ -3288,16 +2866,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("POC");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
{
b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company")
.WithMany("Vendors")
.HasForeignKey("CompanyId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Company");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b =>
{
b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor")
@ -3309,44 +2877,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Vendor");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b =>
{
b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor")
.WithMany("AuditLogs")
.HasForeignKey("VendorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Vendor");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b =>
{
b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch")
.WithMany("CompletionDocuments")
.HasForeignKey("DispatchId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor")
.WithMany()
.HasForeignKey("VendorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder")
.WithMany()
.HasForeignKey("WorkOrderId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Dispatch");
b.Navigation("Vendor");
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>
{
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser")
@ -3548,8 +3078,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Comments");
b.Navigation("CompletionDocuments");
b.Navigation("DispatchWorkOrders");
b.Navigation("Signoffs");
@ -3593,16 +3121,7 @@ namespace Data.SeaHavenIndustries.Migrations
modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b =>
{
b.Navigation("AuditLogs");
b.Navigation("Dispatches");
b.Navigation("SitePreferences");
});
modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b =>
{
b.Navigation("Vendors");
});
modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b =>

View file

@ -19,12 +19,6 @@ namespace Data.SeaHavenIndustries
public string? Description { get; set; }
public string? Status { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateTime? EstimatedArrivalAt { get; set; }
public bool EtaManualOverride { get; set; }
public string InvoiceStatus { get; set; } = "Not Submitted";
public string PaymentStatus { get; set; } = "Unavailable";
public string? InvoiceNumber { get; set; }
public DateTime? CommercialStatusUpdatedAt { get; set; }
public DateTime? CompletedDate { get; set; }
public DateTime? DispatchedAt { get; set; }
public DateTime? AcknowledgedAt { get; set; }
@ -48,6 +42,5 @@ namespace Data.SeaHavenIndustries
public ICollection<DispatchWorkOrder>? DispatchWorkOrders { get; set; }
public ICollection<DispatchChecklistItem>? ChecklistItems { get; set; }
public ICollection<DispatchSignoff>? Signoffs { get; set; }
public ICollection<VendorCompletionDocument>? CompletionDocuments { get; set; }
}
}

View file

@ -1,15 +0,0 @@
using Data.SeaHavenIndustries.Enums;
namespace Data.SeaHavenIndustries
{
public class LinkedWorkOrderInfo
{
public int WorkOrderId { get; set; }
public string? WorkOrderNumber { get; set; }
public string? WorkOrderTitle { get; set; }
public string? Status { get; set; }
public LifecycleStatus? LifecycleStatus { get; set; }
public DateTime? ScheduledDate { get; set; }
public int? DispatchId { get; set; }
}
}

View file

@ -1,19 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
public class SitePreferredVendor : FullAuditEntity
{
public int LocationId { get; set; }
[ForeignKey(nameof(LocationId))]
public Locations? Location { get; set; }
public int VendorId { get; set; }
[ForeignKey(nameof(VendorId))]
public Vendor? Vendor { get; set; }
public string? Trade { get; set; }
public int SortOrder { get; set; }
public string? UpdatedBy { get; set; }
}
}

View file

@ -1,5 +1,3 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
public class Vendor : FullAuditEntity
@ -8,27 +6,12 @@ namespace Data.SeaHavenIndustries
public string? ContactName { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? CompanyPhone { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? TradeSpecialties { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public bool IsActive { get; set; } = true;
public string? PreferredContact { get; set; }
public string AvailabilityStatus { get; set; } = "Unknown";
public DateTime? AvailabilityUpdatedAt { get; set; }
public int? CompanyId { get; set; }
[ForeignKey(nameof(CompanyId))]
public virtual VendorCompany? Company { get; set; }
[NotMapped]
public int TotalJobs { get; set; }
public ICollection<Dispatch>? Dispatches { get; set; }
public ICollection<VendorAuditLog>? AuditLogs { get; set; }
public ICollection<SitePreferredVendor>? SitePreferences { get; set; }
}
}

View file

@ -1,18 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
public class VendorAuditLog
{
public int Id { get; set; }
public int VendorId { get; set; }
[ForeignKey(nameof(VendorId))]
public virtual Vendor? Vendor { get; set; }
public int? WorkOrderId { get; set; }
public string? FieldName { get; set; }
public string? OldValue { get; set; }
public string? NewValue { get; set; }
public string? Actor { get; set; }
public DateTime CreatedAt { get; set; }
}
}

View file

@ -1,19 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
public class VendorCompany : FullAuditEntity
{
public string Name { get; set; } = string.Empty;
public string NormalizedName { get; set; } = string.Empty;
public string? CompanyPhone { get; set; }
public string? Email { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public ICollection<Vendor>? Vendors { get; set; }
}
}

View file

@ -1,31 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
public class VendorCompletionDocument : FullAuditEntity
{
public int VendorId { get; set; }
[ForeignKey(nameof(VendorId))]
public Vendor? Vendor { get; set; }
public int DispatchId { get; set; }
[ForeignKey(nameof(DispatchId))]
public Dispatch? Dispatch { get; set; }
public int WorkOrderId { get; set; }
[ForeignKey(nameof(WorkOrderId))]
public WorkOrder? WorkOrder { get; set; }
public string OriginalFileName { get; set; } = "";
public string StoredFileName { get; set; } = "";
public string ContentType { get; set; } = "";
public long SizeBytes { get; set; }
public string ScanStatus { get; set; } = "Pending";
public string ReviewStatus { get; set; } = "Processing";
public string? RejectionReason { get; set; }
public int Version { get; set; } = 1;
public int? ReplacesDocumentId { get; set; }
public DateTime? ScannedAt { get; set; }
public DateTime? ReviewedAt { get; set; }
}
}

View file

@ -1,101 +0,0 @@
namespace Data.SeaHavenIndustries
{
public class VendorDispatchSummaryData
{
public int Id { get; set; }
public string? DispatchNumber { get; set; }
public string? PONumber { get; set; }
public string? Status { get; set; }
public decimal? NTEAmount { get; set; }
public DateTime? ScheduledDate { get; set; }
public DateTime? CompletedDate { get; set; }
public DateTime? DispatchedAt { get; set; }
public DateTime? AcknowledgedAt { get; set; }
public string? WorkOrderTitle { get; set; }
public string? InternalWONumber { get; set; }
public string? LocationName { get; set; }
public string? LocationCity { get; set; }
public string? LocationState { get; set; }
}
public class PortalChecklistItemData
{
public int Id { get; set; }
public string? ItemText { get; set; }
public bool IsCompleted { get; set; }
public string? CompletedBy { get; set; }
public DateTime? CompletedAt { get; set; }
}
public class PortalSignoffData
{
public int Id { get; set; }
public string? SignoffType { get; set; }
public string? Name { get; set; }
public string? SignatureMethod { get; set; }
public DateTime? SignedAt { get; set; }
}
public class PortalUpliftData
{
public int Id { get; set; }
public decimal? CurrentNTE { get; set; }
public decimal RequestedNTE { get; set; }
public string? VendorReason { get; set; }
public string? Status { get; set; }
public int RequiredTier { get; set; }
public string? RequestedByVendorName { get; set; }
public DateTime? CreatedDate { get; set; }
public DateTime? DecidedAt { get; set; }
public string? DecisionNote { get; set; }
public string? DecidedByFirstName { get; set; }
public string? DecidedByLastName { get; set; }
}
public class PortalCommentData
{
public int Id { get; set; }
public string? Commenttext { get; set; }
public string? Commenter { get; set; }
public string? CommentType { get; set; }
public DateTime? CreatedDate { get; set; }
public string? UserId { get; set; }
public string? UserFirstName { get; set; }
public string? UserLastName { get; set; }
}
public class UpliftListItemData
{
public int Id { get; set; }
public int DispatchId { get; set; }
public string? DispatchNumber { get; set; }
public string? PONumber { get; set; }
public int? WorkOrderId { get; set; }
public string? VendorCompanyName { get; set; }
public decimal? CurrentNTE { get; set; }
public decimal RequestedNTE { get; set; }
public string? VendorReason { get; set; }
public int RequiredTier { get; set; }
public string? Status { get; set; }
public DateTime? CreatedDate { get; set; }
public DateTime? DecidedAt { get; set; }
public string? DecisionNote { get; set; }
}
public class UpliftForDispatchData
{
public int Id { get; set; }
public int DispatchId { get; set; }
public decimal? CurrentNTE { get; set; }
public decimal RequestedNTE { get; set; }
public string? VendorReason { get; set; }
public string? Status { get; set; }
public int RequiredTier { get; set; }
public string? RequestedByVendorName { get; set; }
public DateTime? CreatedDate { get; set; }
public DateTime? DecidedAt { get; set; }
public string? DecisionNote { get; set; }
public string? DecidedByFirstName { get; set; }
public string? DecidedByLastName { get; set; }
}
}

Some files were not shown because too many files have changed in this diff Show more