Compare commits

...

48 commits

Author SHA1 Message Date
Alexandre Brandizzi
d6175f84f3 merge dev and expose author-only comment edits 2026-07-24 18:24:20 -03:00
Arthur Bassi
620a36af54
feat(work-orders): enrich board search overdue filters and 0-based paging (#23)
* feat(work-orders): enrich board search overdue filters and 0-based paging

* fix(work-orders): align stacked services with CI build

* fix(tests): pass userDataService in comment service unit test

* fix(work-orders): use dedicated overdue query flag

Stop treating WorkOrderType.Other as an overdue sentinel. Board and advanced search now accept overdue=true while types=Other filters real Other rows; combining both uses OR.

* test(work-orders): cover overdue date/status boundary and Other type-filter

Lock the PR #23 overdue regression boundary through the public advanced
search service. Prove overdue filtering is driven by past-due date plus
non-terminal status, not by the WorkOrderType.Other sentinel:
- Other + future/not-completed excluded from overdue
- past-due + Scheduled included; past-due + Completed/Canceled excluded
- types=[PM, Other] keeps real Other rows and does not pull past-due rows
- assert 0-based paging (Page=0) is preserved alongside overdue/type filters

---------

Co-authored-by: Arthur Bassi <arthur.winiarski.ranger@outlook.com>
Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
2026-07-24 21:12:03 +00:00
Alexandre Brandizzi
833fb816ee
chore(governance): enforce backend architecture and quality gates (#31)
* docs(governance): add canonical governance docs, unified quality-gate script, and CI parity

- ARCHITECTURE_AND_CODE_QUALITY.md: canonical layering, EF allowlist,
  transaction/commit convention, cancellation, migrations, error disclosure,
  Big-O/perf, ADR exceptions (supersedes BACKEND_ARCHITECTURE.md)
- QUALITY_GATES.md: gate inventory + pass/fail/skip semantics; local==CI
- REVIEW_AND_PR_FRAMEWORK.md: exact-head review, board-backed regression
  inventory, security/perf evidence, ADR exceptions, no godfile theater
- AGENTS.md: repo-specific delta + precedence pointers
- scripts/governance-check.sh: unified G1 restore + G2 ArchitectureTests +
  G3 changed-file format (portable bash)
- .github/workflows/architecture-quality.yml: call the same local script
- ArchitectureTests.cs: add business-service interface-dependency invariant

* fix(governance): make backend gate complete

* fix(ci): enforce governance on every pull request
2026-07-24 17:41:10 -03:00
Alexandre Brandizzi
7d245eb717
refactor: enforce backend boundaries and optimize dispatch (#30)
* refactor(api): enforce service and data-service boundaries

* refactor(api): complete feature service boundaries

* refactor(identity): enforce service and data boundaries

* refactor(vendors): enforce service and data boundaries

* refactor(workorders): enforce service and data boundaries

* refactor(backend): enforce architecture and optimize dispatch

* style(backend): format changed architecture files

* fix(architecture): address backend review follow-ups

* fix(backend): sanitize exception disclosure in changed API endpoints

Replace raw exception-message disclosure (ex.Message) returned to API
callers with a stable sanitized public message plus correlated structured
internal logging, across the endpoints changed in this PR.

- Add SanitizedErrors helper: logs the original exception at Error with a
  generated correlation id and returns a stable public message referencing
  it so support can trace without exposing internals.
- Inject ILogger<T> into the 14 changed controllers and route every
  ex.Message/dbex.Message disclosure through the helper, preserving status
  codes, response shapes, and business data (e.g. OpenWorkOrders).
- Leave FluentValidation (vex.Errors) and existing fixed-message catches
  untouched; out-of-scope controllers (Account/Contact/Employee/Asset/
  PMSchedule) are unchanged.
- Add focused tests proving internal exception text is not returned and
  that Error logging carrying the original exception is invoked.

* fix(architecture): abstract job run state access

* style: format board update service

* test: use collection assertion idiom
2026-07-24 17:35:34 -03:00
Alexandre Brandizzi
8be07a9c5f
Merge pull request #22 from Sea-Haven-Industries/feature/wo-flag-color
feat(work-orders): add board flagColor with fixed palette
2026-07-24 14:32:41 -03:00
Alexandre Brandizzi
78abf84b0d fix(work-orders): finalize PR 22 integration compile fixes 2026-07-24 14:27:52 -03:00
Alexandre Brandizzi
e5cf4cec09 merge: integrate origin/dev into PR #22 flag-color base
Brings in dev's Phase 5 (PR #17) + vendor PRs (#25/#28/#29) atop the
Phase 6/7 + flagColor base (PR #22). Preserves dev Phase 1-5 behavior and
PR #22 Phase 6/7 + flagColor behavior.

Conflict resolutions (16 files):
- Migrations Phase4_SearchIndexes/.Designer + Phase5_DomainEvents/.Designer:
  take dev (Phase4 incl. SQL Server SiteCode/InternalWONumber index-compat
  shrink fix; Phase5 identical). ModelSnapshot union: Vendor CompanyId index
  + Phase7 ServiceNotes/ExternalWorkOrderId index.
- ApplicationDbContext: keep dev SiteCode/InternalWONumber MaxLength (Phase1-5
  + unguarded model test) + HEAD CompletionDocTemplate/ExternalWorkOrderId.
- WorkOrderAuditService: unify on dev async staging API; convert Phase6
  CompletionService 2 call sites to await StageFieldChangedAsync (drops
  HEAD sync duplicate; only callers, no test refs).
- Hosted services: take HEAD (retry-on-failure, coherent with Phase7
  WorkOrderJobRunStateAccessor/OpsHealth). Program.cs keeps dev vendor DI
  (ClamAV/VendorDocumentScanWorker/ArgumentExceptionFilter) + HEAD Phase7.
- WorkOrderController: keep HEAD Phase6/7 service params + dev doc comment.
- VendorController/WorkOrderBoardCreateService/QueryFilters/appsettings:
  union / dev-correct.
- WorkOrderBoardUpdateServiceTests: union of HEAD (Phase6/7+flagColor) and
  dev (Phase1-5) test methods.

Verified WorkOrderType.Other (enum 99) is a legit category, not an overdue
sentinel; overdue uses dedicated OperationalFlags.PastDue + IsPastDue, and
'Overdue' is rejected as a WorkOrderType (no PR #23 import needed).

Removed dev duplicate Api.Options.WorkOrderJobRunState (HEAD defines it in
Services.Implementation alongside the Accessor; Services cannot reference Api).
2026-07-24 14:20:16 -03:00
Alexandre Brandizzi
0e87817416
Merge pull request #17 from Sea-Haven-Industries/feat/work-orders-phase-5
Feat/work orders Phase 5
2026-07-24 13:55:09 -03:00
Alexandre Brandizzi
5fa9b4e344 merge: integrate origin/dev into work-orders-phase-5
Brings in merged PR #16 and vendor/Phase 1-4 work from origin/dev into the
Phase 5 (WeekRolled) head. Only conflict was ApplicationDbContextModelSnapshot.cs
(EF model snapshot touched by both sides); resolved by taking dev's latest
snapshot (matches migration 20260723220614) and grafting the
WorkOrderWeekRolledLedger entity + relationship blocks, matching the Phase5
migration Designer exactly. Migration ordering unchanged: Phase5 (20260709)
runs before vendor migrations (20260720-20260723).
2026-07-24 13:51:41 -03:00
Alexandre Brandizzi
917bad3ee1
Merge pull request #16 from Sea-Haven-Industries/feat/work-orders-phase-4
Feat/work orders phase 4
2026-07-24 13:41:14 -03:00
Alexandre Brandizzi
a9dee0bb84 merge: integrate origin/dev into work-orders-phase-4
Merge origin/dev (vendor roadmap PRs #28/#29, phases 1-3 PRs #13-15)
into work-orders-phase-4. Conflict in WorkOrderController.cs resolved by
keeping both the Phase 4 board/search endpoint and the dev-side XML doc
comment on GetDispatcherLookups (both additions at the same site).

Migration timestamps remain monotonic and non-overlapping. Phase 4 global
advanced search, search index migrations, and the cross-platform LocalDB
test guard are preserved alongside the merged vendor roadmap and phases 1-3.

Validation (Docker .NET 8 SDK): build 0 errors; 191 tests pass across
all three test projects (64 + 18 + 109).
2026-07-24 13:38:20 -03:00
Alexandre Brandizzi
8192da7790
Merge pull request #15 from Sea-Haven-Industries/feat/work-orders-phase-3
Feat/work orders Phase 3 board create and soft cancel
2026-07-24 13:34:39 -03:00
Alexandre Brandizzi
df828cf48c
Merge pull request #14 from Sea-Haven-Industries/feat/work-orders-phase-2
Feat/work orders phase 2
2026-07-24 13:32:25 -03:00
Alexandre Brandizzi
3bd0268129
Merge pull request #13 from Sea-Haven-Industries/feat/work-orders-phase-1-board
Feat/work orders phase 1 board
2026-07-24 13:29:09 -03:00
Alexandre Brandizzi
782d0e8ae9
Merge pull request #29 from Sea-Haven-Industries/codex/vendor-roadmap-completion-backend
Complete vendor operations roadmap backend
2026-07-24 13:24:22 -03:00
Alexandre Brandizzi
1ede0a5ece test: sync cross-platform LocalDB guard 2026-07-24 13:12:38 -03:00
Alexandre Brandizzi
af724ff625 fix(test): guard LocalDB migration check by platform 2026-07-24 13:10:50 -03:00
Alexandre Brandizzi
b1421de0b5 fix(vendors): address roadmap review findings 2026-07-24 11:49:57 -03:00
Arthur Bassi
8134c0b77f fix(work-orders): add Phase5 DomainEvents Designer metadata
Ensure EF discovers the WeekRolled ledger migration and keeps Phase 4 column bounds in the designer snapshot.
2026-07-24 10:14:47 -03:00
Arthur Bassi
93ef4f578b fix(work-orders): process all WeekRolled candidates across batches
Offset paging skipped remaining WOs after ledger writes; always refetch the first page of unprocessed candidates and cover with a >BatchSize test.
2026-07-24 10:13:17 -03:00
Arthur Bassi
7459dca3c6 feat(work-orders): add Phase 5 scheduled domain events (WeekRolled)
Introduce in-process WeekRolled job with idempotent ledger to increment carriedOver for the SHOC board, plus optional PastDue cache and admin reprocess endpoints while keeping isPastDue derived on-read.
2026-07-24 10:13:17 -03:00
Arthur Bassi
bdd566de83 fix(work-orders): bound SiteCode/InternalWONumber so Phase4 indexes apply on SQL Server 2026-07-24 10:02:57 -03:00
Arthur Bassi
31675e49dd fix(work-orders): make Phase4 search index migration discoverable by EF 2026-07-24 10:01:11 -03:00
Arthur Bassi
edcdc10395 fix(work-orders): return 400 for invalid advanced-search custom date range
Map ArgumentException from datePreset=custom without dates to BadRequest so the endpoint matches the Phase 4 contract.
2026-07-24 10:01:11 -03:00
Arthur Bassi
54cd24acf4 fix(migrations): shrink SiteCode and InternalWONumber before Phase4 search indexes
SQL Server cannot index nvarchar(max). Alter columns to nvarchar(450)
before creating Phase 4 search indexes.
2026-07-24 09:59:24 -03:00
Arthur Bassi
11fed06f03 feat(work-orders): add Phase 4 board search and advanced search API
Harden contextual search on the weekly board and expose paginated cross-week GET /board/search with date presets, FE filter params, SQL indexes, and unit tests.
2026-07-24 09:58:39 -03:00
Alexandre Brandizzi
4b4ab04615
Merge pull request #28 from Sea-Haven-Industries/codex/vendor-core-completion-backend
Complete core vendor workflows
2026-07-23 19:46:01 -03:00
Alexandre Brandizzi
39829f7274 Merge branch 'codex/vendor-core-completion-backend' into codex/vendor-roadmap-completion-backend 2026-07-23 19:18:19 -03:00
Alexandre Brandizzi
4863d1fdaf feat(vendors): complete operations roadmap backend 2026-07-23 19:18:06 -03:00
Alexandre Brandizzi
5b4b538c16 fix(vendors): preserve company and phone integrity 2026-07-23 19:17:14 -03:00
Alexandre Brandizzi
8cf49afc2c feat(vendors): complete core vendor workflows 2026-07-23 17:02:40 -03:00
Arthur Bassi
385812c5c3 merge: sync phase 3 with updated phase 2 base
Reconcile migration/docs deletions from phase 2, adopt async audit staging and ApptTime lock contracts, and keep phase 3 create/cancel transactional behavior.
2026-07-23 13:28:54 -03:00
Alexandre Brandizzi
1162c68596
feat(vendors): add directory filters and details API (#25)
* feat(vendors): add directory filters and details API

* fix(vendors): preserve omitted status

* fix(vendors): align facet filtering

* fix(vendors): address directory review findings
2026-07-23 15:55:47 +00:00
Alexandre Brandizzi
1ff29c234e
Merge branch 'dev' into feat/work-orders-phase-1-board 2026-07-21 14:01:48 -03:00
Alexandre Brandizzi
32b9113057
Merge pull request #10 from Sea-Haven-Industries/feat/work-orders-domain-foundation
feat(work-orders): Phase 0 domain foundation with FE alignment
2026-07-21 13:58:18 -03:00
Arthur Bassi
75969a1e3d docs(work-orders): clarify board filter precedence and dispatcher lookup
Document myWorkOrders overriding dispatchers, weekend dayGroup null contract, and that dispatcher lookups currently return all users. Map service ArgumentException to 400 for consistency.
2026-07-21 09:33:24 -03:00
Arthur Bassi
61b4c84337 fix(work-orders): lock ScheduledStart/End when patching apptTime
Align board apptTime field locks with SyncFieldMergePolicy field names so sync cannot overwrite a manual appointment edit. Also add shared LifecycleStatusSets so the Phase 2 branch compiles.
2026-07-21 09:31:27 -03:00
Arthur Bassi
f3d2a5cfe7 fix(work-orders): address PR13 phase-1 review feedback 2026-07-17 14:51:56 -03:00
Arthur Bassi
76db2b5a37 fix(work-orders): address PR14 phase-2 review feedback 2026-07-17 14:48:48 -03:00
Arthur Bassi
4bd11dcf6b fix(work-orders): address stacked PR review feedback 2026-07-17 14:31:08 -03:00
Arthur Bassi
ceeb0c8fa3 fix(work-orders): wrap board create in a transaction for atomic persistence 2026-07-10 11:38:58 -03:00
Arthur Bassi
3122cbc8f7 fix(migrations): remove undiscoverable WorkOrderType backfill migration
Superseded by Phase0 (WorkOrderType as int) and Phase1 board indexes; keeping the bare Migration would either be skipped by EF or conflict if metadata were added.
2026-07-10 11:28:55 -03:00
Arthur Bassi
bdfaf1ff61 fix(migrations): add Phase1 BoardIndexes Designer metadata for EF discovery 2026-07-10 11:27:43 -03:00
Arthur Bassi
cc8825ace0 fix(migrations): add Phase1 BoardIndexes Designer metadata for EF discovery 2026-07-10 11:20:14 -03:00
Arthur Bassi
88f9245fbf merge: sync Phase 1 board review fixes into Phase 2
Resolve conflicts in WorkOrderController and WorkOrderBoardService so Phase 2 merges cleanly into the Phase 1 base.
2026-07-10 10:41:36 -03:00
Arthur Bassi
25eb6293e0 chore: remove docs and scripts from phase 2 PR scope 2026-07-09 13:24:47 -03:00
Arthur Bassi
c2d0bd45e3 fix(work-orders): address PR #13 review feedback
Return BadRequest for invalid board week window, remove duplicate unused board types, fix POC null guard, and document appt precedence and UTC Phase 1 assumptions.
2026-07-09 10:06:36 -03:00
Arthur Bassi
88caede86a fix(work-orders): address PR #14 board PATCH review feedback
Reuse field-lock DB checks during staged audits, defer dispatch creation to the final save, and return 404 for missing work orders.
2026-07-09 09:58:41 -03:00
306 changed files with 28623 additions and 9611 deletions

View file

@ -0,0 +1,29 @@
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

70
AGENTS.md Normal file
View file

@ -0,0 +1,70 @@
# 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

@ -0,0 +1,273 @@
# 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

@ -0,0 +1,97 @@
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

@ -0,0 +1,261 @@
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

@ -0,0 +1,171 @@
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,34 +3,17 @@ using Api.SeaHavenIndustries.Controllers;
using Api.SeaHavenIndustries.DTOs;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
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,
@ -43,164 +26,181 @@ public class CalendarControllerTests
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 CalendarController NewController(Mock<ICalendarService> service) =>
new(service.Object, Mock.Of<ILogger<CalendarController>>());
private static object Prop(object source, string name) =>
source.GetType().GetProperty(name)!.GetValue(source)!;
[Fact]
public async Task AddEvent_WhenValid_PersistsEventAndReturnsOk()
public async Task AddEvent_WhenServiceSucceeds_ReturnsOkWithCreatedMessage()
{
using var ctx = NewContext();
var result = await NewController(ctx).AddEvent(ValidDto());
var service = new Mock<ICalendarService>();
var result = await NewController(service).AddEvent(ValidDto(), CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
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();
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);
}
[Fact]
public async Task EditEvent_WhenIdMissing_ReturnsBadRequest()
public async Task AddEvent_WhenServiceThrows_ReturnsSanitizedMessageAndLogsInternally()
{
using var ctx = NewContext();
var result = await NewController(ctx).EditEvent(ValidDto(id: null));
var service = new Mock<ICalendarService>();
service.Setup(s => s.CreateEventAsync(It.IsAny<EditEventInput>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("SECRET-internal-db-connection-leak"));
result.Should().BeOfType<BadRequestObjectResult>();
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);
}
[Fact]
public async Task EditEvent_WhenEventNotFound_ReturnsNotFound()
public async Task EditEvent_WhenIdMissing_ReturnsBadRequestWithoutCallingService()
{
using var ctx = NewContext();
var result = await NewController(ctx).EditEvent(ValidDto(id: 999));
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);
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task EditEvent_WhenEventExists_UpdatesFieldsAndStampsModified()
public async Task GetEventById_WhenServiceReturnsDto_ReturnsOkWithEventDto()
{
using var ctx = NewContext();
var existing = SeedEvent(ctx, "Old", new DateTime(2026, 6, 1));
var service = new Mock<ICalendarService>();
service.Setup(s => s.GetEventByIdAsync(7, It.IsAny<CancellationToken>()))
.ReturnsAsync(new EventDTO { Id = 7, Title = "Review", createdby = "abc" });
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 result = await NewController(service).GetEventById(7, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var dto = ok.Value.Should().BeOfType<DataResponse>().Subject.Data as Event_DTO;
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
var dto = response.Data as Event_DTO;
dto.Should().NotBeNull();
dto!.Title.Should().Be("Review");
dto!.createdby.Should().Be("abc");
}
[Fact]
public async Task GetEventById_WhenSoftDeleted_ReturnsNotFound()
public async Task DeleteEvent_WhenServiceReturnsFalse_ReturnsNotFound()
{
using var ctx = NewContext();
var deleted = SeedEvent(ctx, "Gone", new DateTime(2026, 6, 1), isDeleted: true);
var service = new Mock<ICalendarService>();
service.Setup(s => s.DeleteEventAsync(9, It.IsAny<CancellationToken>())).ReturnsAsync(false);
var result = await NewController(ctx).GetEventById(deleted.Id);
var result = await NewController(service).DeleteEvent(9, CancellationToken.None);
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task DeleteEvent_WhenExists_SoftDeletesAndKeepsRow()
public async Task DeleteEvent_WhenServiceReturnsTrue_ReturnsOkWithDeletedMessage()
{
using var ctx = NewContext();
var existing = SeedEvent(ctx, "Cancel", new DateTime(2026, 6, 1));
var service = new Mock<ICalendarService>();
service.Setup(s => s.DeleteEventAsync(9, It.IsAny<CancellationToken>())).ReturnsAsync(true);
var result = await NewController(ctx).DeleteEvent(existing.Id);
var result = await NewController(service).DeleteEvent(9, CancellationToken.None);
result.Should().BeOfType<OkObjectResult>();
var row = ctx.Events.Single();
row.IsDeleted.Should().Be(true);
row.DeletionTime.Should().NotBeNull();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeOfType<DataResponse>().Which.Message.Should().Be("Event Deleted Successfully");
}
[Fact]
public async Task DeleteEvent_WhenNotFound_ReturnsNotFound()
public async Task GetEventList_ReturnsOkWithRowsAndTotalCount()
{
using var ctx = NewContext();
var result = await NewController(ctx).DeleteEvent(12345);
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" }
}
});
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 result = await NewController(service).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);
}
[Fact]
public void GetEventList_OrdersByStartDateDescending()
{
using var ctx = NewContext();
SeedEvent(ctx, "Earliest", new DateTime(2026, 6, 1));
SeedEvent(ctx, "Latest", new DateTime(2026, 6, 10));
SeedEvent(ctx, "Middle", new DateTime(2026, 6, 5));
var result = NewController(ctx).GetEventList(page: 1, pageSize: 10);
var data = (result as OkObjectResult)!.Value.As<DataResponse>().Data!;
var rows = ((IEnumerable)Prop(data, "rows")).Cast<Event_DTO>().ToList();
rows.Select(r => r.Title).Should().ContainInOrder("Latest", "Middle", "Earliest");
rows.Should().HaveCount(2);
}
[Fact]
public void GetEventsForCalendar_ReturnsOnlyEventsOverlappingRange()
public async Task GetEventsForCalendar_ReturnsOkWithEvents()
{
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 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" } });
var result = NewController(ctx).GetEventsForCalendar(
start: new DateTime(2026, 6, 1), end: new DateTime(2026, 6, 30));
var result = await NewController(service).GetEventsForCalendar(
start: new DateTime(2026, 6, 1), end: new DateTime(2026, 6, 30), CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var events = ok.Value.As<DataResponse>().Data as IEnumerable<Event_DTO>;
events!.Select(e => e.Title).Should().BeEquivalentTo(new[] { "InRange" });
var data = ok.Value.Should().BeOfType<DataResponse>().Subject.Data;
var events = ((IEnumerable)data!).Cast<Event_DTO>().ToList();
events.Single().Title.Should().Be("InRange");
}
}

View file

@ -0,0 +1,188 @@
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

@ -0,0 +1,49 @@
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

@ -0,0 +1,223 @@
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

@ -0,0 +1,77 @@
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

@ -0,0 +1,117 @@
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

@ -0,0 +1,146 @@
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

@ -0,0 +1,135 @@
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

@ -0,0 +1,162 @@
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

@ -0,0 +1,42 @@
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,6 +4,7 @@ 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;
@ -31,7 +32,7 @@ public class LocationControllerSitesTests
Mock.Of<ICreateLocationValidation>(),
Mock.Of<IUpdateLocationValidation>());
var controller = new LocationController(service, dataService)
var controller = new LocationController(service, Mock.Of<ILogger<LocationController>>())
{
ControllerContext = new ControllerContext
{

View file

@ -0,0 +1,105 @@
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

@ -0,0 +1,133 @@
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

@ -0,0 +1,71 @@
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

@ -0,0 +1,281 @@
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

@ -0,0 +1,75 @@
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

@ -0,0 +1,120 @@
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

@ -0,0 +1,168 @@
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

@ -0,0 +1,146 @@
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

@ -0,0 +1,213 @@
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

@ -0,0 +1,191 @@
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

@ -0,0 +1,195 @@
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

@ -0,0 +1,317 @@
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

@ -0,0 +1,324 @@
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

@ -0,0 +1,319 @@
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

@ -0,0 +1,317 @@
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

@ -0,0 +1,179 @@
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

@ -0,0 +1,491 @@
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,13 +1,8 @@
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;
@ -21,50 +16,14 @@ namespace Api.SeaHavenIndustries.Tests;
public class WorkOrderControllerSearchTests
{
private static ApplicationDbContext NewContext()
private static WorkOrderBoardController NewController(IWorkOrderAdvancedSearchService advancedSearch)
{
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>(),
var controller = new WorkOrderBoardController(
Mock.Of<IWorkOrderBoardService>(),
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
advancedSearch,
Mock.Of<IWorkOrderDetailService>(),
Mock.Of<IWorkOrderCommentService>(),
Mock.Of<IWorkOrderCompletionService>(),
Mock.Of<IWorkOrderMediaService>());
advancedSearch);
controller.ControllerContext = new ControllerContext
{
@ -88,10 +47,9 @@ public class WorkOrderControllerSearchTests
[Fact]
public async Task SearchBoard_WhenCustomPresetMissingDates_ReturnsBadRequest()
{
using var ctx = NewContext();
var advancedSearch = new WorkOrderAdvancedSearchService(
Mock.Of<IWorkOrderAdvancedSearchDataService>());
var controller = NewController(ctx, advancedSearch);
var controller = NewController(advancedSearch);
var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto
{
@ -107,57 +65,13 @@ public class WorkOrderControllerSearchTests
[Fact]
public async Task GetBoard_WhenWeekEndBeforeWeekStart_ReturnsBadRequest()
{
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 controller = NewController(Mock.Of<IWorkOrderAdvancedSearchService>());
// 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(
var result = await controller.GetBoard(
new DateOnly(2026, 6, 22),
new DateOnly(2026, 6, 15));
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var body = badRequest.Value.Should().BeOfType<Response>().Subject;
body.Status.Should().Be("Error");
body.Message.Should().Contain("weekEnd");
badRequest.Value.Should().BeOfType<string>().Which.Should().Contain("weekEnd");
}
}

View file

@ -0,0 +1,104 @@
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

@ -0,0 +1,207 @@
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,4 +24,8 @@
<ProjectReference Include="..\SeaHaven.DataServices\SeaHaven.DataServices.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Api.SeaHavenIndustries.Tests" />
</ItemGroup>
</Project>

View file

@ -1,14 +1,12 @@
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Microsoft.EntityFrameworkCore;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace Api.SeaHavenIndustries.Controllers
{
@ -16,73 +14,54 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/Authentication")]
public class AuthenticationController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly IConfiguration _configuration;
private readonly ApplicationDbContext _db;
private readonly SendMessage _sendMessage;
private readonly IAuthenticationService _authenticationService;
private readonly ILogger<AuthenticationController> _logger;
public AuthenticationController(UserManager<ApplicationUser> userManager, IConfiguration configuration, ApplicationDbContext db, SendMessage sendMessage)
public AuthenticationController(IAuthenticationService authenticationService, ILogger<AuthenticationController> logger)
{
_userManager = userManager;
_configuration = configuration;
_db = db;
_sendMessage = sendMessage;
_authenticationService = authenticationService;
_logger = logger;
}
[AllowAnonymous]
[HttpPost]
[Route("login")]
public async Task<IActionResult> Login([FromBody] LoginModel model)
public async Task<IActionResult> Login([FromBody] LoginModel model, CancellationToken cancellationToken)
{
try
{
var user = await _userManager.FindByNameAsync(model.Username ?? "");
if (user != null && user.IsDeleted != true && await _userManager.CheckPasswordAsync(user, model.Password ?? ""))
var result = await _authenticationService.LoginAsync(model.Username, model.Password, cancellationToken);
if (result != null)
{
// 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 = new JwtSecurityTokenHandler().WriteToken(token),
expiration = token.ValidTo,
email = user.Email,
userRoles = userRoles.FirstOrDefault(),
phoneNumber = user.PhoneNumber,
fullname = user.FirstName + " " + user.LastName,
id = user.Id
token = result.Token,
expiration = result.Expiration,
email = result.Email,
userRoles = result.UserRole,
phoneNumber = result.PhoneNumber,
fullname = result.Fullname,
id = result.Id
});
}
// Invalid credentials
return Unauthorized();
}
catch (Exception ex)
{
return StatusCode(500, ex.Message);
return StatusCode(500, _logger.Sanitize(ex));
}
}
[Route("ChangePassword")]
[HttpPost]
public async Task<IActionResult> ChangePassword(ChangePasswords usermodel)
public async Task<IActionResult> ChangePassword(ChangePasswords usermodel, CancellationToken cancellationToken)
{
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
var user = await _userManager.FindByIdAsync(userid);
var result = await _userManager.ChangePasswordAsync(user, usermodel.Currentpassword ?? "", usermodel.Confirmpassword ?? "");
if (result.Succeeded)
var succeeded = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Confirmpassword, cancellationToken);
if (succeeded)
{
return Ok(new Response { Status = "Success ", Message = "Password successfully changed" });
}
@ -90,49 +69,36 @@ 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)
public async Task<IActionResult> UserProfileUpdate([FromForm] User_DTO model, CancellationToken cancellationToken)
{
try
{
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
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();
var dto = new UpdateProfileRequestDTO
{
Name = model.Name,
Email = model.Email,
Contact = model.Contact
};
var data = await _authenticationService.UpdateProfileAsync(userid, dto, cancellationToken);
return Ok(new DataResponse
{
Message = "Introduction Updated Successfully",
Status = "200",
Data = _db.Users.Where(u => u.Id == userid).Select(s => new
Data = data == null ? null : new
{
s.FirstName,
//s.Location,
s.Email,
s.Contact
}).FirstOrDefault()
data.FirstName,
data.Email,
data.Contact
}
});
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
@ -141,25 +107,11 @@ namespace Api.SeaHavenIndustries.Controllers
[AllowAnonymous]
[HttpPost()]
[Route("ForgetPassword")]
public async Task<IActionResult> ForgetPassword(string Email)
public async Task<IActionResult> ForgetPassword(string Email, CancellationToken cancellationToken)
{
var user = await _db.Users.Where(u => u.Email.ToLower().Trim() == Email.ToLower().ToLower()).FirstOrDefaultAsync();
if (user != null)
var found = await _authenticationService.ForgetPasswordAsync(Email, cancellationToken);
if (found)
{
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
@ -171,11 +123,11 @@ namespace Api.SeaHavenIndustries.Controllers
[AllowAnonymous]
[HttpPost()]
[Route("VerificationCode")]
public async Task<IActionResult> VerificationCode(string code)
public async Task<IActionResult> VerificationCode(string code, CancellationToken cancellationToken)
{
try
{
if (await _db.ForgetPasswordCodes.Where(u => u.Code == code).AnyAsync())
if (await _authenticationService.VerifyCodeAsync(code, cancellationToken))
{
return Ok(new Response { Status = "Success ", Message = "Code Matched" });
@ -187,7 +139,7 @@ namespace Api.SeaHavenIndustries.Controllers
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = "Error: " + ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
@ -195,16 +147,12 @@ namespace Api.SeaHavenIndustries.Controllers
[AllowAnonymous]
[HttpPost()]
[Route("ResetPassword")]
public async Task<IActionResult> ResetPassword(ForgetPassword_Dto fpdto)
public async Task<IActionResult> ResetPassword(ForgetPassword_Dto fpdto, CancellationToken cancellationToken)
{
try
{
if (_db.ForgetPasswordCodes.Where(u => u.Email.ToLower().Trim() == fpdto.Email.ToLower().Trim() && u.Code == fpdto.Code.Trim()).Any())
if (await _authenticationService.ResetPasswordAsync(fpdto.Email, fpdto.Code, fpdto.Password, cancellationToken))
{
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
@ -215,16 +163,10 @@ namespace Api.SeaHavenIndustries.Controllers
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = "Error: " + ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
private Random _random = new Random();
private string GenerateRandomNo()
{
return _random.Next(0, 999999).ToString("D6");
}
#endregion
}
}

View file

@ -1,13 +1,11 @@
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.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
@ -16,13 +14,13 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/Calendar")]
public class CalendarController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly ApplicationDbContext _db;
private readonly ICalendarService _calendarService;
private readonly ILogger<CalendarController> _logger;
public CalendarController(UserManager<ApplicationUser> userManager, ApplicationDbContext db)
public CalendarController(ICalendarService calendarService, ILogger<CalendarController> logger)
{
_userManager = userManager;
_db = db;
_calendarService = calendarService;
_logger = logger;
}
// ===================================================================================
@ -30,37 +28,17 @@ namespace Api.SeaHavenIndustries.Controllers
// ===================================================================================
[HttpPost]
[Route("AddEvent")]
public async Task<IActionResult> AddEvent(EditEvent_DTO eventDto)
public async Task<IActionResult> AddEvent(EditEvent_DTO eventDto, CancellationToken cancellationToken)
{
try
{
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();
await _calendarService.CreateEventAsync(MapToInput(eventDto), cancellationToken);
return Ok(new DataResponse { Message = "Event Created Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
@ -69,7 +47,7 @@ namespace Api.SeaHavenIndustries.Controllers
// ===================================================================================
[HttpPost]
[Route("EditEvent")]
public async Task<IActionResult> EditEvent(EditEvent_DTO eventDto)
public async Task<IActionResult> EditEvent(EditEvent_DTO eventDto, CancellationToken cancellationToken)
{
try
{
@ -78,35 +56,17 @@ namespace Api.SeaHavenIndustries.Controllers
return BadRequest(new Response { Status = "Error", Message = "Event ID is required for editing" });
}
var model = await _db.Events.FindAsync(eventDto.Id.Value);
if (model == null || model.IsDeleted == true)
var updated = await _calendarService.UpdateEventAsync(MapToInput(eventDto), cancellationToken);
if (!updated)
{
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 = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
@ -115,44 +75,22 @@ namespace Api.SeaHavenIndustries.Controllers
// ===================================================================================
[HttpGet]
[Route("GetEventById/{id}")]
public async Task<IActionResult> GetEventById(int id)
public async Task<IActionResult> GetEventById(int id, CancellationToken cancellationToken)
{
try
{
var model = await _db.Events
.Where(e => e.Id == id && e.IsDeleted != true)
.FirstOrDefaultAsync();
var dto = await _calendarService.GetEventByIdAsync(id, cancellationToken);
if (model == null)
if (dto == null)
{
return NotFound(new Response { Status = "Error", Message = "Event not found" });
}
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 });
return Ok(new DataResponse { Message = "Success", Status = "200", Data = MapToResponse(dto) });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
@ -161,28 +99,21 @@ namespace Api.SeaHavenIndustries.Controllers
// ===================================================================================
[HttpDelete]
[Route("DeleteEvent/{id}")]
public async Task<IActionResult> DeleteEvent(int id)
public async Task<IActionResult> DeleteEvent(int id, CancellationToken cancellationToken)
{
try
{
var model = await _db.Events.FindAsync(id);
if (model == null)
var deleted = await _calendarService.DeleteEventAsync(id, cancellationToken);
if (!deleted)
{
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 = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
@ -191,63 +122,24 @@ namespace Api.SeaHavenIndustries.Controllers
// ===================================================================================
[HttpGet]
[Route("GetEventList")]
public IActionResult GetEventList(string? search = "", int page = 1, int pageSize = 10)
public async Task<IActionResult> GetEventList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
{
try
{
var query = _db.Events.Where(e => e.IsDeleted != true);
var result = await _calendarService.GetEventListAsync(search, page, pageSize, cancellationToken);
// 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();
var events = result.Rows.Select(MapToResponse).ToList();
return Ok(new DataResponse
{
Message = "Success",
Status = "200",
Data = new { rows = events, totalCount }
Data = new { rows = events, totalCount = result.TotalCount }
});
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
@ -256,51 +148,62 @@ namespace Api.SeaHavenIndustries.Controllers
// ===================================================================================
[HttpGet]
[Route("GetEventsForCalendar")]
public IActionResult GetEventsForCalendar([FromQuery] DateTime? start, [FromQuery] DateTime? end)
public async Task<IActionResult> GetEventsForCalendar([FromQuery] DateTime? start, [FromQuery] DateTime? end, CancellationToken cancellationToken)
{
try
{
var query = _db.Events.Where(e => e.IsDeleted != true);
var events = await _calendarService.GetEventsForCalendarAsync(start, end, cancellationToken);
// 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 });
return Ok(new DataResponse { Message = "Success", Status = "200", Data = events.Select(MapToResponse).ToList() });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
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,136 +1,101 @@
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.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[ApiController]
[Route("[controller]")]
public class CommonController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly ApplicationDbContext _db;
public CommonController(UserManager<ApplicationUser> userManager, ApplicationDbContext db)
private readonly ICommonService _commonService;
private readonly ILogger<CommonController> _logger;
public CommonController(ICommonService commonService, ILogger<CommonController> logger)
{
_userManager = userManager;
_db = db;
_commonService = commonService;
_logger = logger;
}
[HttpGet]
[Route("GetUsers")]
public async Task<IActionResult> GetUsers(string? search = "", int page = 1, int pageSize = 10)
public async Task<IActionResult> GetUsers(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
{
try
{
var allUsersQuery = _db.Users
.Where(w => (w.FirstName ?? "").ToLower().Contains((search ?? "").ToLower()))
.OrderByDescending(d => d.Id);
var result = await _commonService.GetUsersPagedAsync(search, page, pageSize, cancellationToken);
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)
var filteredUsers = result.Users.Select(u => new UserRoleViewModel
{
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
RoleName = u.RoleName,
Email = u.Email,
UserName = u.UserName,
Date = u.Date,
Status = u.Status,
Id = u.Id
}).ToList();
var viewModel = new Pagination_DTO
{
Data = filteredUsers,
PageNumber = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = totalPages
PageNumber = result.PageNumber,
PageSize = result.PageSize,
TotalCount = result.TotalCount,
TotalPages = result.TotalPages
};
return Ok(viewModel);
}
catch (Exception ex)
{
return BadRequest(new { Status = "Error", Message = ex.Message });
return BadRequest(new { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpGet]
[Route("GetRoles")]
public async Task<IActionResult> GetRoles()
public async Task<IActionResult> GetRoles(CancellationToken cancellationToken)
{
try
{
var data = await _db.Roles.Select(s => new {Id=s.Name,s.Name}).ToListAsync();
var data = await _commonService.GetRolesAsync(cancellationToken);
return Ok(data);
}
catch (Exception ex)
{
return BadRequest(new { Status = "Error", Message = ex.Message });
return BadRequest(new { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpGet]
[Route("GetSitesName")]
public async Task<IActionResult> GetSiteName()
public async Task<IActionResult> GetSiteName(CancellationToken cancellationToken)
{
var data =await _db.Locations.Select(n=> new
{
n.Id,
n.Name,
n.Title
}).ToListAsync();
var data = await _commonService.GetSiteNamesAsync(cancellationToken);
return Ok(data);
}
[HttpGet]
[Route("GetContacts")]
public async Task<IActionResult> GetContacts()
public async Task<IActionResult> GetContacts(CancellationToken cancellationToken)
{
var data =await _db.Contacts.Select(n=> new
{
n.Id,
Name = (n.FirstName + " " + n.LastName).Trim(),
email=n.Email,
phone=n.PhoneNumber
}).ToListAsync();
var data = await _commonService.GetContactsAsync(cancellationToken);
return Ok(data);
}
[HttpGet]
[Route("GetCategories")]
public async Task<IActionResult> GetCategories()
public async Task<IActionResult> GetCategories(CancellationToken cancellationToken)
{
var data = await _db.Categories.Select(s=> new
{
s.Id,
s.Name
}).ToListAsync();
var data = await _commonService.GetCategoriesAsync(cancellationToken);
return Ok(data);
}
}
}
}

View file

@ -1,7 +1,6 @@
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
@ -10,29 +9,24 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/[controller]")]
public class DashboardController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IDashboardService _dashboardService;
public DashboardController(ApplicationDbContext db)
public DashboardController(IDashboardService dashboardService)
{
_db = db;
_dashboardService = dashboardService;
}
[HttpGet("Stats")]
public async Task<IActionResult> GetStats()
public async Task<IActionResult> GetStats(CancellationToken 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");
var stats = await _dashboardService.GetStatsAsync(cancellationToken);
return Ok(new
{
total,
open,
notDispatched,
completed
total = stats.Total,
open = stats.Open,
notDispatched = stats.NotDispatched,
completed = stats.Completed
});
}
}

View file

@ -1,9 +1,7 @@
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
{
@ -14,12 +12,10 @@ namespace Api.SeaHavenIndustries.Controllers
public class DispatchController : Controller
{
private readonly IDispatchService _dispatchService;
private readonly ApplicationDbContext _db; // Keep for complex queries
public DispatchController(IDispatchService dispatchService, ApplicationDbContext db)
public DispatchController(IDispatchService dispatchService)
{
_dispatchService = dispatchService;
_db = db;
}
[HttpGet]
@ -32,317 +28,29 @@ namespace Api.SeaHavenIndustries.Controllers
[FromQuery] string? sortBy = "dispatchedAt",
[FromQuery] string? sortDir = "desc",
[FromQuery] int page = 1,
[FromQuery] int pageSize = 25)
[FromQuery] int pageSize = 25,
CancellationToken cancellationToken = default)
{
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();
var result = await _dispatchService.GetDispatchListAsync(
search, status, vendorId, dateFrom, dateTo, sortBy, sortDir, page, pageSize, cancellationToken);
return Ok(new DataResponse
{
Status = "Success",
Data = new { total, page, pageSize, items = rows }
Data = new { total = result.Total, page = result.Page, pageSize = result.PageSize, items = result.Items }
});
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
var dispatch = await _db.Dispatches
.Include(d => d.Vendor)
.Include(d => d.WorkOrder).ThenInclude(w => w!.Locations)
.FirstOrDefaultAsync(d => d.Id == id);
var dispatch = await _dispatchService.GetDispatchDetailAsync(id, cancellationToken);
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 = 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
}
Data = dispatch
});
}
}

View file

@ -1,7 +1,10 @@
using Data.SeaHavenIndustries;
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
@ -10,78 +13,82 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/[controller]")]
public class DropdownOptionsController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IDropdownOptionsService _dropdownOptionsService;
private readonly ILogger<DropdownOptionsController> _logger;
public DropdownOptionsController(ApplicationDbContext db)
public DropdownOptionsController(IDropdownOptionsService dropdownOptionsService, ILogger<DropdownOptionsController> logger)
{
_db = db;
_dropdownOptionsService = dropdownOptionsService;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] string? category = null)
public async Task<IActionResult> GetAll([FromQuery] string? category = null, CancellationToken cancellationToken = default)
{
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();
var data = await _dropdownOptionsService.GetAllAsync(category, cancellationToken);
return Ok(data);
}
[HttpGet("ByCategory/{category}")]
public async Task<IActionResult> GetByCategory(string category, [FromQuery] string? parent = null)
public async Task<IActionResult> GetByCategory(string category, [FromQuery] string? parent = null, CancellationToken cancellationToken = default)
{
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();
var data = await _dropdownOptionsService.GetByCategoryAsync(category, parent, cancellationToken);
return Ok(data);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] DropdownOption model)
public async Task<IActionResult> Create([FromBody] DropdownOption_DTO model, CancellationToken cancellationToken)
{
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);
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") });
}
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, [FromBody] DropdownOption model)
public async Task<IActionResult> Update(int id, [FromBody] DropdownOption_DTO model, CancellationToken cancellationToken)
{
var item = await _db.DropdownOptions.FindAsync(id);
if (item == null)
try
{
var updated = await _dropdownOptionsService.UpdateAsync(id, MapToService(model), cancellationToken);
return Ok(updated);
}
catch (KeyNotFoundException)
{
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)
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
var item = await _db.DropdownOptions.FindAsync(id);
if (item == null)
try
{
await _dropdownOptionsService.DeleteAsync(id, cancellationToken);
return Ok(new { message = "Deleted" });
}
catch (KeyNotFoundException)
{
return NotFound(new { message = "Option not found" });
}
}
_db.DropdownOptions.Remove(item);
await _db.SaveChangesAsync();
return Ok(new { message = "Deleted" });
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
};
}
}
}

View file

@ -1,14 +1,13 @@
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.EntityFrameworkCore;
using SeaHaven.Services.Interfaces;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Interfaces;
[ApiController]
[Authorize]
@ -17,16 +16,12 @@ using SeaHaven.DataServices.Interfaces;
public class FollowUpController : ControllerBase
{
private readonly IFollowUpService _followUpService;
private readonly IFollowUpDataService _followUpDataService; // Keep for direct entity operations
private readonly ApplicationDbContext _db; // Keep for User lookups
private readonly UserManager<ApplicationUser> _users;
private readonly ILogger<FollowUpController> _logger;
public FollowUpController(IFollowUpService followUpService, IFollowUpDataService followUpDataService, ApplicationDbContext db, UserManager<ApplicationUser> users)
public FollowUpController(IFollowUpService followUpService, ILogger<FollowUpController> logger)
{
_followUpService = followUpService;
_followUpDataService = followUpDataService;
_db = db;
_users = users;
_logger = logger;
}
[HttpGet("GetFollowUpList")]
@ -34,70 +29,31 @@ public class FollowUpController : ControllerBase
string? search = "",
string? status = "Open",
int page = 1,
int pageSize = 12)
int pageSize = 12,
CancellationToken cancellationToken = default)
{
status = string.IsNullOrWhiteSpace(status) ? "Open" : status;
var pagedResult = await _followUpService.GetFollowUpsPagedAsync(page, pageSize, search, status);
var pagedResult = await _followUpService.GetFollowUpListAsync(page, pageSize, search, status, cancellationToken);
var items = pagedResult.Items;
var rows = items.Select(x => new
var data = items.Select(x => new
{
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)
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
}).ToList();
return Ok(new Pagination_DTO
@ -111,26 +67,12 @@ public class FollowUpController : ControllerBase
}
[HttpGet("GetById")]
public async Task<IActionResult> GetById(int id)
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
var model = await _followUpDataService.GetByIdWithDetailsAsync(id);
var model = await _followUpService.GetFollowUpDetailAsync(id, cancellationToken);
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,
@ -141,22 +83,20 @@ public class FollowUpController : ControllerBase
model.WorkOrderId,
model.AccountId,
model.LocationId,
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.AccountName,
model.AssignedTo,
model.LocationName,
model.WorkOrderNumber,
model.InternalWONumber,
model.Status,
model.CreatedDate,
CreatedById = model.createdby,
CreatedByName = createdByName
model.CreatedById,
model.CreatedByName
});
}
[HttpPost("Create")]
public async Task<IActionResult> Create(FollowUp_DTO dto)
public async Task<IActionResult> Create(FollowUp_DTO dto, CancellationToken cancellationToken)
{
try
{
@ -164,23 +104,9 @@ public class FollowUpController : ControllerBase
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
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
};
var id = await _followUpService.CreateOpenFollowUpAsync(MapToCreateRequest(dto), userId, cancellationToken);
await _followUpDataService.AddAsync(model);
return Ok(new DataResponse { Status = "200", Message = "Follow Up created", Data = model.Id });
return Ok(new DataResponse { Status = "200", Message = "Follow Up created", Data = id });
}
catch (ValidationException vex)
{
@ -189,29 +115,16 @@ public class FollowUpController : ControllerBase
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPost("Update")]
public async Task<IActionResult> Update(FollowUp_DTO dto)
public async Task<IActionResult> Update(FollowUp_DTO dto, CancellationToken cancellationToken)
{
try
{
var model = await _followUpDataService.GetByIdAsync(dto.Id);
if (model == null)
return NotFound(new Response { Status = "Error", Message = "Follow Up not found" });
model.Reason = dto.Reason;
model.ScheduleDate = dto.ScheduleDate;
model.ScheduleStartTime = dto.ScheduleStartTime;
model.EmployeeId = dto.EmployeeId;
model.WorkOrderId = dto.WorkOrderId;
model.AccountId = dto.AccountId;
model.LocationId = dto.LocationId;
model.LastModificationTime = DateTime.UtcNow;
await _followUpDataService.UpdateAsync(model);
await _followUpService.UpdateFollowUpFieldsAsync(dto.Id, MapToUpdateRequest(dto), cancellationToken);
return Ok(new DataResponse { Status = "200", Message = "Follow Up updated" });
}
catch (ValidationException vex)
@ -225,47 +138,37 @@ public class FollowUpController : ControllerBase
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPost("Complete")]
public async Task<IActionResult> Complete(int id)
public async Task<IActionResult> Complete(int id, CancellationToken cancellationToken)
{
var model = await _followUpDataService.GetByIdAsync(id);
if (model == null) return NotFound();
model.Status = "Completed";
model.LastModificationTime = DateTime.UtcNow;
await _followUpDataService.UpdateAsync(model);
var found = await _followUpService.SetFollowUpStatusAsync(id, "Completed", cancellationToken);
if (!found) return NotFound();
return Ok();
}
[HttpPost("Cancel")]
public async Task<IActionResult> Cancel(int id)
public async Task<IActionResult> Cancel(int id, CancellationToken cancellationToken)
{
var model = await _followUpDataService.GetByIdAsync(id);
if (model == null) return NotFound();
model.Status = "Cancelled";
model.LastModificationTime = DateTime.UtcNow;
await _followUpDataService.UpdateAsync(model);
var found = await _followUpService.SetFollowUpStatusAsync(id, "Cancelled", cancellationToken);
if (!found) return NotFound();
return Ok();
}
[HttpPost("Delete")]
public async Task<IActionResult> Delete(int id)
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
try
{
var model = await _followUpDataService.GetByIdAsync(id);
if (model == null)
var found = await _followUpService.RemoveFollowUpAsync(id, cancellationToken);
if (!found)
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)
@ -274,7 +177,36 @@ public class FollowUpController : ControllerBase
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
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,13 +1,12 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SeaHaven.Services.Interfaces;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.DataServices.Interfaces;
using System.Security.Claims;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
@ -18,12 +17,12 @@ namespace Api.SeaHavenIndustries.Controllers
public class LocationController : Controller
{
private readonly ILocationService _locationService;
private readonly ILocationDataService _locationDataService; // Keep for direct entity operations
private readonly ILogger<LocationController> _logger;
public LocationController(ILocationService locationService, ILocationDataService locationDataService)
public LocationController(ILocationService locationService, ILogger<LocationController> logger)
{
_locationService = locationService;
_locationDataService = locationDataService;
_logger = logger;
}
[HttpGet("sites")]
@ -34,9 +33,9 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpGet("GetLocationList")]
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10)
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
{
var pagedResult = await _locationService.GetLocationsPagedAsync(page, pageSize, search);
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, cancellationToken);
var data = pagedResult.Items.Select(l => new
{
@ -68,9 +67,9 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpGet("{id}")]
public async Task<IActionResult> GetLocationById(int id)
public async Task<IActionResult> GetLocationById(int id, CancellationToken cancellationToken)
{
var location = await _locationDataService.GetByIdAsync(id);
var location = await _locationService.GetLocationDetailAsync(id, cancellationToken);
if (location == null)
return NotFound(new Response { Status = "Error", Message = "Location not found" });
@ -96,25 +95,11 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpPost]
public async Task<IActionResult> AddLocation([FromBody] Location_DTO model)
public async Task<IActionResult> AddLocation([FromBody] Location_DTO model, CancellationToken cancellationToken)
{
try
{
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);
await _locationService.CreateLocationFromRequestAsync(MapToCreateRequest(model), cancellationToken);
return Ok(new DataResponse { Message = "Location Created Successfully", Status = "200" });
}
catch (ValidationException vex)
@ -124,31 +109,16 @@ namespace Api.SeaHavenIndustries.Controllers
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPut("{id}")]
public async Task<IActionResult> EditLocation(int id, [FromBody] EditLocation_DTO model)
public async Task<IActionResult> EditLocation(int id, [FromBody] EditLocation_DTO model, CancellationToken cancellationToken)
{
try
{
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);
await _locationService.UpdateLocationFromRequestAsync(id, MapToUpdateRequest(model), cancellationToken);
return Ok(new DataResponse { Message = "Location Updated Successfully", Status = "200" });
}
catch (ValidationException vex)
@ -162,20 +132,19 @@ namespace Api.SeaHavenIndustries.Controllers
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteLocation(int id)
public async Task<IActionResult> DeleteLocation(int id, CancellationToken cancellationToken)
{
try
{
var exist = await _locationDataService.GetByIdAsync(id);
if (exist == null)
var found = await _locationService.DeleteLocationByIdAsync(id, cancellationToken);
if (!found)
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)
@ -184,14 +153,46 @@ namespace Api.SeaHavenIndustries.Controllers
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpGet("DeleteLocation")]
public async Task<IActionResult> DeleteLocationByQuery(int id)
public async Task<IActionResult> DeleteLocationByQuery(int id, CancellationToken cancellationToken)
{
return await DeleteLocation(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
};
}
}
}

View file

@ -1,12 +1,11 @@
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.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using static System.Runtime.InteropServices.JavaScript.JSType;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
@ -15,178 +14,82 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/Quotes")]
public class QuotesController : Controller
{
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)
private readonly IQuotesService _quotesService;
private readonly ILogger<QuotesController> _logger;
public QuotesController(IQuotesService quotesService, ILogger<QuotesController> logger)
{
_userManager = userManager;
_db = db;
_webHostEnvironment = webHostEnvironment;
_httpContext = httpContext;
_quotesService = quotesService;
_logger = logger;
}
[HttpPost]
[Route("AddQuote")]
public async Task<IActionResult> AddQuote([FromBody] Quote_DTO model)
public async Task<IActionResult> AddQuote([FromBody] Quote_DTO model, CancellationToken cancellationToken)
{
try
{
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();
await _quotesService.CreateQuoteAsync(MapToCreateInput(model), cancellationToken);
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPut]
[Route("EditQuote")]
public async Task<IActionResult> EditQuote([FromBody] EditQuote_DTO model)
public async Task<IActionResult> EditQuote([FromBody] EditQuote_DTO model, CancellationToken cancellationToken)
{
try
{
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
{
var updated = await _quotesService.UpdateQuoteAsync(MapToUpdateInput(model), cancellationToken);
if (!updated)
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 = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpDelete]
[Route("DeleteQuote")]
public IActionResult DeleteQuote(EditQuote_DTO model)
public async Task<IActionResult> DeleteQuote(EditQuote_DTO model, CancellationToken cancellationToken)
{
int id = model.id;
try
try
{
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
{
var deleted = await _quotesService.DeleteQuoteAsync(id, cancellationToken);
if (!deleted)
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 = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpGet]
[Route("GetQuoutes")]
public IActionResult GetQuotes(string search = "", int page = 1, int pageSize = 10)
public async Task<IActionResult> GetQuotes(string search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
{
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 result = await _quotesService.GetQuotesPagedAsync(page, pageSize, search, cancellationToken);
// Calculate the number of pages
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
var totalPages = (int)Math.Ceiling(result.TotalCount / (double)pageSize);
// Create a view model containing the paged items and pagination metadata
var viewModel = new Pagination_DTO
{
Data = data,
Data = result.Items,
PageNumber = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalCount = result.TotalCount,
TotalPages = totalPages
};
return Ok(viewModel);
@ -194,66 +97,85 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet]
[Route("GetQuoutesdetailsAgainstWorkOrder")]
public async Task<IActionResult> GetQuoutesdetailsAgainstWorkOrder(int woId)
public async Task<IActionResult> GetQuoutesdetailsAgainstWorkOrder(int woId, CancellationToken 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();
var data = await _quotesService.GetWorkOrderQuoteDetailsAsync(woId, cancellationToken);
return Ok(data);
}
[HttpGet]
[Route("GetQuoutesById")]
public async Task<IActionResult> GetQuoutesById(int id)
public async Task<IActionResult> GetQuoutesById(int id, CancellationToken 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();
var data = await _quotesService.GetQuoteDetailsByIdAsync(id, cancellationToken);
return Ok(data);
}
[HttpGet]
[Route("GetReporting")]
public async Task<IActionResult> GetReporting()
public async Task<IActionResult> GetReporting(CancellationToken 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 data = await _quotesService.GetReportingAsync(cancellationToken);
var data = new Reporting_DTO
var reportingDto = new Reporting_DTO
{
TotalCreatedWorkOrder = totalCreatedWorkOrder,
TotalCompleted = totalCompleted,
TotalSev1 = totalSev1,
TotalSev2 = totalSev2,
TotalSev3 = totalSev3,
TotalSev4 = totalSev4,
TotalSev5 = totalSev5
TotalCreatedWorkOrder = data.TotalCreatedWorkOrder,
TotalCompleted = data.TotalCompleted,
TotalSev1 = data.TotalSev1,
TotalSev2 = data.TotalSev2,
TotalSev3 = data.TotalSev3,
TotalSev4 = data.TotalSev4,
TotalSev5 = data.TotalSev5
};
return Ok(data);
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()
};
}
}
}

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.EntityFrameworkCore;
using static System.Runtime.InteropServices.JavaScript.JSType;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
@ -14,107 +14,78 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("[controller]")]
public class SettingsController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly ApplicationDbContext _db;
public SettingsController(UserManager<ApplicationUser> userManager, ApplicationDbContext db)
private readonly ISettingsService _settingsService;
private readonly ILogger<SettingsController> _logger;
public SettingsController(ISettingsService settingsService, ILogger<SettingsController> logger)
{
_userManager = userManager;
_db = db;
_settingsService = settingsService;
_logger = logger;
}
[HttpPost]
[Route("AddCategory")]
public async Task<IActionResult> AddCategory(Category_DTO model)
public async Task<IActionResult> AddCategory(Category_DTO model, CancellationToken cancellationToken)
{
try
{
Category category = new Category();
category.Name = model.Name;
_db.Categories.Add(category);
await _db.SaveChangesAsync();
await _settingsService.AddCategoryAsync(model.Name, cancellationToken);
return Ok(new DataResponse { Message = "Successfully Saved Data", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPost]
[Route("EditCategory")]
public async Task<IActionResult> EditCategory(EditCategory_DTO model)
public async Task<IActionResult> EditCategory(EditCategory_DTO model, CancellationToken cancellationToken)
{
try
{
var exist = _db.Categories.Where(w => w.Id == model.Id).FirstOrDefault();
if (exist == null)
var found = await _settingsService.UpdateCategoryAsync(model.Id, model.Name, cancellationToken);
if (!found)
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 = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpGet]
[Route("DeleteCategory")]
public async Task<IActionResult> DeleteCategory(int id)
public async Task<IActionResult> DeleteCategory(int id, CancellationToken cancellationToken)
{
try
{
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
{
var found = await _settingsService.DeleteCategoryAsync(id, cancellationToken);
if (!found)
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 = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpGet]
[Route("GetCategoryList")]
public IActionResult GetCategoryList(int page = 1, int pageSize = 10)
public async Task<IActionResult> GetCategoryList(int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
{
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 (items, total) = await _settingsService.GetCategoryListAsync(page, pageSize, cancellationToken);
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
var totalPages = (int)Math.Ceiling(total / (double)pageSize);
var viewModel = new Pagination_DTO
{
Data = data,
Data = items,
PageNumber = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalCount = total,
TotalPages = totalPages
};
return Ok(viewModel);
@ -122,117 +93,79 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet]
[Route("GetTemplates")]
public IActionResult GetTemplates(int page = 1, int pageSize = 10)
public async Task<IActionResult> GetTemplates(int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
{
try
{
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 (items, total) = await _settingsService.GetTemplatesAsync(page, pageSize, cancellationToken);
// Calculate the number of pages
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
var totalPages = (int)Math.Ceiling(total / (double)pageSize);
// Create a view model containing the paged items and pagination metadata
var viewModel = new Pagination_DTO
{
Data = data,
Data = items,
PageNumber = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalCount = total,
TotalPages = totalPages
};
return Ok(viewModel);
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpGet]
[Route("GetUsers")]
public async Task<IActionResult> GetUsers(string? search = "", int page = 1, int pageSize = 10)
public async Task<IActionResult> GetUsers(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
{
try
{
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 (users, total) = await _settingsService.GetUsersAsync(search, page, pageSize, cancellationToken);
// Calculate the number of pages
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
var filteredUsers = new List<UserRoleViewModel>();
foreach (var user in allUsers)
var filteredUsers = users.Select(u => new UserRoleViewModel
{
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
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 viewModel = new Pagination_DTO
{
Data = filteredUsers,
PageNumber = page,
PageSize = pageSize,
TotalCount = totalCount,
TotalCount = total,
TotalPages = totalPages
};
return Ok(viewModel);
}
catch (Exception ex)
{
return BadRequest(new { Status = "Error", Message = ex.Message });
return BadRequest(new { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpGet]
[Route("GetRoles")]
public async Task<IActionResult> GetRoles()
public async Task<IActionResult> GetRoles(CancellationToken cancellationToken)
{
try
{
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);
var roles = await _settingsService.GetRolesAsync(cancellationToken);
var result = roles.Select(r => new Roles_DTO { Name = r.Name, Description = r.Description }).ToList();
return Ok(result);
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
}

View file

@ -1,11 +1,8 @@
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
{
@ -14,18 +11,13 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/[controller]")]
public class SyncController : Controller
{
private readonly ApplicationDbContext _db;
private readonly AmazonDynamoDBClient _dynamo;
private readonly ISyncService _syncService;
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(ApplicationDbContext db, IOptions<SyncOptions> syncOptions)
public SyncController(ISyncService syncService, IOptions<SyncOptions> syncOptions)
{
_db = db;
_syncService = syncService;
_syncOptions = syncOptions.Value;
_dynamo = new AmazonDynamoDBClient(Amazon.RegionEndpoint.USEast1);
}
private IActionResult? SyncDisabledResult()
@ -40,419 +32,73 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpPost("WorkOrders")]
public async Task<IActionResult> SyncWorkOrders()
public async Task<IActionResult> SyncWorkOrders(CancellationToken cancellationToken)
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var synced = 0;
var created = 0;
var updated = 0;
var locationsCreated = 0;
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()
});
var result = await _syncService.SyncWorkOrdersAsync(cancellationToken);
return Ok(result);
}
[HttpPost("Comments")]
public async Task<IActionResult> SyncComments()
public async Task<IActionResult> SyncComments(CancellationToken cancellationToken)
{
if (SyncDisabledResult() is { } disabled)
return disabled;
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 });
var result = await _syncService.SyncCommentsAsync(cancellationToken);
return Ok(result);
}
[HttpPost("BackfillInternalWONumbers")]
public async Task<IActionResult> BackfillInternalWONumbers()
public async Task<IActionResult> BackfillInternalWONumbers(CancellationToken cancellationToken)
{
if (SyncDisabledResult() is { } disabled)
return disabled;
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 });
var result = await _syncService.BackfillInternalWONumbersAsync(cancellationToken);
return Ok(result);
}
[HttpPost("BackfillDispatchNumbers")]
public async Task<IActionResult> BackfillDispatchNumbers()
public async Task<IActionResult> BackfillDispatchNumbers(CancellationToken cancellationToken)
{
if (SyncDisabledResult() is { } disabled)
return disabled;
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 });
var result = await _syncService.BackfillDispatchNumbersAsync(cancellationToken);
return Ok(result);
}
[HttpPost("VendorReplies")]
public async Task<IActionResult> SyncVendorReplies()
public async Task<IActionResult> SyncVendorReplies(CancellationToken cancellationToken)
{
if (SyncDisabledResult() is { } disabled)
return disabled;
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 });
var result = await _syncService.SyncVendorRepliesAsync(cancellationToken);
return Ok(result);
}
[HttpPost("BackfillCommentTypes")]
public async Task<IActionResult> BackfillCommentTypes()
public async Task<IActionResult> BackfillCommentTypes(CancellationToken cancellationToken)
{
if (SyncDisabledResult() is { } disabled)
return disabled;
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 });
var result = await _syncService.BackfillCommentTypesAsync(cancellationToken);
return Ok(result);
}
[HttpPost("All")]
public async Task<IActionResult> SyncAll()
public async Task<IActionResult> SyncAll(CancellationToken cancellationToken)
{
if (SyncDisabledResult() is { } disabled)
return disabled;
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}";
var result = await _syncService.SyncAllAsync(cancellationToken);
return Ok(result);
}
}
}

View file

@ -1,8 +1,11 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
@ -12,57 +15,26 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/tasklisttemplates")]
public class TaskListTemplateController : Controller
{
private readonly ApplicationDbContext _db;
private readonly ITaskListTemplateService _taskListTemplateService;
private readonly ILogger<TaskListTemplateController> _logger;
public TaskListTemplateController(ApplicationDbContext db)
public TaskListTemplateController(ITaskListTemplateService taskListTemplateService, ILogger<TaskListTemplateController> logger)
{
_db = db;
_taskListTemplateService = taskListTemplateService;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> GetAll()
public async Task<IActionResult> GetAll(CancellationToken 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();
var data = await _taskListTemplateService.GetAllAsync(cancellationToken);
return Ok(data);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
public async Task<IActionResult> GetById(int id, CancellationToken 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();
var template = await _taskListTemplateService.GetByIdAsync(id, cancellationToken);
if (template == null)
return NotFound(new Response { Status = "Error", Message = "Template not found" });
@ -71,76 +43,61 @@ namespace Api.SeaHavenIndustries.Controllers
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] TaskListTemplate_DTO model)
public async Task<IActionResult> Create([FromBody] TaskListTemplate_DTO model, CancellationToken cancellationToken)
{
try
{
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 });
var input = MapToInput(model);
var id = await _taskListTemplateService.CreateAsync(input, cancellationToken);
return Ok(new { message = "Template created", id });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, [FromBody] TaskListTemplate_DTO model)
public async Task<IActionResult> Update(int id, [FromBody] TaskListTemplate_DTO model, CancellationToken cancellationToken)
{
try
{
var template = await _db.TaskListTemplates
.Include(t => t.Items)
.FirstOrDefaultAsync(t => t.Id == id);
if (template == null)
var input = MapToInput(model);
var updated = await _taskListTemplateService.UpdateAsync(id, input, cancellationToken);
if (!updated)
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 = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
var template = await _db.TaskListTemplates.FindAsync(id);
if (template == null)
var deleted = await _taskListTemplateService.DeleteAsync(id, cancellationToken);
if (!deleted)
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,8 +1,10 @@
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
@ -12,225 +14,77 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/uplifts")]
public class UpliftController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IConfiguration _config;
private readonly IUpliftService _upliftService;
private readonly ILogger<UpliftController> _logger;
public UpliftController(ApplicationDbContext db, IConfiguration config)
public UpliftController(IUpliftService upliftService, ILogger<UpliftController> logger)
{
_db = db;
_config = config;
_upliftService = upliftService;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> List([FromQuery] string? status = "Pending", [FromQuery] int? tier = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 25)
public async Task<IActionResult> List([FromQuery] string? status = "Pending", [FromQuery] int? tier = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 25, CancellationToken cancellationToken = default)
{
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 }
});
var result = await _upliftService.ListAsync(User, status, tier, page, pageSize, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
}
[HttpGet("dispatch/{dispatchId:int}")]
public async Task<IActionResult> ListForDispatch(int dispatchId)
public async Task<IActionResult> ListForDispatch(int dispatchId, CancellationToken cancellationToken = default)
{
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();
var items = await _upliftService.ListForDispatchAsync(User, dispatchId, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = items });
}
[HttpPost("{id:int}/approve")]
public async Task<IActionResult> Approve(int id, [FromBody] DecisionRequest? body)
public async Task<IActionResult> Approve(int id, [FromBody] DecisionRequest? body, CancellationToken cancellationToken = default)
{
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
try
{
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 } });
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") });
}
}
[HttpPost("{id:int}/deny")]
public async Task<IActionResult> Deny(int id, [FromBody] DecisionRequest body)
public async Task<IActionResult> Deny(int id, [FromBody] DecisionRequest? body, CancellationToken cancellationToken = default)
{
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
try
{
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 } });
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") });
}
}
[HttpGet("can-approve")]
public IActionResult CanApprove([FromQuery] int 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;
return Ok(new DataResponse { Status = "Success", Data = new { canApprove = _upliftService.CanApprove(User, tier) } });
}
public class DecisionRequest

View file

@ -1,15 +1,12 @@
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.EntityFrameworkCore;
using PasswordGenerator;
using SendGrid.Helpers.Mail;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using System.Security.Cryptography;
namespace Api.SeaHavenIndustries.Controllers
{
@ -18,287 +15,123 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("api/User")]
public class UserController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly ApplicationDbContext _db;
private readonly SendMessage _sendemail;
private readonly IUserService _userService;
private readonly ILogger<UserController> _logger;
public UserController(UserManager<ApplicationUser> userManager, ApplicationDbContext db, SendMessage sendemail)
public UserController(IUserService userService, ILogger<UserController> logger)
{
_userManager = userManager;
_db = db;
_sendemail = sendemail;
_userService = userService;
_logger = logger;
}
[Route("")]
[HttpGet]
public async Task<IActionResult> GetUsers()
public async Task<IActionResult> GetUsers(CancellationToken cancellationToken)
{
_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);
var users = await _userService.GetUsersAsync(cancellationToken);
return Ok(users);
}
[HttpPost]
[Route("AddUser")]
public async Task<IActionResult> AddUser(User_DTO user)
public async Task<IActionResult> AddUser(User_DTO user, CancellationToken cancellationToken)
{
var model = new ApplicationUser
{
UserName = user.Email,
FirstName = user.Name,
Email = user.Email,
};
try
{
_db.ChangeTracker.Clear();
var exist = _db.Set<ApplicationUser>().Find(model.Id);
if (exist == null)
var dto = new AddUserRequestDTO
{
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
Name = user.Name,
Email = user.Email,
Contact = user.Contact,
Role = user.Role
};
var outcome = await _userService.AddUserAsync(dto, cancellationToken);
if (!outcome.Success)
{
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 BadRequest(new Response { Status = "Error", Message = outcome.Error });
}
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPut]
[Route("EditUser")]
public async Task<IActionResult> EditUser(EditUser_DTO user)
public async Task<IActionResult> EditUser(EditUser_DTO user, CancellationToken cancellationToken)
{
try
{
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())
var dto = new EditUserRequestDTO
{
await _userManager.RemoveFromRolesAsync(exist, existingRole);
}
await _userManager.AddToRoleAsync(exist, user.Role ?? "");
await _db.SaveChangesAsync();
Id = user.Id,
Name = user.Name,
Email = user.Email,
Role = user.Role
};
var succeeded = await _userService.EditUserAsync(dto, cancellationToken);
if (!succeeded)
return BadRequest(new Response { Status = "Error", Message = "User not found" });
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
public class DeleteUserRequest
{
public string Id { get; set; }
}
[HttpDelete]
[Route("DeleteUser")]
public async Task<IActionResult> DeleteUser(EditUser_DTO req)
public async Task<IActionResult> DeleteUser(EditUser_DTO req, CancellationToken cancellationToken)
{
string id = req.Id;
string id = req.Id ?? "";
try
{
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
var found = await _userService.DeleteUserAsync(id, cancellationToken);
if (!found)
{
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 = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpPost]
[Route("DeleteCurrentUser")]
public async Task<IActionResult> DeleteCurrentUser()
public async Task<IActionResult> DeleteCurrentUser(CancellationToken cancellationToken)
{
try
{
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
var exist = _db.Users.Where(w => w.Id == userid).FirstOrDefault();
if(exist != null)
{
exist.IsDeleted = true;
}
await _db.SaveChangesAsync();
await _userService.DeleteCurrentUserAsync(userid, cancellationToken);
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
}
catch (Exception ex)
{
return BadRequest(new Response { Status = "Error", Message = ex.Message });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpGet]
[Route("UserProfile")]
public async Task<IActionResult> UserProfile()
public async Task<IActionResult> UserProfile(CancellationToken cancellationToken)
{
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
var data = await _db.Users.Where(w => w.Id == userid).Select(s => new
var data = await _userService.GetUserProfileAsync(userid, cancellationToken);
return Ok(data.Select(d => new
{
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
d.Id,
Name = d.Name,
addeddate = d.AddedDate,
d.Email,
d.Contact
}));
}
}
}

View file

@ -1,9 +1,10 @@
using Api.SeaHavenIndustries.DTOs;
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.DTOs;
using System.Security.Claims;
@ -17,31 +18,54 @@ namespace Api.SeaHavenIndustries.Controllers
public class VendorController : Controller
{
private readonly IVendorService _vendorService;
private readonly ApplicationDbContext _db; // Keep for complex queries like nearby vendors
private readonly Helper.ZipCodeDistance _zipDistance;
private readonly Helper.VendorPortalTokenService _vendorTokens;
private readonly IConfiguration _config;
private readonly ILogger<VendorController> _logger;
public VendorController(IVendorService vendorService, ApplicationDbContext db, Helper.ZipCodeDistance zipDistance, Helper.VendorPortalTokenService vendorTokens, IConfiguration config)
public VendorController(IVendorService vendorService, ILogger<VendorController> logger)
{
_vendorService = vendorService;
_db = db;
_zipDistance = zipDistance;
_vendorTokens = vendorTokens;
_config = config;
_logger = logger;
}
[HttpGet("GetVendorList")]
public async Task<IActionResult> GetVendorList(string? search = "", int page = 1, int pageSize = 10)
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)
{
var pagedResult = await _vendorService.GetVendorsPagedAsync(page, pageSize, search);
var pagedResult = await _vendorService.GetVendorsPagedAsync(
page,
pageSize,
search,
isActive,
companies,
trades,
locations,
jobBuckets);
var data = pagedResult.Items.Select(v => new
{
v.Id,
CompanyName = v.Name,
v.CompanyId,
v.ContactName,
v.Email,
v.Phone
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
});
return Ok(new Pagination_DTO
@ -56,17 +80,37 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet("{id}")]
[HttpGet("GetById")]
public async Task<IActionResult> GetVendorById([FromQuery] int? id)
public async Task<IActionResult> GetVendorById([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId)
{
if (id == null)
var vendorId = id ?? queryId;
if (vendorId == null)
return BadRequest(new Response { Status = "Error", Message = "Id is required" });
var vendor = await _vendorService.GetVendorByIdAsync(id.Value);
var vendor = await _vendorService.GetVendorByIdAsync(vendorId.Value);
if (vendor == null)
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
return Ok(vendor);
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
});
}
[HttpPost]
@ -77,7 +121,21 @@ namespace Api.SeaHavenIndustries.Controllers
{
var createDto = new CreateVendorDTO
{
Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required")
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
};
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
@ -94,7 +152,7 @@ namespace Api.SeaHavenIndustries.Controllers
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
@ -111,7 +169,21 @@ namespace Api.SeaHavenIndustries.Controllers
var updateDto = new UpdateVendorDTO
{
Name = model.CompanyName
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
};
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
@ -126,19 +198,28 @@ namespace Api.SeaHavenIndustries.Controllers
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (KeyNotFoundException)
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)
{
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpDelete("{id}")]
[HttpPost("Delete")]
public async Task<IActionResult> Delete([FromRoute] int? id, [FromQuery] int? queryId = null)
public async Task<IActionResult> Delete([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId = null)
{
try
{
@ -152,109 +233,121 @@ 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 Deleted", Status = "200" });
return Ok(new DataResponse { Message = "Vendor Deactivated", Status = "200" });
}
catch (KeyNotFoundException)
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)
{
return NotFound(new Response { Status = "Error", Message = "Vendor not found" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
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) });
}
}
[HttpGet("Dropdown")]
public async Task<IActionResult> GetDropdown([FromQuery] string? trade = null, [FromQuery] string? siteZip = null)
public async Task<IActionResult> GetDropdown([FromQuery] string? trade = null, [FromQuery] string? siteZip = null, CancellationToken cancellationToken = default)
{
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();
}
var result = await _vendorService.GetDropdownAsync(trade, siteZip, cancellationToken);
return Ok(result);
}
[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)
public async Task<IActionResult> GetPortalToken(int id, CancellationToken cancellationToken = default)
{
var vendor = await _db.Vendors.FindAsync(id);
if (vendor == null) return NotFound();
var token = await _vendorTokens.GetOrCreateActiveTokenAsync(id);
return Ok(BuildPortalTokenResponse(token));
var result = await _vendorService.GetPortalTokenAsync(id, cancellationToken);
if (result == null) return NotFound();
return Ok(result);
}
[HttpPost("{id:int}/portal-token/rotate")]
public async Task<IActionResult> RotatePortalToken(int id)
public async Task<IActionResult> RotatePortalToken(int id, CancellationToken cancellationToken = default)
{
var vendor = await _db.Vendors.FindAsync(id);
if (vendor == null) return NotFound();
var token = await _vendorTokens.RotateAsync(id);
return Ok(BuildPortalTokenResponse(token));
var result = await _vendorService.RotatePortalTokenAsync(id, cancellationToken);
if (result == null) return NotFound();
return Ok(result);
}
[HttpPost("{id:int}/portal-token/revoke")]
public async Task<IActionResult> RevokePortalToken(int id)
public async Task<IActionResult> RevokePortalToken(int id, CancellationToken cancellationToken = default)
{
var vendor = await _db.Vendors.FindAsync(id);
if (vendor == null) return NotFound();
await _vendorTokens.RevokeAllAsync(id);
var success = await _vendorService.RevokePortalTokenAsync(id, cancellationToken);
if (!success) return NotFound();
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

@ -0,0 +1,210 @@
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,8 +1,10 @@
using Api.SeaHavenIndustries.Helper;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
{
@ -13,650 +15,260 @@ namespace Api.SeaHavenIndustries.Controllers
{
private const string TokenHeader = "X-Vendor-Token";
private static readonly string[] VendorViewableStatuses =
new[] { "Sent", "Acknowledged", "In Progress", "Completed", "Verified", "Cancelled" };
private readonly IVendorPortalService _portalService;
private readonly 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)
public VendorPortalController(IVendorPortalService portalService, ILogger<VendorPortalController> logger)
{
_db = db;
_tokens = tokens;
_config = config;
_sendMessage = sendMessage;
_portalService = portalService;
_logger = logger;
}
[HttpGet("session")]
public async Task<IActionResult> Session()
public async Task<IActionResult> Session(CancellationToken cancellationToken = default)
{
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var session = await ResolveSessionAsync(cancellationToken);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
return Ok(new DataResponse
{
Status = "Success",
Data = new
{
vendor.Id,
vendor.CompanyName,
vendor.ContactName,
vendor.Email,
vendor.Phone
}
Data = session
});
}
[HttpGet("dispatches")]
public async Task<IActionResult> ListDispatches([FromQuery] string? status = null)
public async Task<IActionResult> ListDispatches([FromQuery] string? status = null, CancellationToken cancellationToken = default)
{
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 session = await ResolveSessionAsync(cancellationToken);
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
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)
public async Task<IActionResult> GetDispatch(int id, CancellationToken cancellationToken = default)
{
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var session = await ResolveSessionAsync(cancellationToken);
if (session == 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 detail = await _portalService.GetDispatchDetailAsync(session, id, cancellationToken);
if (detail == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
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
}
});
return Ok(new DataResponse { Status = "Success", Data = detail });
}
[HttpPost("dispatches/{id:int}/accept")]
public async Task<IActionResult> Accept(int id)
public async Task<IActionResult> Accept(int id, CancellationToken cancellationToken = default)
{
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var session = await ResolveSessionAsync(cancellationToken);
if (session == 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" });
if (dispatch.Status != "Sent")
try
{
return BadRequest(new Response { Status = "Error", Message = $"Cannot accept a dispatch with status '{dispatch.Status}'" });
var result = await _portalService.AcceptDispatchAsync(session, id, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
}
var now = DateTime.UtcNow;
dispatch.Status = "Acknowledged";
dispatch.AcknowledgedAt = now;
dispatch.LastModificationTime = now;
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
catch (KeyNotFoundException)
{
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 } });
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}/status")]
public async Task<IActionResult> ChangeStatus(int id, [FromBody] ChangeStatusRequest body)
public async Task<IActionResult> ChangeStatus(int id, [FromBody] ChangeStatusRequest body, CancellationToken cancellationToken = default)
{
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var session = await ResolveSessionAsync(cancellationToken);
if (session == 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 from = dispatch.Status;
var to = body.Status;
if (!IsAllowedVendorTransition(from, to))
try
{
return BadRequest(new Response { Status = "Error", Message = $"Transition from '{from}' to '{to}' is not allowed" });
var result = await _portalService.ChangeStatusAsync(session, id, body?.Status, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
}
var now = DateTime.UtcNow;
dispatch.Status = to;
dispatch.LastModificationTime = now;
if (to == "Completed") dispatch.CompletedDate = now;
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
catch (KeyNotFoundException)
{
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 } });
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}/request-cancel")]
public async Task<IActionResult> RequestCancel(int id, [FromBody] RequestCancelRequest body)
public async Task<IActionResult> RequestCancel(int id, [FromBody] RequestCancelRequest body, CancellationToken cancellationToken = default)
{
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var session = await ResolveSessionAsync(cancellationToken);
if (session == 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" });
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
try
{
return BadRequest(new Response { Status = "Error", Message = $"Cannot request cancel on a '{dispatch.Status}' dispatch" });
await _portalService.RequestCancelAsync(session, id, body?.Reason, cancellationToken);
return Ok(new DataResponse { Status = "Success", Message = "Cancel request sent to dispatcher" });
}
var now = DateTime.UtcNow;
var reason = string.IsNullOrWhiteSpace(body?.Reason) ? "(no reason provided)" : body!.Reason!.Trim();
_db.Comments.Add(new Comments
catch (KeyNotFoundException)
{
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 NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
}
catch (InvalidOperationException ex)
{
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" });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") });
}
}
[HttpPost("dispatches/{id:int}/checklist/{itemId:int}")]
public async Task<IActionResult> UpdateChecklistItem(int id, int itemId, [FromBody] ChecklistUpdateRequest body)
public async Task<IActionResult> UpdateChecklistItem(int id, int itemId, [FromBody] ChecklistUpdateRequest body, CancellationToken cancellationToken = default)
{
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var session = await ResolveSessionAsync(cancellationToken);
if (session == 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" });
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
try
{
return BadRequest(new Response { Status = "Error", Message = "Dispatch is locked" });
var result = await _portalService.UpdateChecklistItemAsync(session, id, itemId, body.IsCompleted, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
}
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
catch (KeyNotFoundException)
{
Status = "Success",
Data = new { item.Id, item.IsCompleted, item.CompletedBy, item.CompletedAt }
});
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}/signoff")]
public async Task<IActionResult> AddSignoff(int id, [FromBody] SignoffRequest body)
public async Task<IActionResult> AddSignoff(int id, [FromBody] SignoffRequest body, CancellationToken cancellationToken = default)
{
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var session = await ResolveSessionAsync(cancellationToken);
if (session == 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" });
if (dispatch.Status != "In Progress" && dispatch.Status != "Completed")
try
{
return BadRequest(new Response { Status = "Error", Message = "Signoffs only allowed on In Progress or Completed dispatches" });
var result = await _portalService.AddSignoffAsync(session, id, body?.SignoffType, body?.Name, body?.Signature, body?.SignatureMethod, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
}
if (string.IsNullOrWhiteSpace(body?.Name) || string.IsNullOrWhiteSpace(body?.Signature))
catch (KeyNotFoundException)
{
return BadRequest(new Response { Status = "Error", Message = "Name and signature are required" });
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
}
var signoffType = (body?.SignoffType ?? "vendor").Trim().ToLowerInvariant();
if (signoffType != "vendor" && signoffType != "customer")
catch (InvalidOperationException ex)
{
return BadRequest(new Response { Status = "Error", Message = "signoffType must be 'vendor' or 'customer'" });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") });
}
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)
public async Task<IActionResult> AddComment(int id, [FromBody] CommentRequest body, CancellationToken cancellationToken = default)
{
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var session = await ResolveSessionAsync(cancellationToken);
if (session == 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" });
if (string.IsNullOrWhiteSpace(body?.CommentText))
try
{
return BadRequest(new Response { Status = "Error", Message = "Comment cannot be empty" });
var result = await _portalService.AddCommentAsync(session, id, body?.CommentText, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
}
var now = DateTime.UtcNow;
var comment = new Comments
catch (KeyNotFoundException)
{
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 NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
}
catch (InvalidOperationException ex)
{
Status = "Success",
Data = new { comment.Id, comment.Commenttext, comment.Commenter, comment.CreatedDate }
});
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") });
}
}
[HttpPost("dispatches/{id:int}/uplift-request")]
public async Task<IActionResult> RequestUplift(int id, [FromBody] UpliftRequestBody body)
public async Task<IActionResult> RequestUplift(int id, [FromBody] UpliftRequestBody body, CancellationToken cancellationToken = default)
{
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var session = await ResolveSessionAsync(cancellationToken);
if (session == 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" });
if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled")
try
{
return BadRequest(new Response { Status = "Error", Message = $"Cannot request uplift on a '{dispatch.Status}' dispatch" });
var result = await _portalService.RequestUpliftAsync(session, id, body?.RequestedNTE ?? 0m, body?.Reason, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
}
if (body == null || body.RequestedNTE <= 0)
catch (KeyNotFoundException)
{
return BadRequest(new Response { Status = "Error", Message = "Requested NTE must be greater than zero" });
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
}
var current = dispatch.NTEAmount ?? 0m;
if (body.RequestedNTE <= current)
catch (InvalidOperationException ex)
{
return BadRequest(new Response { Status = "Error", Message = "Requested NTE must be greater than the current NTE" });
return BadRequest(new Response { Status = "Error", Message = _logger.Sanitize(ex, "The requested action could not be completed for this dispatch") });
}
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)
public async Task<IActionResult> CancelUpliftRequest(int id, int requestId, CancellationToken cancellationToken = default)
{
var vendor = await ResolveVendorAsync();
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
var session = await ResolveSessionAsync(cancellationToken);
if (session == 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 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);
var result = await _portalService.CancelUpliftRequestAsync(session, id, requestId, cancellationToken);
return Ok(new DataResponse { Status = "Success", Data = result });
}
catch
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 requested action could not be completed for this dispatch") });
}
}
private async Task<Vendor?> ResolveVendorAsync()
[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)
{
if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null;
var token = values.ToString();
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;
return await _portalService.ResolveSessionAsync(token, cancellationToken);
}
public class ChangeStatusRequest

View file

@ -0,0 +1,218 @@
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

@ -0,0 +1,139 @@
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

@ -0,0 +1,103 @@
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

@ -0,0 +1,214 @@
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

@ -0,0 +1,85 @@
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,6 +6,8 @@ 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

@ -0,0 +1,12 @@
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,6 +6,8 @@ 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,19 +3,34 @@ 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 bool IsActive { get; set; } = true;
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
public bool? IsActive { get; set; }
}
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

@ -0,0 +1,21 @@
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

@ -0,0 +1,70 @@
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

@ -0,0 +1,27 @@
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

@ -0,0 +1,20 @@
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,11 +1,12 @@

using SeaHaven.Services.Interfaces;
using SendGrid;
using SendGrid.Helpers.Mail;
namespace Api.SeaHavenIndustries.Helper
{
public class SendMessage
public class SendMessage : IEmailSender
{
private IConfiguration _configuration;
//string keysapi = "";
@ -58,6 +59,8 @@ 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

@ -0,0 +1,78 @@
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

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

View file

@ -0,0 +1,90 @@
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

@ -0,0 +1,108 @@
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,6 +13,7 @@ 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;
@ -29,6 +30,7 @@ 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
@ -42,7 +44,11 @@ builder.Services.AddCors(option =>
option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
builder.Services.AddScoped<SendMessage>();
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.VendorPortalTokenService>();
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.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp =>
{
var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance();
@ -51,9 +57,16 @@ 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();
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.Configure<WorkOrderJobsOptions>(
builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
@ -71,6 +84,8 @@ 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>();
@ -103,7 +118,9 @@ builder.Services.AddAuthentication(options =>
ValidateAudience = true,
ValidAudience = configuration["JWT:ValidAudience"],
ValidIssuer = configuration["JWT:ValidIssuer"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JWT:Secret"]))
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
configuration["JWT:Secret"]
?? throw new InvalidOperationException("JWT:Secret configuration is required")))
};
});
builder.Services.AddEndpointsApiExplorer();

View file

@ -14,6 +14,12 @@
// 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,6 +17,13 @@
// 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",
@ -41,4 +48,3 @@
},
"FrontendBaseUrl": "https://shoc.seahaven.com"
}

View file

@ -1,564 +1,106 @@
# Backend Architecture Guide
# Backend Architecture and Quality Rules
## 🏗️ Clean Architecture Overview
This repository uses a feature-oriented service boundary over Entity Framework Core:
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 │
└─────────────────────────────────────────────────────────────┘
```text
HTTP controller -> I{Feature}Service -> I{Feature}DataService -> ApplicationDbContext
```
---
## 📁 Project Structure
```
backend/shoc-backend/
│
├── Api.SeaHavenIndustries/ # 🌐 API Layer
│ ├── Controllers/ # HTTP endpoints
│ ├── DTOs/ # Data Transfer Objects (API contracts)
│ ├── Program.cs # App startup & DI registration
│ └── appsettings.json # Configuration
│
├── SeaHaven.Services/ # 💼 Service Layer (Business Logic)
│ ├── Interfaces/ # Service contracts (IContactService, etc.)
│ ├── Implementation/ # Service implementations
│ ├── DTOs/ # Internal DTOs for service layer
│ └── Validators/ # FluentValidation validators
│
├── SeaHaven.DataServices/ # 💾 Data Access Layer
│ ├── Interfaces/ # DataService contracts (IContactDataService, etc.)
│ ├── Implementation/ # DataService implementations
│ └── DTOs/ # Data layer DTOs
│
└── Data.SeaHavenIndustries/ # 🗄️ Data Layer (EF Core)
├── Models/ # Entity models (Contact, Employee, etc.)
├── ApplicationDbContext.cs # EF Core DbContext
└── Migrations/ # Database migrations
```
---
## 🔄 Data Flow Example (Creating a Contact)
### 1️⃣ **API Layer** (`ContactController.cs`)
```csharp
[HttpPost("Create")]
public async Task<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" });
// Convert API DTO → Service DTO
var serviceDto = dto.ToServiceCreateDTO();
// Call Service Layer
await _contactService.CreateContactAsync(serviceDto, userId);
return Ok(new Response { Status = "Success", Message = "Contact created" });
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Resource not found" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
}
}
```
**Responsibilities:**
- ✅ Authenticate user
- ✅ Extract user ID from claims
- ✅ Map API DTO to Service DTO
- ✅ Catch exceptions and return proper HTTP status codes
- ❌ NO business logic
- ❌ NO database access
---
### 2️⃣ **Service Layer** (`ContactService.cs`)
```csharp
public class ContactService : IContactService
{
private readonly IContactDataService _dataService;
private readonly IValidator<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
};
}
}
```
**Responsibilities:**
- ✅ Validate input using FluentValidation
- ✅ Apply business rules
- ✅ Orchestrate operations
- ✅ Transform data between layers
- ❌ NO HTTP concerns
- ❌ NO direct database access
---
### 3️⃣ **Data Service Layer** (`ContactDataService.cs`)
```csharp
public class ContactDataService : IContactDataService
{
private readonly ApplicationDbContext _db;
public ContactDataService(ApplicationDbContext db)
{
_db = db;
}
public async Task<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
};
// STEP 2: Add to database
_db.Contacts.Add(contact);
await _db.SaveChangesAsync();
// STEP 3: Map entity → Data DTO and return
return new ContactDataDTO
{
Id = contact.Id,
FirstName = contact.FirstName,
LastName = contact.LastName,
Email = contact.Email,
Phone = contact.Phone
};
}
}
```
**Responsibilities:**
- ✅ Direct database operations (CRUD)
- ✅ Entity mapping
- ✅ Set audit fields (CreatedBy, CreatedDate, etc.)
- ✅ Transaction management
- ❌ NO validation
- ❌ NO business logic
---
## 🔌 Dependency Injection (DI)
### Automatic Assembly Scanning
Services are **automatically registered** by scanning assemblies using naming convention.
**In `Program.cs`:**
```csharp
builder.Services.AddDataServices(); // Auto-register all Data Services
builder.Services.AddBusinessServices(); // Auto-register all Business Services + Validators
```
### How It Works
**Module Files:**
- `SeaHaven.DataServices/DependencyInjection/DataServicesModule.cs`
- `SeaHaven.Services/DependencyInjection/ServicesModule.cs`
**Process:**
1. Scans assembly for all classes
2. Finds interface matching pattern `I{ClassName}`
3. Registers as Scoped service
4. Also auto-registers FluentValidation validators
### Naming Convention Required
| Interface | Implementation | Registered? |
|-----------|---------------|-------------|
| `IContactService` | `ContactService` | ✅ YES |
| `IContactDataService` | `ContactDataService` | ✅ YES |
| `ISomething` | `SomethingDifferent` | ❌ NO |
**Rule**: Interface must be `I` + Class name exactly
### Usage in Code
Once registered, inject via constructor:
```csharp
public class ContactController : Controller
{
private readonly IContactService _contactService;
public ContactController(IContactService contactService)
{
_contactService = contactService;
}
}
```
### Benefits
✅ No manual registration - just follow naming convention
✅ Auto-discovery - new services work automatically
✅ FluentValidation auto-registered
---
## ✅ FluentValidation
### Validator Example (`CreateContactDTOValidator.cs`)
```csharp
public class CreateContactDTOValidator : AbstractValidator<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**.
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
### API controllers
- 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
- 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.
### Data services
- 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.
## Performance review
For each changed hot path, document and test:
- 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.
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²)`.
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
Configuration is bound once during composition in
`SeaHaven.Services/DependencyInjection/ServicesModule.cs`. Business services receive
typed options such as `FrontendOptions`, `JwtOptions`, `ApprovalsOptions`, and
`VendorPortalOptions`.
Defaults must preserve current behavior. Startup validation should be introduced only
when every deployed environment is known to provide the required value.
## Enforcement
`ArchitectureTests` enforces mechanical dependency rules:
- 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.
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.
## Review checklist
- 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,10 +45,22 @@ 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");
@ -70,6 +82,7 @@ namespace Data.SeaHavenIndustries
.IsUnique()
.HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''");
builder.Entity<Dispatch>()
.Property(d => d.RowVersion)
.IsRowVersion();
@ -79,6 +92,26 @@ 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; }
@ -98,7 +131,11 @@ 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; }
@ -169,7 +206,7 @@ namespace Data.SeaHavenIndustries
return bytes;
}
}
public class ApplicationUser : IdentityUser
{
@ -185,8 +222,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

@ -0,0 +1,14 @@
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

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

View file

@ -10,6 +10,24 @@ 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",
@ -43,6 +61,24 @@ 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);
}
}
}

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -0,0 +1,41 @@
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

@ -0,0 +1,185 @@
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");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,230 @@
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,6 +686,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateTime?>("AcknowledgedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("CommercialStatusUpdatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("CompletedDate")
.HasColumnType("datetime2");
@ -710,6 +713,19 @@ 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");
@ -725,6 +741,10 @@ 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)");
@ -1210,6 +1230,86 @@ 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")
@ -1589,6 +1689,65 @@ 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")
@ -1790,12 +1949,25 @@ 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)");
@ -1811,6 +1983,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<string>("GoogleMapsUrl")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
@ -1823,9 +1998,15 @@ 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)");
@ -1840,6 +2021,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.HasKey("Id");
b.HasIndex("CompanyId");
b.ToTable("Vendors", (string)null);
});
@ -1902,6 +2085,195 @@ 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")
@ -1980,7 +2352,8 @@ namespace Data.SeaHavenIndustries.Migrations
.HasColumnType("nvarchar(7)");
b.Property<string>("InternalWONumber")
.HasColumnType("nvarchar(max)");
.HasMaxLength(11)
.HasColumnType("nvarchar(11)");
b.Property<bool?>("IsDeleted")
.HasColumnType("bit");
@ -2074,7 +2447,8 @@ namespace Data.SeaHavenIndustries.Migrations
.HasColumnType("nvarchar(max)");
b.Property<string>("SiteCode")
.HasColumnType("nvarchar(max)");
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Source")
.HasColumnType("nvarchar(max)");
@ -2397,6 +2771,35 @@ 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")
@ -2824,6 +3227,25 @@ 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")
@ -2866,6 +3288,16 @@ 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")
@ -2877,6 +3309,44 @@ 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")
@ -3078,6 +3548,8 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Comments");
b.Navigation("CompletionDocuments");
b.Navigation("DispatchWorkOrders");
b.Navigation("Signoffs");
@ -3121,7 +3593,16 @@ 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,6 +19,12 @@ 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; }
@ -42,5 +48,6 @@ 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

@ -0,0 +1,15 @@
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

@ -0,0 +1,19 @@
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,3 +1,5 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Data.SeaHavenIndustries
{
public class Vendor : FullAuditEntity
@ -6,12 +8,27 @@ 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

@ -0,0 +1,18 @@
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

@ -0,0 +1,19 @@
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

@ -0,0 +1,31 @@
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

@ -0,0 +1,101 @@
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