mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 06:33:13 +00:00
Merge pull request #50 from Sea-Haven-Industries/feature/fix-phase-2
Fix Phase 2 audit findings: reliability, UX, and monitoring
This commit is contained in:
commit
a199b4675c
17 changed files with 397 additions and 47 deletions
|
|
@ -94,11 +94,8 @@ public class CurrentUserService : ICurrentUserService
|
|||
|
||||
private User GetOrThrow()
|
||||
{
|
||||
if (_cachedUser != null) return _cachedUser;
|
||||
|
||||
ResolveAsync().GetAwaiter().GetResult();
|
||||
|
||||
return _cachedUser
|
||||
?? throw new UnauthorizedAccessException("Could not resolve current user");
|
||||
?? throw new InvalidOperationException(
|
||||
"CurrentUserService.ResolveAsync() was not called. Ensure the authentication middleware runs before accessing user properties.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
using FluentValidation;
|
||||
using ProposalSystem.Application.DTOs;
|
||||
|
||||
namespace ProposalSystem.Application.Validators;
|
||||
|
||||
public class BulkUpdateLineItemsValidator : AbstractValidator<BulkUpdateLineItemsRequest>
|
||||
{
|
||||
public BulkUpdateLineItemsValidator()
|
||||
{
|
||||
RuleFor(x => x.LineItems).NotNull();
|
||||
|
||||
RuleForEach(x => x.LineItems).ChildRules(entry =>
|
||||
{
|
||||
entry.RuleFor(x => x.Description)
|
||||
.NotEmpty().WithMessage("Description is required")
|
||||
.MaximumLength(1000);
|
||||
|
||||
entry.RuleFor(x => x.Quantity)
|
||||
.GreaterThan(0).WithMessage("Quantity must be positive");
|
||||
|
||||
entry.RuleFor(x => x.Unit)
|
||||
.NotEmpty().WithMessage("Unit is required")
|
||||
.MaximumLength(50);
|
||||
|
||||
entry.RuleFor(x => x.TotalPrice)
|
||||
.GreaterThanOrEqualTo(0).WithMessage("Total price cannot be negative");
|
||||
|
||||
entry.RuleFor(x => x.UnitPrice)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.When(x => x.UnitPrice.HasValue)
|
||||
.WithMessage("Unit price cannot be negative");
|
||||
|
||||
entry.RuleFor(x => x.PricingMode).IsInEnum();
|
||||
entry.RuleFor(x => x.Source).IsInEnum();
|
||||
entry.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:15-alpine
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: proposalsystem
|
||||
POSTGRES_DB: proposals
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: localdev
|
||||
volumes:
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const compute = new ComputeStack(app, 'proposal-system-compute', {
|
|||
libraryBucket: foundation.libraryBucket,
|
||||
jobsQueue: foundation.jobsQueue,
|
||||
userPool: foundation.userPool,
|
||||
alarmTopic: foundation.alarmTopic,
|
||||
webClientId: foundation.webClientId,
|
||||
mobileClientId: foundation.mobileClientId,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,12 +7,15 @@ import * as apigatewayv2Integrations from 'aws-cdk-lib/aws-apigatewayv2-integrat
|
|||
import * as iam from 'aws-cdk-lib/aws-iam';
|
||||
import * as s3 from 'aws-cdk-lib/aws-s3';
|
||||
import * as sqs from 'aws-cdk-lib/aws-sqs';
|
||||
import * as sns from 'aws-cdk-lib/aws-sns';
|
||||
import * as cognito from 'aws-cdk-lib/aws-cognito';
|
||||
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
|
||||
import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources';
|
||||
import * as bedrock from 'aws-cdk-lib/aws-bedrock';
|
||||
import * as opensearchserverless from 'aws-cdk-lib/aws-opensearchserverless';
|
||||
import * as logs from 'aws-cdk-lib/aws-logs';
|
||||
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
|
||||
import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions';
|
||||
import * as cr from 'aws-cdk-lib/custom-resources';
|
||||
import { Construct } from 'constructs';
|
||||
|
||||
|
|
@ -25,6 +28,7 @@ export interface ComputeStackProps extends cdk.StackProps {
|
|||
libraryBucket: s3.IBucket;
|
||||
jobsQueue: sqs.IQueue;
|
||||
userPool: cognito.IUserPool;
|
||||
alarmTopic: sns.ITopic;
|
||||
webClientId: string;
|
||||
mobileClientId: string;
|
||||
}
|
||||
|
|
@ -441,6 +445,45 @@ export class ComputeStack extends cdk.Stack {
|
|||
],
|
||||
}));
|
||||
|
||||
// CloudWatch Alarms
|
||||
const alarmAction = new cloudwatchActions.SnsAction(props.alarmTopic);
|
||||
|
||||
const lambdaFunctions = [
|
||||
{ fn: apiFunction, name: 'api' },
|
||||
{ fn: suggestionsFunction, name: 'suggestions' },
|
||||
{ fn: pdfExtractFunction, name: 'pdf-extract' },
|
||||
{ fn: pdfGenerateFunction, name: 'pdf-generate' },
|
||||
{ fn: libraryIngestFunction, name: 'library-ingest' },
|
||||
];
|
||||
|
||||
for (const { fn, name } of lambdaFunctions) {
|
||||
const alarm = new cloudwatch.Alarm(this, `LambdaErrors-${name}`, {
|
||||
alarmName: `proposal-system-${name}-errors`,
|
||||
alarmDescription: `Lambda errors for ${name}`,
|
||||
metric: fn.metricErrors({ period: cdk.Duration.minutes(5) }),
|
||||
threshold: 1,
|
||||
evaluationPeriods: 1,
|
||||
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
|
||||
});
|
||||
alarm.addAlarmAction(alarmAction);
|
||||
}
|
||||
|
||||
const api5xxAlarm = new cloudwatch.Alarm(this, 'Api5xxAlarm', {
|
||||
alarmName: 'proposal-system-api-5xx',
|
||||
alarmDescription: 'API Gateway 5xx errors',
|
||||
metric: new cloudwatch.Metric({
|
||||
namespace: 'AWS/ApiGateway',
|
||||
metricName: '5xx',
|
||||
dimensionsMap: { ApiId: httpApi.httpApiId },
|
||||
statistic: 'Sum',
|
||||
period: cdk.Duration.minutes(5),
|
||||
}),
|
||||
threshold: 5,
|
||||
evaluationPeriods: 1,
|
||||
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
|
||||
});
|
||||
api5xxAlarm.addAlarmAction(alarmAction);
|
||||
|
||||
// Outputs
|
||||
new cdk.CfnOutput(this, 'ApiEndpoint', { value: httpApi.apiEndpoint });
|
||||
new cdk.CfnOutput(this, 'ApiFunctionArn', { value: apiFunction.functionArn });
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ import * as sqs from 'aws-cdk-lib/aws-sqs';
|
|||
import * as cognito from 'aws-cdk-lib/aws-cognito';
|
||||
import * as logs from 'aws-cdk-lib/aws-logs';
|
||||
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
|
||||
import * as sns from 'aws-cdk-lib/aws-sns';
|
||||
import * as snsSubscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
|
||||
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
|
||||
import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions';
|
||||
import { Construct } from 'constructs';
|
||||
|
||||
export class FoundationStack extends cdk.Stack {
|
||||
|
|
@ -17,6 +21,7 @@ export class FoundationStack extends cdk.Stack {
|
|||
public readonly libraryBucket: s3.IBucket;
|
||||
public readonly jobsQueue: sqs.IQueue;
|
||||
public readonly userPool: cognito.IUserPool;
|
||||
public readonly alarmTopic: sns.ITopic;
|
||||
public readonly webClientId: string;
|
||||
public readonly mobileClientId: string;
|
||||
|
||||
|
|
@ -257,8 +262,67 @@ export class FoundationStack extends cdk.Stack {
|
|||
},
|
||||
});
|
||||
|
||||
this.webClientId = webClient.userPoolClientId;
|
||||
this.mobileClientId = mobileClient.userPoolClientId;
|
||||
|
||||
// SNS Alarm Topic
|
||||
const alarmTopic = new sns.Topic(this, 'AlarmTopic', {
|
||||
topicName: 'proposal-system-alarms',
|
||||
displayName: 'Proposal System Alarms',
|
||||
});
|
||||
alarmTopic.addSubscription(
|
||||
new snsSubscriptions.EmailSubscription('adam@seahavenind.com'),
|
||||
);
|
||||
this.alarmTopic = alarmTopic;
|
||||
|
||||
const alarmAction = new cloudwatchActions.SnsAction(alarmTopic);
|
||||
|
||||
// DLQ Alarm: any message landing in DLQ indicates a processing failure
|
||||
const dlqAlarm = new cloudwatch.Alarm(this, 'DlqDepthAlarm', {
|
||||
alarmName: 'proposal-system-dlq-depth',
|
||||
alarmDescription: 'Messages in DLQ — SQS processing failures',
|
||||
metric: dlq.metricApproximateNumberOfMessagesVisible({
|
||||
period: cdk.Duration.minutes(1),
|
||||
}),
|
||||
threshold: 0,
|
||||
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
|
||||
evaluationPeriods: 1,
|
||||
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
|
||||
});
|
||||
dlqAlarm.addAlarmAction(alarmAction);
|
||||
|
||||
// RDS Alarms
|
||||
const rdsAlarms = [
|
||||
new cloudwatch.Alarm(this, 'RdsCpuAlarm', {
|
||||
alarmName: 'proposal-system-rds-cpu',
|
||||
alarmDescription: 'RDS CPU utilization above 80%',
|
||||
metric: dbInstance.metricCPUUtilization({ period: cdk.Duration.minutes(5) }),
|
||||
threshold: 80,
|
||||
evaluationPeriods: 3,
|
||||
treatMissingData: cloudwatch.TreatMissingData.BREACHING,
|
||||
}),
|
||||
new cloudwatch.Alarm(this, 'RdsConnectionsAlarm', {
|
||||
alarmName: 'proposal-system-rds-connections',
|
||||
alarmDescription: 'RDS database connections above 80',
|
||||
metric: dbInstance.metricDatabaseConnections({ period: cdk.Duration.minutes(5) }),
|
||||
threshold: 80,
|
||||
evaluationPeriods: 2,
|
||||
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
|
||||
}),
|
||||
new cloudwatch.Alarm(this, 'RdsFreeStorageAlarm', {
|
||||
alarmName: 'proposal-system-rds-free-storage',
|
||||
alarmDescription: 'RDS free storage below 2 GB',
|
||||
metric: dbInstance.metricFreeStorageSpace({ period: cdk.Duration.minutes(5) }),
|
||||
threshold: 2_000_000_000,
|
||||
comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD,
|
||||
evaluationPeriods: 1,
|
||||
treatMissingData: cloudwatch.TreatMissingData.BREACHING,
|
||||
}),
|
||||
];
|
||||
for (const alarm of rdsAlarms) {
|
||||
alarm.addAlarmAction(alarmAction);
|
||||
}
|
||||
|
||||
// CloudWatch Log Groups
|
||||
const logGroupNames = [
|
||||
'proposal-system-api',
|
||||
|
|
@ -286,5 +350,6 @@ export class FoundationStack extends cdk.Stack {
|
|||
new cdk.CfnOutput(this, 'DbSecretArn', { value: this.dbSecret.secretArn });
|
||||
new cdk.CfnOutput(this, 'WebClientId', { value: webClient.userPoolClientId });
|
||||
new cdk.CfnOutput(this, 'MobileClientId', { value: mobileClient.userPoolClientId });
|
||||
new cdk.CfnOutput(this, 'AlarmTopicArn', { value: alarmTopic.topicArn });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ then triggers a KB sync.
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import boto3
|
||||
|
|
@ -72,10 +73,10 @@ def process_ingestion(proposal_id: str):
|
|||
|
||||
def fetch_proposal(proposal_id: str) -> dict | None:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
resp = _retry_request(
|
||||
"GET",
|
||||
f"{API_BASE_URL}/api/proposals/{proposal_id}",
|
||||
headers=_api_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
|
|
@ -86,10 +87,10 @@ def fetch_proposal(proposal_id: str) -> dict | None:
|
|||
|
||||
def fetch_line_items(proposal_id: str) -> list[dict]:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
resp = _retry_request(
|
||||
"GET",
|
||||
f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items",
|
||||
headers=_api_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
|
|
@ -203,3 +204,22 @@ def _api_headers() -> dict:
|
|||
if api_key:
|
||||
headers["X-Internal-Api-Key"] = api_key
|
||||
return headers
|
||||
|
||||
|
||||
def _retry_request(
|
||||
method: str, url: str, *, max_retries: int = 3, **kwargs
|
||||
) -> httpx.Response:
|
||||
kwargs.setdefault("timeout", 10)
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
resp = httpx.request(method, url, **kwargs)
|
||||
if resp.status_code < 500:
|
||||
return resp
|
||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc:
|
||||
if attempt == max_retries - 1:
|
||||
raise
|
||||
logger.warning(
|
||||
"Retryable error (attempt %d/%d): %s", attempt + 1, max_retries, exc
|
||||
)
|
||||
time.sleep(min(2**attempt, 4))
|
||||
return resp # type: ignore[possibly-undefined]
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import json
|
|||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import boto3
|
||||
import httpx
|
||||
|
|
@ -304,7 +305,8 @@ def save_extraction(vendor_proposal_id: str, extracted: dict):
|
|||
}
|
||||
|
||||
try:
|
||||
resp = httpx.put(
|
||||
resp = _retry_request(
|
||||
"PUT",
|
||||
f"{API_BASE_URL}/api/vendor-proposals/{vendor_proposal_id}",
|
||||
json={
|
||||
"vendorName": extracted["vendorName"],
|
||||
|
|
@ -313,7 +315,6 @@ def save_extraction(vendor_proposal_id: str, extracted: dict):
|
|||
"processingStatus": "Complete",
|
||||
},
|
||||
headers=_api_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code not in (200, 204):
|
||||
logger.error(
|
||||
|
|
@ -327,11 +328,11 @@ def update_processing_status(vendor_proposal_id: str, status: str):
|
|||
if not vendor_proposal_id:
|
||||
return
|
||||
try:
|
||||
httpx.put(
|
||||
_retry_request(
|
||||
"PUT",
|
||||
f"{API_BASE_URL}/api/vendor-proposals/{vendor_proposal_id}/status",
|
||||
json={"processingStatus": status},
|
||||
headers=_api_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error updating status: %s", e)
|
||||
|
|
@ -343,3 +344,22 @@ def _api_headers() -> dict:
|
|||
if api_key:
|
||||
headers["X-Internal-Api-Key"] = api_key
|
||||
return headers
|
||||
|
||||
|
||||
def _retry_request(
|
||||
method: str, url: str, *, max_retries: int = 3, **kwargs
|
||||
) -> httpx.Response:
|
||||
kwargs.setdefault("timeout", 10)
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
resp = httpx.request(method, url, **kwargs)
|
||||
if resp.status_code < 500:
|
||||
return resp
|
||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc:
|
||||
if attempt == max_retries - 1:
|
||||
raise
|
||||
logger.warning(
|
||||
"Retryable error (attempt %d/%d): %s", attempt + 1, max_retries, exc
|
||||
)
|
||||
time.sleep(min(2**attempt, 4))
|
||||
return resp # type: ignore[possibly-undefined]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ Triggered via SQS when an admin requests PDF generation.
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
|
||||
|
|
@ -101,10 +102,10 @@ def generate_pdf(proposal_id: str):
|
|||
|
||||
def fetch_proposal(proposal_id: str) -> dict | None:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
resp = _retry_request(
|
||||
"GET",
|
||||
f"{API_BASE_URL}/api/proposals/{proposal_id}",
|
||||
headers=_api_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
|
|
@ -115,10 +116,10 @@ def fetch_proposal(proposal_id: str) -> dict | None:
|
|||
|
||||
def fetch_line_items(proposal_id: str) -> list[dict]:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
resp = _retry_request(
|
||||
"GET",
|
||||
f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items",
|
||||
headers=_api_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
|
|
@ -529,11 +530,11 @@ def upload_pdf(s3_key: str, pdf_bytes: bytes):
|
|||
|
||||
def register_pdf(proposal_id: str, s3_key: str):
|
||||
try:
|
||||
resp = httpx.post(
|
||||
resp = _retry_request(
|
||||
"POST",
|
||||
f"{API_BASE_URL}/api/generated-pdfs",
|
||||
json={"proposalId": proposal_id, "s3Key": s3_key},
|
||||
headers=_api_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code not in (200, 201):
|
||||
logger.error("Failed to register PDF: %s %s", resp.status_code, resp.text)
|
||||
|
|
@ -547,3 +548,22 @@ def _api_headers() -> dict:
|
|||
if api_key:
|
||||
headers["X-Internal-Api-Key"] = api_key
|
||||
return headers
|
||||
|
||||
|
||||
def _retry_request(
|
||||
method: str, url: str, *, max_retries: int = 3, **kwargs
|
||||
) -> httpx.Response:
|
||||
kwargs.setdefault("timeout", 10)
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
resp = httpx.request(method, url, **kwargs)
|
||||
if resp.status_code < 500:
|
||||
return resp
|
||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc:
|
||||
if attempt == max_retries - 1:
|
||||
raise
|
||||
logger.warning(
|
||||
"Retryable error (attempt %d/%d): %s", attempt + 1, max_retries, exc
|
||||
)
|
||||
time.sleep(min(2**attempt, 4))
|
||||
return resp # type: ignore[possibly-undefined]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ to generate line item suggestions for new proposals.
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
import boto3
|
||||
import httpx
|
||||
|
|
@ -77,10 +78,10 @@ def process_suggestion(proposal_id: str, trigger: str):
|
|||
|
||||
def fetch_line_items(proposal_id: str) -> list[dict]:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
resp = _retry_request(
|
||||
"GET",
|
||||
f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items",
|
||||
headers=_api_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
|
|
@ -91,10 +92,10 @@ def fetch_line_items(proposal_id: str) -> list[dict]:
|
|||
|
||||
def fetch_proposal(proposal_id: str) -> dict | None:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
resp = _retry_request(
|
||||
"GET",
|
||||
f"{API_BASE_URL}/api/proposals/{proposal_id}",
|
||||
headers=_api_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
|
|
@ -268,7 +269,8 @@ def post_line_items(proposal_id: str, items: list[dict], existing_items: list[di
|
|||
)
|
||||
|
||||
try:
|
||||
resp = httpx.put(
|
||||
resp = _retry_request(
|
||||
"PUT",
|
||||
f"{API_BASE_URL}/api/proposals/{proposal_id}/line-items",
|
||||
json={"lineItems": line_items_payload},
|
||||
headers=_api_headers(),
|
||||
|
|
@ -293,14 +295,14 @@ def store_similar_references(proposal_id: str, similar_proposals: list[dict]):
|
|||
continue
|
||||
|
||||
try:
|
||||
httpx.post(
|
||||
_retry_request(
|
||||
"POST",
|
||||
f"{API_BASE_URL}/api/proposals/{proposal_id}/similar-references",
|
||||
json={
|
||||
"referencedLibraryItemId": library_item_id,
|
||||
"similarityScore": sp["score"],
|
||||
},
|
||||
headers=_api_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error storing similar reference: %s", e)
|
||||
|
|
@ -308,11 +310,11 @@ def store_similar_references(proposal_id: str, similar_proposals: list[dict]):
|
|||
|
||||
def update_status_to_in_review(proposal_id: str):
|
||||
try:
|
||||
httpx.put(
|
||||
_retry_request(
|
||||
"PUT",
|
||||
f"{API_BASE_URL}/api/proposals/{proposal_id}",
|
||||
json={"status": "InReview"},
|
||||
headers=_api_headers(),
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error updating status: %s", e)
|
||||
|
|
@ -324,3 +326,22 @@ def _api_headers() -> dict:
|
|||
if api_key:
|
||||
headers["X-Internal-Api-Key"] = api_key
|
||||
return headers
|
||||
|
||||
|
||||
def _retry_request(
|
||||
method: str, url: str, *, max_retries: int = 3, **kwargs
|
||||
) -> httpx.Response:
|
||||
kwargs.setdefault("timeout", 10)
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
resp = httpx.request(method, url, **kwargs)
|
||||
if resp.status_code < 500:
|
||||
return resp
|
||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as exc:
|
||||
if attempt == max_retries - 1:
|
||||
raise
|
||||
logger.warning(
|
||||
"Retryable error (attempt %d/%d): %s", attempt + 1, max_retries, exc
|
||||
)
|
||||
time.sleep(min(2**attempt, 4))
|
||||
return resp # type: ignore[possibly-undefined]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import UIKit
|
|||
import React
|
||||
import React_RCTAppDelegate
|
||||
import ReactAppDependencyProvider
|
||||
import RCTLinking
|
||||
|
||||
@main
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
|
@ -31,6 +32,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
|
||||
return true
|
||||
}
|
||||
|
||||
func application(
|
||||
_ app: UIApplication,
|
||||
open url: URL,
|
||||
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
|
||||
) -> Bool {
|
||||
return RCTLinkingManager.application(app, open: url, options: options)
|
||||
}
|
||||
}
|
||||
|
||||
class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ import { theme } from './theme';
|
|||
import { queryClient } from './lib/queryClient';
|
||||
import { RootNavigator } from './navigation/RootNavigator';
|
||||
import { tokenStorage, userStorage } from './lib/storage';
|
||||
import { setUser, setLoading } from './store/slices/authSlice';
|
||||
import { setUser, setLoading, logout as logoutAction } from './store/slices/authSlice';
|
||||
import { processOfflineQueue } from './hooks/useOfflineDraft';
|
||||
import { proposalsApi, CreateProposalRequest } from './lib/api/proposals';
|
||||
import { onSessionExpired } from './lib/api/client';
|
||||
|
||||
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
|
||||
state = { error: null as Error | null };
|
||||
|
|
@ -56,6 +57,12 @@ function AuthBootstrap({ children }: { children: React.ReactNode }) {
|
|||
})();
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
return onSessionExpired(() => {
|
||||
dispatch(logoutAction());
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
||||
if (state.isConnected) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { authorize, refresh, revoke } from 'react-native-app-auth';
|
||||
import Config from '../../config';
|
||||
import apiClient from './client';
|
||||
import apiClient, { registerTokenRefresh } from './client';
|
||||
import { authenticateWithCredentials } from './cognito-auth';
|
||||
import {
|
||||
tokenStorage,
|
||||
|
|
@ -12,11 +12,14 @@ import {
|
|||
function parseUserFromIdToken(idToken: string): StoredUser {
|
||||
const base64Url = idToken.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const payload = JSON.parse(atob(base64));
|
||||
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
|
||||
const payload = JSON.parse(atob(padded));
|
||||
const groups: string[] = payload['cognito:groups'] || [];
|
||||
const role = groups.includes('admins') || groups.includes('sysadmins')
|
||||
? 'Admin'
|
||||
: 'Dispatcher';
|
||||
const role = groups.includes('sysadmins')
|
||||
? 'SysAdmin'
|
||||
: groups.includes('admins')
|
||||
? 'Admin'
|
||||
: 'Dispatcher';
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
|
|
@ -117,3 +120,5 @@ export const authApi = {
|
|||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
registerTokenRefresh(() => authApi.refreshTokens());
|
||||
|
|
|
|||
|
|
@ -2,6 +2,41 @@ import axios from 'axios';
|
|||
import Config from '../../config';
|
||||
import { tokenStorage } from '../storage';
|
||||
|
||||
type RefreshFn = () => Promise<void>;
|
||||
let _refreshTokens: RefreshFn | null = null;
|
||||
|
||||
export function registerTokenRefresh(fn: RefreshFn) {
|
||||
_refreshTokens = fn;
|
||||
}
|
||||
|
||||
type SessionExpiredListener = () => void;
|
||||
|
||||
const sessionExpiredListeners: SessionExpiredListener[] = [];
|
||||
|
||||
export function onSessionExpired(listener: SessionExpiredListener): () => void {
|
||||
sessionExpiredListeners.push(listener);
|
||||
return () => {
|
||||
const idx = sessionExpiredListeners.indexOf(listener);
|
||||
if (idx >= 0) sessionExpiredListeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
function emitSessionExpired() {
|
||||
sessionExpiredListeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
let isRefreshing = false;
|
||||
let refreshSubscribers: Array<(token: string) => void> = [];
|
||||
|
||||
function subscribeToRefresh(cb: (token: string) => void) {
|
||||
refreshSubscribers.push(cb);
|
||||
}
|
||||
|
||||
function onRefreshComplete(token: string) {
|
||||
refreshSubscribers.forEach((cb) => cb(token));
|
||||
refreshSubscribers = [];
|
||||
}
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: Config.API_URL,
|
||||
headers: {
|
||||
|
|
@ -23,15 +58,40 @@ apiClient.interceptors.request.use(
|
|||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
async (error) => {
|
||||
if (error.response) {
|
||||
const { status, data } = error.response;
|
||||
const { status, data, config: originalRequest } = error.response;
|
||||
|
||||
if (status === 401) {
|
||||
tokenStorage.clear();
|
||||
return Promise.reject(
|
||||
new Error('Session expired. Please log in again.'),
|
||||
);
|
||||
if (status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve) => {
|
||||
subscribeToRefresh((token) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
resolve(apiClient(originalRequest));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
try {
|
||||
if (!_refreshTokens) throw new Error('No refresh handler');
|
||||
await _refreshTokens();
|
||||
const tokens = await tokenStorage.get();
|
||||
const newToken = tokens?.accessToken ?? '';
|
||||
onRefreshComplete(newToken);
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
return apiClient(originalRequest);
|
||||
} catch {
|
||||
await tokenStorage.clear();
|
||||
emitSessionExpired();
|
||||
return Promise.reject(
|
||||
new Error('Session expired. Please log in again.'),
|
||||
);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,14 @@ export function usePaginatedList<T>(fetchFn: FetchFn<T>, extraParams: Record<str
|
|||
};
|
||||
}, [search]);
|
||||
|
||||
const prevExtraKey = useRef(extraKey);
|
||||
useEffect(() => {
|
||||
if (prevExtraKey.current !== extraKey) {
|
||||
prevExtraKey.current = extraKey;
|
||||
setPage(1);
|
||||
}
|
||||
}, [extraKey]);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate, useBlocker } from 'react-router-dom';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
Box,
|
||||
|
|
@ -81,6 +81,23 @@ export default function AdminWorkspace() {
|
|||
}
|
||||
}, [proposal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
window.addEventListener('beforeunload', handler);
|
||||
return () => window.removeEventListener('beforeunload', handler);
|
||||
}, [dirty]);
|
||||
|
||||
const blocker = useBlocker(
|
||||
useCallback(
|
||||
({ currentLocation, nextLocation }: { currentLocation: { pathname: string }; nextLocation: { pathname: string } }) =>
|
||||
dirty && currentLocation.pathname !== nextLocation.pathname,
|
||||
[dirty],
|
||||
),
|
||||
);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await adminApi.updateProposal(id!, { refinedScope });
|
||||
|
|
@ -387,6 +404,20 @@ export default function AdminWorkspace() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Unsaved Changes Navigation Guard */}
|
||||
<Dialog open={blocker.state === 'blocked'} onClose={() => blocker.reset?.()}>
|
||||
<DialogTitle>Unsaved Changes</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>You have unsaved changes. Are you sure you want to leave?</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => blocker.reset?.()}>Stay</Button>
|
||||
<Button variant="contained" color="error" onClick={() => blocker.proceed?.()}>
|
||||
Discard & Leave
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Approve Confirmation Dialog */}
|
||||
<Dialog open={approveDialogOpen} onClose={() => setApproveDialogOpen(false)}>
|
||||
<DialogTitle>Approve Proposal</DialogTitle>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Box, Button, Card, CardContent, Typography, Divider, Stack } from '@mui/material';
|
||||
import { Alert, Box, Button, Card, CardContent, Typography, Divider, Stack } from '@mui/material';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
||||
import EngineeringIcon from '@mui/icons-material/Engineering';
|
||||
|
|
@ -28,7 +28,7 @@ function buildLoginUrl(): string {
|
|||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { isAuthenticated, error } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const isDevMode = !CLIENT_ID;
|
||||
|
|
@ -103,6 +103,11 @@ export default function LoginPage() {
|
|||
</Typography>
|
||||
</Box>
|
||||
<CardContent sx={{ p: '30px', textAlign: 'center' }}>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2, textAlign: 'left' }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{isDevMode ? (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue