mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-01 19:13:13 +00:00
fix(work-orders): address document review feedback
This commit is contained in:
commit
3454125d2d
10 changed files with 410 additions and 8 deletions
250
.github/workflows/deploy.yml
vendored
250
.github/workflows/deploy.yml
vendored
|
|
@ -26,6 +26,9 @@ jobs:
|
||||||
dotnet-version: "8.0.x"
|
dotnet-version: "8.0.x"
|
||||||
|
|
||||||
- name: Repository quality gate
|
- name: Repository quality gate
|
||||||
|
env:
|
||||||
|
BASE_REF: ${{ github.event.pull_request.base.sha || 'origin/dev' }}
|
||||||
|
HEAD_REF: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
run: bash scripts/governance-check.sh
|
run: bash scripts/governance-check.sh
|
||||||
|
|
||||||
- name: Build Elastic Beanstalk source bundle
|
- name: Build Elastic Beanstalk source bundle
|
||||||
|
|
@ -141,6 +144,92 @@ jobs:
|
||||||
echo "Application version did not become PROCESSED." >&2
|
echo "Application version did not become PROCESSED." >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
|
- name: Discard blocking VCS run before GitHub CD
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python3 << 'PY'
|
||||||
|
import json, os, urllib.error, urllib.request
|
||||||
|
|
||||||
|
token = os.environ["TF_API_TOKEN"]
|
||||||
|
workspace = "shoc-backend-dev"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/vnd.api+json",
|
||||||
|
}
|
||||||
|
|
||||||
|
def get(url):
|
||||||
|
req = urllib.request.Request(url, headers=headers)
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return json.load(resp)
|
||||||
|
|
||||||
|
def post(url, payload):
|
||||||
|
data = json.dumps(payload).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, data=data, method="POST", headers=headers
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return resp.status
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code in (409, 404):
|
||||||
|
body = exc.read().decode("utf-8", "replace")
|
||||||
|
print(f"discard returned HTTP {exc.code}: {body}")
|
||||||
|
return exc.code
|
||||||
|
raise
|
||||||
|
|
||||||
|
ws = get(
|
||||||
|
f"https://app.terraform.io/api/v2/organizations/seahaven/workspaces/{workspace}"
|
||||||
|
)["data"]
|
||||||
|
attrs = ws["attributes"]
|
||||||
|
if attrs.get("auto-apply") is True:
|
||||||
|
raise SystemExit("shoc-backend-dev auto-apply is on; refuse to continue")
|
||||||
|
if not attrs.get("speculative-enabled"):
|
||||||
|
raise SystemExit("speculative plans are off; refuse to continue")
|
||||||
|
if (attrs.get("vcs-repo") or {}).get("tags-regex"):
|
||||||
|
raise SystemExit("tag-based VCS triggering is set; refuse to continue")
|
||||||
|
expected_patterns = [
|
||||||
|
"terraform/live/dev/**",
|
||||||
|
"terraform/live/modules/**",
|
||||||
|
]
|
||||||
|
if attrs.get("trigger-patterns") != expected_patterns:
|
||||||
|
raise SystemExit(
|
||||||
|
"trigger-patterns must be "
|
||||||
|
f"{expected_patterns}; got {attrs.get('trigger-patterns')}"
|
||||||
|
)
|
||||||
|
if not attrs.get("locked"):
|
||||||
|
print("workspace is unlocked")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
current = (
|
||||||
|
ws.get("relationships", {})
|
||||||
|
.get("current-run", {})
|
||||||
|
.get("data")
|
||||||
|
)
|
||||||
|
if not current:
|
||||||
|
raise SystemExit("workspace is locked without a current run")
|
||||||
|
run_id = current["id"]
|
||||||
|
run = get(f"https://app.terraform.io/api/v2/runs/{run_id}")["data"]
|
||||||
|
run_attrs = run["attributes"]
|
||||||
|
status = run_attrs.get("status")
|
||||||
|
plan_only = run_attrs.get("plan-only")
|
||||||
|
print(f"current run {run_id} status={status} plan-only={plan_only}")
|
||||||
|
if plan_only:
|
||||||
|
print("speculative run does not block GitHub CD")
|
||||||
|
raise SystemExit(0)
|
||||||
|
if status in {"applying", "apply_queued"}:
|
||||||
|
raise SystemExit(f"{run_id} is {status}; wait, do not discard an apply")
|
||||||
|
discardable = {
|
||||||
|
"pending", "planned", "cost_estimated", "policy_checked", "policy_override"
|
||||||
|
}
|
||||||
|
if status not in discardable:
|
||||||
|
raise SystemExit(f"{run_id} status {status} is not discardable")
|
||||||
|
code = post(
|
||||||
|
f"https://app.terraform.io/api/v2/runs/{run_id}/actions/discard",
|
||||||
|
{"comment": "Discarded so GitHub CD can create the version-only applyable run"},
|
||||||
|
)
|
||||||
|
print(f"discarded {run_id} http={code}")
|
||||||
|
PY
|
||||||
|
|
||||||
- name: Create Terraform release run
|
- name: Create Terraform release run
|
||||||
id: release-run
|
id: release-run
|
||||||
uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||||
|
|
@ -184,11 +273,44 @@ jobs:
|
||||||
|
|
||||||
- name: Apply Terraform release run
|
- name: Apply Terraform release run
|
||||||
id: release-apply
|
id: release-apply
|
||||||
|
continue-on-error: true
|
||||||
uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||||
with:
|
with:
|
||||||
run: ${{ steps.release-run.outputs.run_id }}
|
run: ${{ steps.release-run.outputs.run_id }}
|
||||||
comment: Apply version-only release from GitHub Actions ${{ github.sha }}
|
comment: Apply version-only release from GitHub Actions ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Treat already-applied release run as success
|
||||||
|
env:
|
||||||
|
APPLY_OUTCOME: ${{ steps.release-apply.outcome }}
|
||||||
|
RUN_ID: ${{ steps.release-run.outputs.run_id }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ "$APPLY_OUTCOME" = "success" ]; then
|
||||||
|
echo "Apply succeeded."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
python3 << 'PY'
|
||||||
|
import json, os, urllib.request
|
||||||
|
run_id = os.environ["RUN_ID"]
|
||||||
|
token = os.environ["TF_API_TOKEN"]
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"https://app.terraform.io/api/v2/runs/{run_id}",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/vnd.api+json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
status = json.load(resp)["data"]["attributes"]["status"]
|
||||||
|
print(f"HCP run {run_id} status={status}")
|
||||||
|
if status == "applied":
|
||||||
|
raise SystemExit(0)
|
||||||
|
raise SystemExit(
|
||||||
|
f"Apply failed: GitHub outcome={os.environ['APPLY_OUTCOME']} "
|
||||||
|
f"HCP status={status}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
|
||||||
- name: Verify exact application version is active
|
- name: Verify exact application version is active
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
@ -294,16 +416,103 @@ jobs:
|
||||||
echo "rollback_label=$prev" >> "${GITHUB_OUTPUT}"
|
echo "rollback_label=$prev" >> "${GITHUB_OUTPUT}"
|
||||||
id: rollback-prepare
|
id: rollback-prepare
|
||||||
|
|
||||||
|
- name: Discard blocking VCS run before GitHub rollback
|
||||||
|
id: rollback-discard-vcs
|
||||||
|
if: failure() && steps.rollback-prepare.outputs.rollback_label != ''
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python3 << 'PY'
|
||||||
|
import json, os, urllib.error, urllib.request
|
||||||
|
|
||||||
|
token = os.environ["TF_API_TOKEN"]
|
||||||
|
workspace = "shoc-backend-dev"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/vnd.api+json",
|
||||||
|
}
|
||||||
|
|
||||||
|
def get(url):
|
||||||
|
req = urllib.request.Request(url, headers=headers)
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return json.load(resp)
|
||||||
|
|
||||||
|
def post(url, payload):
|
||||||
|
data = json.dumps(payload).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, data=data, method="POST", headers=headers
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return resp.status
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
if exc.code in (409, 404):
|
||||||
|
body = exc.read().decode("utf-8", "replace")
|
||||||
|
print(f"discard returned HTTP {exc.code}: {body}")
|
||||||
|
return exc.code
|
||||||
|
raise
|
||||||
|
|
||||||
|
ws = get(
|
||||||
|
f"https://app.terraform.io/api/v2/organizations/seahaven/workspaces/{workspace}"
|
||||||
|
)["data"]
|
||||||
|
attrs = ws["attributes"]
|
||||||
|
if attrs.get("auto-apply") is True:
|
||||||
|
raise SystemExit("shoc-backend-dev auto-apply is on; refuse to continue")
|
||||||
|
if not attrs.get("speculative-enabled"):
|
||||||
|
raise SystemExit("speculative plans are off; refuse to continue")
|
||||||
|
if (attrs.get("vcs-repo") or {}).get("tags-regex"):
|
||||||
|
raise SystemExit("tag-based VCS triggering is set; refuse to continue")
|
||||||
|
expected_patterns = [
|
||||||
|
"terraform/live/dev/**",
|
||||||
|
"terraform/live/modules/**",
|
||||||
|
]
|
||||||
|
if attrs.get("trigger-patterns") != expected_patterns:
|
||||||
|
raise SystemExit(
|
||||||
|
"trigger-patterns must be "
|
||||||
|
f"{expected_patterns}; got {attrs.get('trigger-patterns')}"
|
||||||
|
)
|
||||||
|
if not attrs.get("locked"):
|
||||||
|
print("workspace is unlocked")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
current = (
|
||||||
|
ws.get("relationships", {})
|
||||||
|
.get("current-run", {})
|
||||||
|
.get("data")
|
||||||
|
)
|
||||||
|
if not current:
|
||||||
|
raise SystemExit("workspace is locked without a current run")
|
||||||
|
run_id = current["id"]
|
||||||
|
run = get(f"https://app.terraform.io/api/v2/runs/{run_id}")["data"]
|
||||||
|
run_attrs = run["attributes"]
|
||||||
|
status = run_attrs.get("status")
|
||||||
|
plan_only = run_attrs.get("plan-only")
|
||||||
|
print(f"current run {run_id} status={status} plan-only={plan_only}")
|
||||||
|
if plan_only:
|
||||||
|
print("speculative run does not block GitHub CD")
|
||||||
|
raise SystemExit(0)
|
||||||
|
if status in {"applying", "apply_queued"}:
|
||||||
|
raise SystemExit(f"{run_id} is {status}; wait, do not discard an apply")
|
||||||
|
discardable = {
|
||||||
|
"pending", "planned", "cost_estimated", "policy_checked", "policy_override"
|
||||||
|
}
|
||||||
|
if status not in discardable:
|
||||||
|
raise SystemExit(f"{run_id} status {status} is not discardable")
|
||||||
|
code = post(
|
||||||
|
f"https://app.terraform.io/api/v2/runs/{run_id}/actions/discard",
|
||||||
|
{"comment": "Discarded so GitHub CD can create the version-only applyable run"},
|
||||||
|
)
|
||||||
|
print(f"discarded {run_id} http={code}")
|
||||||
|
PY
|
||||||
|
|
||||||
- name: Create Terraform rollback run
|
- name: Create Terraform rollback run
|
||||||
id: rollback-run
|
id: rollback-run
|
||||||
if: failure() && steps.rollback-prepare.outputs.rollback_label != ''
|
if: failure() && steps.rollback-prepare.outputs.rollback_label != '' && steps.rollback-discard-vcs.outcome == 'success'
|
||||||
uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
uses: hashicorp/tfc-workflows-github/actions/create-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||||
env:
|
env:
|
||||||
TF_VAR_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_label }}"'
|
TF_VAR_release_version_label: '"${{ steps.rollback-prepare.outputs.rollback_label }}"'
|
||||||
with:
|
with:
|
||||||
workspace: shoc-backend-dev
|
workspace: shoc-backend-dev
|
||||||
message: "Rollback to ${{ steps.rollback-prepare.outputs.rollback_label }} from GitHub Actions"
|
message: "Rollback to ${{ steps.rollback-prepare.outputs.rollback_label }} from GitHub Actions"
|
||||||
|
|
||||||
- name: Read Terraform rollback plan counts
|
- name: Read Terraform rollback plan counts
|
||||||
id: rollback-plan
|
id: rollback-plan
|
||||||
if: failure() && steps.rollback-run.outcome == 'success'
|
if: failure() && steps.rollback-run.outcome == 'success'
|
||||||
|
|
@ -344,13 +553,48 @@ jobs:
|
||||||
- name: Apply Terraform rollback run
|
- name: Apply Terraform rollback run
|
||||||
id: rollback-apply
|
id: rollback-apply
|
||||||
if: failure() && steps.rollback-json-guard.outcome == 'success'
|
if: failure() && steps.rollback-json-guard.outcome == 'success'
|
||||||
|
continue-on-error: true
|
||||||
uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
uses: hashicorp/tfc-workflows-github/actions/apply-run@8e08d1ba957673f5fbf971a22b3219639dc45661 # v1.3.2
|
||||||
with:
|
with:
|
||||||
run: ${{ steps.rollback-run.outputs.run_id }}
|
run: ${{ steps.rollback-run.outputs.run_id }}
|
||||||
comment: Apply version-only rollback from GitHub Actions ${{ github.sha }}
|
comment: Apply version-only rollback from GitHub Actions ${{ github.sha }}
|
||||||
|
|
||||||
|
- name: Treat already-applied rollback run as success
|
||||||
|
id: rollback-apply-result
|
||||||
|
if: failure() && steps.rollback-apply.outcome != 'skipped'
|
||||||
|
env:
|
||||||
|
APPLY_OUTCOME: ${{ steps.rollback-apply.outcome }}
|
||||||
|
RUN_ID: ${{ steps.rollback-run.outputs.run_id }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ "$APPLY_OUTCOME" = "success" ]; then
|
||||||
|
echo "Apply succeeded."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
python3 << 'PY'
|
||||||
|
import json, os, urllib.request
|
||||||
|
run_id = os.environ["RUN_ID"]
|
||||||
|
token = os.environ["TF_API_TOKEN"]
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"https://app.terraform.io/api/v2/runs/{run_id}",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/vnd.api+json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
status = json.load(resp)["data"]["attributes"]["status"]
|
||||||
|
print(f"HCP run {run_id} status={status}")
|
||||||
|
if status == "applied":
|
||||||
|
raise SystemExit(0)
|
||||||
|
raise SystemExit(
|
||||||
|
f"Apply failed: GitHub outcome={os.environ['APPLY_OUTCOME']} "
|
||||||
|
f"HCP status={status}"
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
|
||||||
- name: Verify previous application version is active
|
- name: Verify previous application version is active
|
||||||
if: failure() && steps.rollback-apply.outcome == 'success'
|
if: failure() && steps.rollback-apply-result.outcome == 'success'
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
prev="${{ steps.rollback-prepare.outputs.rollback_label }}"
|
prev="${{ steps.rollback-prepare.outputs.rollback_label }}"
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
HasAvetaDocument = w.workOrderAttachments!.Any(a =>
|
HasAvetaDocument = w.workOrderAttachments!.Any(a =>
|
||||||
(a.IsDeleted == null || a.IsDeleted == false)
|
(a.IsDeleted == null || a.IsDeleted == false)
|
||||||
&& a.Category == WorkOrderMediaCategory.Aveta),
|
&& a.Category == WorkOrderMediaCategory.Aveta),
|
||||||
|
MediaCount = w.workOrderAttachments!.Count(a =>
|
||||||
|
a.IsDeleted == null || a.IsDeleted == false),
|
||||||
w.OriginalDate,
|
w.OriginalDate,
|
||||||
w.OriginalWeek,
|
w.OriginalWeek,
|
||||||
w.WorkOrderType,
|
w.WorkOrderType,
|
||||||
|
|
@ -157,7 +159,8 @@ namespace SeaHaven.DataServices.Helpers
|
||||||
w.OriginalDate,
|
w.OriginalDate,
|
||||||
w.OriginalWeek,
|
w.OriginalWeek,
|
||||||
isUnscheduled,
|
isUnscheduled,
|
||||||
w.Severity);
|
w.Severity,
|
||||||
|
w.MediaCount);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,8 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
DateOnly? OriginalDate,
|
DateOnly? OriginalDate,
|
||||||
DateOnly? OriginalWeek,
|
DateOnly? OriginalWeek,
|
||||||
bool IsUnscheduled,
|
bool IsUnscheduled,
|
||||||
string? Severity = null);
|
string? Severity = null,
|
||||||
|
int MediaCount = 0);
|
||||||
|
|
||||||
public record WorkOrderBoardQueryResult(
|
public record WorkOrderBoardQueryResult(
|
||||||
IReadOnlyList<WorkOrderBoardRawRow> ScheduledRows,
|
IReadOnlyList<WorkOrderBoardRawRow> ScheduledRows,
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,8 @@ namespace SeaHaven.Services.DTOs
|
||||||
public string? ServiceNotes { get; set; }
|
public string? ServiceNotes { get; set; }
|
||||||
public List<string>? ExtraServices { get; set; }
|
public List<string>? ExtraServices { get; set; }
|
||||||
public DocStatus? DocStatus { get; set; }
|
public DocStatus? DocStatus { get; set; }
|
||||||
|
/// <summary>Live photo/video/document count for the board CompDoc cell (excludes soft-deleted).</summary>
|
||||||
|
public int MediaCount { get; set; }
|
||||||
public DateTime? CompletedDate { get; set; }
|
public DateTime? CompletedDate { get; set; }
|
||||||
/// <summary>Board flag color (#RRGGBB). Null = no flag. Distinct from Color (dispatcher avatar).</summary>
|
/// <summary>Board flag color (#RRGGBB). Null = no flag. Distinct from Color (dispatcher avatar).</summary>
|
||||||
public string? FlagColor { get; set; }
|
public string? FlagColor { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ namespace SeaHaven.Services.Helpers
|
||||||
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
"image/png",
|
"image/png",
|
||||||
"video/mp4",
|
"video/mp4",
|
||||||
"video/quicktime",
|
"video/quicktime",
|
||||||
|
|
@ -31,6 +32,13 @@ namespace SeaHaven.Services.Helpers
|
||||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".docx" }
|
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".docx" }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static string CanonicalContentType(string contentType)
|
||||||
|
{
|
||||||
|
if (contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return "image/jpeg";
|
||||||
|
return contentType;
|
||||||
|
}
|
||||||
|
|
||||||
public static bool IsAllowed(
|
public static bool IsAllowed(
|
||||||
IFormFile file,
|
IFormFile file,
|
||||||
WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra)
|
WorkOrderMediaCategory? category = WorkOrderMediaCategory.Extra)
|
||||||
|
|
@ -38,10 +46,11 @@ namespace SeaHaven.Services.Helpers
|
||||||
if (file == null || file.Length <= 0)
|
if (file == null || file.Length <= 0)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var contentType = (file.ContentType ?? string.Empty).Trim();
|
var declaredType = (file.ContentType ?? string.Empty).Trim();
|
||||||
if (string.IsNullOrWhiteSpace(contentType) || !AllowedContentTypes.Contains(contentType))
|
if (string.IsNullOrWhiteSpace(declaredType) || !AllowedContentTypes.Contains(declaredType))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
var contentType = CanonicalContentType(declaredType);
|
||||||
var resolvedCategory = category ?? WorkOrderMediaCategory.Extra;
|
var resolvedCategory = category ?? WorkOrderMediaCategory.Extra;
|
||||||
if (IsDocument(contentType)
|
if (IsDocument(contentType)
|
||||||
&& resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta)
|
&& resolvedCategory is not WorkOrderMediaCategory.Extra and not WorkOrderMediaCategory.Aveta)
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,7 @@ namespace SeaHaven.Services.Implementation
|
||||||
ExtraServices = ParseExtraServices(row.ExtraServices),
|
ExtraServices = ParseExtraServices(row.ExtraServices),
|
||||||
AdditionalContacts = WorkOrderAdditionalContactsMapper.ParseJson(row.AdditionalContacts),
|
AdditionalContacts = WorkOrderAdditionalContactsMapper.ParseJson(row.AdditionalContacts),
|
||||||
DocStatus = row.DocStatus,
|
DocStatus = row.DocStatus,
|
||||||
|
MediaCount = row.MediaCount,
|
||||||
CompletedDate = row.CompletedDate,
|
CompletedDate = row.CompletedDate,
|
||||||
FlagColor = row.FlagColor,
|
FlagColor = row.FlagColor,
|
||||||
Severity = WorkOrderSeverityRules.ParseLevel(row.Severity),
|
Severity = WorkOrderSeverityRules.ParseLevel(row.Severity),
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,13 @@ namespace SeaHaven.Services.Implementation
|
||||||
|
|
||||||
if (category == WorkOrderMediaCategory.Before || category == WorkOrderMediaCategory.After)
|
if (category == WorkOrderMediaCategory.Before || category == WorkOrderMediaCategory.After)
|
||||||
{
|
{
|
||||||
|
if (IsDocumentAttachment(attachment.Attachments))
|
||||||
|
{
|
||||||
|
throw new WorkOrderBoardValidationException(
|
||||||
|
"UnsupportedMediaType",
|
||||||
|
"Documents can only be categorized as Extra or Aveta.");
|
||||||
|
}
|
||||||
|
|
||||||
var url = attachment.Attachments ?? "";
|
var url = attachment.Attachments ?? "";
|
||||||
if (category == WorkOrderMediaCategory.Before)
|
if (category == WorkOrderMediaCategory.Before)
|
||||||
workOrder.BeforPhotoAttachment = url;
|
workOrder.BeforPhotoAttachment = url;
|
||||||
|
|
@ -432,5 +439,11 @@ namespace SeaHaven.Services.Implementation
|
||||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
_ => "application/octet-stream"
|
_ => "application/octet-stream"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static bool IsDocumentAttachment(string? attachment)
|
||||||
|
=> GetContentType(GetSafeFileName(attachment ?? string.Empty)) is
|
||||||
|
"application/pdf"
|
||||||
|
or "application/msword"
|
||||||
|
or "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -895,6 +895,81 @@ public class WorkOrderBoardServiceTests
|
||||||
Assert.False(response.Scheduled[0].HasAvetaDocument);
|
Assert.False(response.Scheduled[0].HasAvetaDocument);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetBoardAsync_MediaCount_CountsLiveAttachmentsOnly()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var weekStart = new DateOnly(2026, 6, 22);
|
||||||
|
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
InternalWONumber = "10000000001",
|
||||||
|
WorkOrderType = WorkOrderType.Emergency,
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
|
});
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = 1,
|
||||||
|
Attachments = "https://example.com/before.jpg",
|
||||||
|
Category = WorkOrderMediaCategory.Before
|
||||||
|
});
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = 1,
|
||||||
|
Attachments = "https://example.com/extra.jpg",
|
||||||
|
Category = WorkOrderMediaCategory.Extra
|
||||||
|
});
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
WorkorderId = 1,
|
||||||
|
Attachments = "https://example.com/removed.jpg",
|
||||||
|
Category = WorkOrderMediaCategory.Extra,
|
||||||
|
IsDeleted = true
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var boardService = new WorkOrderBoardService(
|
||||||
|
new WorkOrderBoardDataService(context),
|
||||||
|
WorkOrderAccountTestHelpers.Resolver(context));
|
||||||
|
var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto
|
||||||
|
{
|
||||||
|
WeekStart = weekStart
|
||||||
|
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||||
|
|
||||||
|
Assert.Single(response.Scheduled);
|
||||||
|
Assert.Equal(2, response.Scheduled[0].MediaCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetBoardAsync_MediaCount_ZeroWithoutAttachments()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var weekStart = new DateOnly(2026, 6, 22);
|
||||||
|
|
||||||
|
context.workOrders.Add(new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
InternalWONumber = "10000000001",
|
||||||
|
WorkOrderType = WorkOrderType.Emergency,
|
||||||
|
ScheduledDate = new DateTime(2026, 6, 24, 12, 0, 0, DateTimeKind.Utc),
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var boardService = new WorkOrderBoardService(
|
||||||
|
new WorkOrderBoardDataService(context),
|
||||||
|
WorkOrderAccountTestHelpers.Resolver(context));
|
||||||
|
var response = await boardService.GetBoardAsync(new WorkOrderBoardQueryDto
|
||||||
|
{
|
||||||
|
WeekStart = weekStart
|
||||||
|
}, WorkOrderAccountTestHelpers.OrgWideAdmin(), null);
|
||||||
|
|
||||||
|
Assert.Single(response.Scheduled);
|
||||||
|
Assert.Equal(0, response.Scheduled[0].MediaCount);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetBoardAsync_InvalidWeekWindow_Throws()
|
public async Task GetBoardAsync_InvalidWeekWindow_Throws()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1428,6 +1428,44 @@ public class WorkOrderMediaServiceTests
|
||||||
a.FieldName == "MediaCategory" && a.NewValue == "10:Before");
|
a.FieldName == "MediaCategory" && a.NewValue == "10:Before");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://example.com/document.pdf")]
|
||||||
|
[InlineData("https://example.com/document.doc")]
|
||||||
|
[InlineData("https://example.com/document.docx")]
|
||||||
|
public async Task UpdateMediaCategory_DocumentToPhotoCategory_ThrowsUnsupportedMediaType(
|
||||||
|
string attachmentUrl)
|
||||||
|
{
|
||||||
|
var (context, service) = CreateSut();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||||
|
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||||
|
};
|
||||||
|
context.workOrders.Add(wo);
|
||||||
|
context.workOrderAttachments.Add(new WorkOrderAttachments
|
||||||
|
{
|
||||||
|
Id = 10,
|
||||||
|
WorkorderId = 1,
|
||||||
|
Attachments = attachmentUrl,
|
||||||
|
Category = WorkOrderMediaCategory.Extra
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||||
|
service.UpdateMediaCategoryAsync(
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
WorkOrderMediaCategory.Before,
|
||||||
|
ToVersion(wo),
|
||||||
|
AuthenticatedUser(),
|
||||||
|
"actor-1"));
|
||||||
|
|
||||||
|
Assert.Equal("UnsupportedMediaType", ex.Code);
|
||||||
|
Assert.Null(context.workOrders.Single().BeforPhotoAttachment);
|
||||||
|
Assert.True(context.workOrderAttachments.Single().IsDeleted != true);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateMediaCategory_StaleVersion_ThrowsConcurrencyConflict()
|
public async Task UpdateMediaCategory_StaleVersion_ThrowsConcurrencyConflict()
|
||||||
{
|
{
|
||||||
|
|
@ -1856,6 +1894,13 @@ public class WorkOrderMediaFileRulesTests
|
||||||
Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.jpg", "image/jpeg")));
|
Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.jpg", "image/jpeg")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsAllowed_ImageJpgAlias_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10 };
|
||||||
|
Assert.True(WorkOrderMediaFileRules.IsAllowed(FormFile(jpeg, "photo.jpg", "image/jpg")));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void IsAllowed_SpoofedExtension_ReturnsFalse()
|
public void IsAllowed_SpoofedExtension_ReturnsFalse()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,13 @@ leave the live version unchanged. Application-CD runs pass the immutable
|
||||||
workspace, in a variable set, or in `terraform.tfvars`. Do not upload a new
|
workspace, in a variable set, or in `terraform.tfvars`. Do not upload a new
|
||||||
configuration version on application releases; `create-run` reuses the
|
configuration version on application releases; `create-run` reuses the
|
||||||
workspace's last applied VCS config. Global auto-apply stays off. GitHub
|
workspace's last applied VCS config. Global auto-apply stays off. GitHub
|
||||||
applies only after `plan-output` counts are `0/1/0` and
|
`apply-run` treats an already-applied run as success so a mis-set auto-apply
|
||||||
|
cannot start a false-failure rollback. The workspace stays branch-based on
|
||||||
|
`dev` with Automatic Speculative Plans enabled and trigger patterns
|
||||||
|
`terraform/live/dev/**` and `terraform/live/modules/**`. GitHub discards a
|
||||||
|
leftover non-speculative VCS run before `create-run`, so a merge to `dev`
|
||||||
|
cannot lock the workspace out from under GitHub CD. GitHub applies only after
|
||||||
|
`plan-output` counts are `0/1/0` and
|
||||||
`scripts/check-terraform-release-plan.py` accepts a version-only plan JSON.
|
`scripts/check-terraform-release-plan.py` accepts a version-only plan JSON.
|
||||||
|
|
||||||
Staging keeps today's direct Elastic Beanstalk deploy path until staging
|
Staging keeps today's direct Elastic Beanstalk deploy path until staging
|
||||||
|
|
@ -181,5 +187,8 @@ identifiers make accidental cross-environment reuse fail review and planning.
|
||||||
## Safety invariants
|
## Safety invariants
|
||||||
|
|
||||||
- Auto-apply remains off.
|
- Auto-apply remains off.
|
||||||
|
- VCS stays branch-based on `dev` with speculative PR plans enabled and
|
||||||
|
trigger patterns `terraform/live/dev/**` and `terraform/live/modules/**`.
|
||||||
|
Do not switch Automatic Run Triggering to tag-based.
|
||||||
- Org baseline owns final HCP plan/apply permissions and manager tags.
|
- Org baseline owns final HCP plan/apply permissions and manager tags.
|
||||||
- Every imported Terraform resource has `prevent_destroy`.
|
- Every imported Terraform resource has `prevent_destroy`.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue