Compare commits

...

69 commits

Author SHA1 Message Date
dependabot[bot]
2bfe2cab53
Bump react-native-image-picker from 7.2.3 to 8.2.1 in /mobile
Bumps [react-native-image-picker](https://github.com/react-native-image-picker/react-native-image-picker) from 7.2.3 to 8.2.1.
- [Release notes](https://github.com/react-native-image-picker/react-native-image-picker/releases)
- [Commits](https://github.com/react-native-image-picker/react-native-image-picker/compare/v7.2.3...v8.2.1)

---
updated-dependencies:
- dependency-name: react-native-image-picker
  dependency-version: 8.2.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 04:35:46 +00:00
dependabot[bot]
257ea97613
Bump react and @types/react in /mobile (#65)
Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) and [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react). These dependencies needed to be updated together.

Updates `react` from 19.2.3 to 19.2.6
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.6/packages/react)

Updates `@types/react` from 19.2.14 to 19.2.15
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: "@types/react"
  dependency-version: 19.2.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 04:34:17 +00:00
dependabot[bot]
7b4481b194
Bump aws-cdk-lib from 2.253.1 to 2.257.0 in /infra (#58)
Bumps [aws-cdk-lib](https://github.com/aws/aws-cdk/tree/HEAD/packages/aws-cdk-lib) from 2.253.1 to 2.257.0.
- [Release notes](https://github.com/aws/aws-cdk/releases)
- [Changelog](https://github.com/aws/aws-cdk/blob/main/CHANGELOG.v2.alpha.md)
- [Commits](https://github.com/aws/aws-cdk/commits/v2.257.0/packages/aws-cdk-lib)

---
updated-dependencies:
- dependency-name: aws-cdk-lib
  dependency-version: 2.257.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 04:34:11 +00:00
dependabot[bot]
91b9386293
Bump aws-cdk from 2.1122.0 to 2.1124.1 in /infra (#63)
Bumps [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk) from 2.1122.0 to 2.1124.1.
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1124.1/packages/aws-cdk)

---
updated-dependencies:
- dependency-name: aws-cdk
  dependency-version: 2.1124.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 04:34:07 +00:00
dependabot[bot]
121a1f2dae
Bump @types/react from 19.2.14 to 19.2.15 in /web (#60)
Bumps [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) from 19.2.14 to 19.2.15.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-version: 19.2.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 04:34:05 +00:00
dependabot[bot]
9a6e0bfc44
Update boto3 requirement in /lambdas/library-ingest (#59)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.10...1.43.14)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.14
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 04:33:59 +00:00
dependabot[bot]
46a60279f7
Update boto3 requirement in /lambdas/pdf-generate (#57)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.10...1.43.14)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.14
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 04:33:56 +00:00
dependabot[bot]
ca77feada6
Bump react-native-safe-area-context from 5.7.0 to 5.8.0 in /mobile (#55)
Bumps [react-native-safe-area-context](https://github.com/AppAndFlow/react-native-safe-area-context) from 5.7.0 to 5.8.0.
- [Release notes](https://github.com/AppAndFlow/react-native-safe-area-context/releases)
- [Commits](https://github.com/AppAndFlow/react-native-safe-area-context/compare/v5.7.0...v5.8.0)

---
updated-dependencies:
- dependency-name: react-native-safe-area-context
  dependency-version: 5.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 04:33:50 +00:00
dependabot[bot]
c2627ae290
Bump @tanstack/react-query from 5.100.11 to 5.100.13 in /web (#53)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.100.11 to 5.100.13.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.100.13/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.100.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 04:33:48 +00:00
dependabot[bot]
e75999b99d
Update boto3 requirement in /lambdas/suggestions (#54)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.35.0...1.43.14)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.14
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 04:33:40 +00:00
dependabot[bot]
254c64d6e9
Update boto3 requirement in /lambdas/pdf-extract (#56)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.10...1.43.14)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.14
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 04:33:33 +00:00
dependabot[bot]
79ec029a0b
Bump react-native-app-auth from 8.2.0 to 8.3.0 in /mobile (#52)
Bumps [react-native-app-auth](https://github.com/FormidableLabs/react-native-app-auth) from 8.2.0 to 8.3.0.
- [Release notes](https://github.com/FormidableLabs/react-native-app-auth/releases)
- [Commits](https://github.com/FormidableLabs/react-native-app-auth/compare/react-native-app-auth@8.2.0...react-native-app-auth@8.3.0)

---
updated-dependencies:
- dependency-name: react-native-app-auth
  dependency-version: 8.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-23 04:33:28 +00:00
Adam Moussa
9d856a9619
Phase 3 audit fixes: FIX-01–47, accessibility NITs, code quality NITs [skip deploy]
Some checks failed
Deploy / Deploy to AWS (push) Has been cancelled
## Summary
Implements Phase 3 of the AUDIT-2026-05-20 findings:
- 29 FIX-severity items across API, web, infra, and lambdas
- 7 accessibility NITs (aria-labels, document titles)
- 4 code quality NITs (deduplication, constants extraction)

Key changes:
- API: N+1 fix, pagination clamping, idempotent transitions, upload confirm endpoint, revision TotalBidAmount carry-forward
- Web: confirmation dialogs, currency formatting, error states, date range filters, document titles
- Infra: S3 CORS lockdown, API Gateway throttling, AOSS network policy fix, CI concurrency
- Lambdas: skip empty suggestions, remove status side-effect
- Scripts: post-deploy health check

## Test plan
- [x] tsc --noEmit (web + infra)
- [x] dotnet build (api)
- [x] ruff check + format (lambdas)
- [x] Cross-review via orchestrator (no blockers)

[skip deploy]
2026-05-20 19:38:36 -04:00
Adam Moussa
a199b4675c
Merge pull request #50 from Sea-Haven-Industries/feature/fix-phase-2
Some checks failed
Deploy / Deploy to AWS (push) Waiting to run
Deploy Mobile (iOS) / Build & Upload to TestFlight (push) Has been cancelled
Fix Phase 2 audit findings: reliability, UX, and monitoring
2026-05-20 19:09:44 -04:00
Adam Moussa
99e0c16505 Merge main into feature/fix-phase-2, resolve infra conflicts
Keep both Phase 1 (JWT authorizer, webClientId/mobileClientId props) and
Phase 2 (alarmTopic, CloudWatch alarms) changes in CDK stacks.
2026-05-20 19:09:35 -04:00
Adam Moussa
184bc1da7e Fix Phase 2 audit findings: reliability, UX, and operational monitoring
BLOCK-10: Add CloudWatch alarms (DLQ, Lambda errors, RDS, API 5xx) with SNS email
BLOCK-11: Remove sync-over-async deadlock in CurrentUserService
BLOCK-12: Add AppDelegate OAuth URL callback handler for mobile
BLOCK-13: Wire mobile 401 interceptor to dispatch Redux logout
BLOCK-14: Fix JWT base64 padding crash and SysAdmin role detection
BLOCK-15: Reset pagination to page 1 on filter change
BLOCK-16: Add unsaved-changes guard (beforeunload + useBlocker) to AdminWorkspace
FIX-08: Add BulkUpdateLineItems FluentValidation validator
FIX-13: Display auth errors on LoginPage
FIX-25: Add token refresh with retry queue to mobile API client
FIX-44: Add httpx retry logic to all Lambda handlers
FIX-42/43: Align docker-compose PG version (15) and DB name (proposals) with RDS
2026-05-20 19:07:49 -04:00
Adam Moussa
091c5fcb44
Fix Phase 1 security and data integrity audit findings (#49)
BLOCK-01: Add API Gateway JWT authorizer with Cognito, route internal
Lambda calls through Function URL to bypass gateway auth
BLOCK-02/03: Prevent proposal number race condition with pg_advisory_xact_lock
and filter revision numbers from max-number query
BLOCK-04: Restrict VendorProposals and GeneratedPdfs to admins/sysadmins
BLOCK-05: Sum all vendor costs instead of overwriting with single vendor
BLOCK-06: Enable ValidateAudience on JWT, add Auth env vars to API Lambda
BLOCK-07: Validate ID token signature in AuthController via OIDC discovery
BLOCK-08: Use batchItemFailures in all Lambda SQS handlers
BLOCK-09: Increase SQS visibility timeout from 180s to 720s
FIX-10: Scope dispatcher queries to own proposals (IDOR fix)
2026-05-20 18:51:31 -04:00
Adam Moussa
dfbd0562ee
Merge pull request #48 from Sea-Haven-Industries/mobile/fix-react-version-and-ui
Fix React version, mobile auth, web/API bugs, and admin UX hardening
2026-05-20 18:29:15 -04:00
Adam Moussa
a978fdd292 Update retrospective with session 5: automated QA and 18-bug fix sweep
Documents the parallel 4-agent QA run (~145 test cases), the 18 bugs
found, and all fixes applied in the preceding 4 commits. Adds session 5
changelog and QA coverage summary table.
2026-05-20 18:10:02 -04:00
Adam Moussa
5e89e42e6a Extract shared constants and format utils, wire admin dashboard filters
Move STATUS_COLORS and PRIORITY_COLORS to constants/index.ts and
formatCurrency/formatDate/formatDateTime to lib/format.ts — previously
duplicated across 5 and 4 files respectively.

AdminDashboard: Wire Category and Priority filter dropdowns to
usePaginatedList extraParams (were no-op onChange handlers).

ProposalDetailPage: Add 'Revised' to STATUS_ORDER so the stepper
renders correctly for revised proposals.
2026-05-20 18:09:11 -04:00
Adam Moussa
4d72b63547 Add frontend role guards, fix dashboard data exposure, and harden UX
RoleGuard: New component wrapping admin routes — dispatchers navigating
to /admin/* by URL now redirect to / instead of seeing error states.

Dashboard: Add mine=true filter so dispatchers only see their own
proposals and stats, not all users' data.

AdminWorkspace: Auto-save dirty changes before approving so edits to
refined scope and line items aren't silently discarded.

ProposalFormPage: Add onError toast and 300ms debounce on customer
search (was firing an API call per keystroke).

admin.ts: Stop swallowing errors in getPdf — let them propagate to the
mutation's onError handler. Fix AuditEntry.details type to string|null.

LoginPage: Fix pre-existing TS error with noUncheckedIndexedAccess.
2026-05-20 18:09:04 -04:00
Adam Moussa
866601025d Validate dev-login input: reject empty email and invalid role
Empty-string email passed model binding but created a ghost user with no
identity. Invalid role strings (e.g. "SuperHero") silently defaulted to
Admin, granting unintended elevated access.

Now returns 400 for both cases. Default role changed from Admin to
Dispatcher (least privilege).
2026-05-20 18:08:55 -04:00
Adam Moussa
d00c552497 Fix admin dashboard LINQ crash, revise unique constraint, and mutation response data
AdminController: Rewrite avgTurnaround query to fetch approved times to
memory before computing TotalHours — EF Core/Npgsql cannot translate
TimeSpan.TotalHours to SQL, causing a 409 on every dashboard load.

ProposalService.ReviseAsync: Append -R{n} suffix to revision's
ProposalNumber so it doesn't violate the unique index. Previously copied
the parent's number verbatim, causing a DbUpdateException (500).

ProposalService Update/Approve/MarkSent: Add .Include(p => p.SubmittedBy)
(and ApprovedBy where relevant) so MapToResponse returns submittedByName
instead of null. GetByIdAsync already had these includes.
2026-05-20 18:08:50 -04:00
Adam Moussa
0b1af71166 Add retrospective covering mobile and web testing sessions
Documents findings from all four sessions (2026-05-18 through 2026-05-20):
mobile CI/CD, device testing, and web dev-mode testing. Includes
standards compliance audit, lessons learned, and prioritized follow-ups.
2026-05-20 17:30:48 -04:00
Adam Moussa
2645d970ed Fix customer name not binding from Autocomplete free text input
The freeSolo Autocomplete only updated customerName on dropdown
selection, not on typed input. Update form state on onInputChange
so validation passes when typing a new customer name.
2026-05-20 17:24:14 -04:00
Adam Moussa
9b97b7b578 Fix proposal workflow: scoped My Proposals, idempotent state transitions
Add Mine filter to ProposalFilterRequest so My Proposals page shows
only the current user's submissions regardless of role. Create
proposals directly as InReview (skip Draft) since form submission is
the review request. Make Approve, MarkSent, and Revise idempotent —
repeat calls return current state instead of throwing. Wrap line item
audit logging in try/catch so audit failures don't mask successful
saves.
2026-05-20 17:24:09 -04:00
Adam Moussa
f37c2aa3f0 Fix dev-login role switching, distinct users, and user resolution races
Dev-login now updates the role when an existing user logs in as a
different role. Each dev role maps to a distinct email/name so
sessions don't collide. CognitoSub is deterministic (email-based)
to prevent mismatch between dev-login and CurrentUserService.
CurrentUserService catches DbUpdateException on concurrent user
creation and retries the lookup instead of crashing.
2026-05-20 17:24:01 -04:00
Adam Moussa
f44caba906 Fix JSON enum serialization and audit log jsonb format
Add JsonStringEnumConverter so string enum values (ServiceCategory,
Priority, PricingMode, LineItemSource) deserialize correctly from
frontend requests. Wrap AuditService detail strings in a JSON object
to satisfy the Postgres jsonb column type. Relax global.json SDK
version to match installed 8.0.1xx feature band.
2026-05-20 17:23:55 -04:00
Adam Moussa
686b42330c Fix React version mismatch, auth fallback, and UI polish
Pin React to 19.2.3 (exact version RN 0.85.3's bundled renderer
requires — 19.2.6 caused "Cannot read property 'default' of
undefined" crash at getPaperRenderer).

Auth: fall back to parsing user info from Cognito ID token when
backend API is unreachable, removing the hard dependency on
localhost:5000 for credential login.

UI fixes:
- Safe area insets on Settings screen (top edge was missing)
- Separate refresh indicator from filter-triggered loading on
  proposal queue (chips no longer trigger pull-to-refresh animation)
- Dashboard welcome shows first name instead of full email
- Dashboard/AdminDashboard remove duplicate top safe area when
  navigation header already provides it
- Service category selector uses wrapping chips instead of
  cramped SegmentedButtons
- Add ErrorBoundary to App root for crash visibility
2026-05-20 13:34:08 -04:00
Adam Moussa
bd9fbb4da5 Fix launch crash: lazy-load amazon-cognito-identity-js
Some checks are pending
Deploy Mobile (iOS) / Build & Upload to TestFlight (push) Waiting to run
Deploy / Deploy to AWS (push) Waiting to run
The library's module graph eagerly initializes native module bindings
(RNAWSCognito) during import, causing a TypeError on startup since the
native pod isn't linked. Dynamic import defers loading until the user
actually taps "Sign In" with credentials, keeping it off the critical
startup path entirely.
2026-05-20 12:14:29 -04:00
Adam Moussa
a9c157dc95 Fix iOS 26 launch crash: patch netinfo removed CoreTelephony APIs
netinfo v12.0.1 calls subscriberCellularProvider and
currentRadioAccessTechnology which were removed in iOS 26. No newer
version exists. Add patch-package with respondsToSelector guards so
the app gracefully returns nil for carrier info and skips cellular
generation detection on iOS 26+ instead of crashing on launch.
2026-05-20 11:52:43 -04:00
Adam Moussa
da00d27049 Add email/password login, fix Cognito config, enable mobile auto-deploy
Apple review requires a test account login path that doesn't depend on
Google OAuth. Add amazon-cognito-identity-js for direct SRP auth with a
native email/password form on the login screen. Fill in the empty Cognito
client ID and pool ID, fix the Cognito domain prefix, and align CDK
callback URLs with the app's actual URL scheme. Enable push-triggered
mobile deploys, add CDK outputs for client IDs, fix stale README
references, and add mobile/README.md.
2026-05-20 11:36:51 -04:00
Adam Moussa
a9b720ee08 Fix launch crash on iOS 26: update netinfo and add error handling
Crash log showed RCTFatal from an unhandled promise rejection during
startup. The NetInfo listener fires immediately and processOfflineQueue
had no .catch() — fatal in production RN.

Also updated @react-native-community/netinfo from 11.x to 12.x because
11.x uses subscriberCellularProvider (deprecated iOS 12, likely removed
in iOS 26).
2026-05-19 19:54:13 -04:00
Adam Moussa
042339948c Add app icons and CFBundleIconName for TestFlight validation
Some checks are pending
Deploy / Deploy to AWS (push) Waiting to run
App Store Connect rejected the upload because:
- Missing 120x120px app icon (and other required sizes)
- Missing CFBundleIconName in Info.plist

Added placeholder solid-color icons at all required sizes.
These should be replaced with the actual Sea Haven logo.
2026-05-19 19:32:38 -04:00
Adam Moussa
fa93870a99 Fix RN bundle phase: align metro-config and babel-preset with RN 0.85
The "Bundle React Native code and images" Xcode build phase failed
because @react-native/metro-config and @react-native/babel-preset were
pinned to 0.79.x while react-native is 0.85.3. The 0.79 metro-config
API is incompatible with the 0.85 bundler script.

Also fixes xcodebuild_formatter from "" to "cat" to avoid empty pipe.
2026-05-19 19:21:33 -04:00
Adam Moussa
ddb1e52e68 Fix .xcode.env.local path: Fastlane runs from fastlane/ not mobile/ 2026-05-19 18:57:05 -04:00
Adam Moussa
8b997b9086 Fix NODE_BINARY for Xcode build phases in CI
Write .xcode.env.local with explicit node path so the "Bundle React
Native code and images" build phase can find node. Xcode build phases
don't inherit the GitHub Actions PATH. Also disable xcbeautify for
this run to see raw xcodebuild output for debugging.
2026-05-19 18:54:42 -04:00
Adam Moussa
0326909e51 Add verbose match output and keychain diagnostics for signing issue
Certificate installs to keychain but security find-identity shows
no signing identities. Added verbose match, explicit keychain params,
setup_ci force, profile_name in update_code_signing_settings, and
diagnostic security find-identity commands to diagnose the import.
2026-05-19 18:47:09 -04:00
Adam Moussa
69f582f0b3 Configure manual code signing for CI builds
Add update_code_signing_settings to disable automatic signing and
set development team (9KAQYC653W) + Apple Distribution identity.
Fixes Xcode error "Signing requires a development team" in CI.
2026-05-19 18:43:53 -04:00
Adam Moussa
a1f2e22562 Add headerless-body and DER-wrap strategies to ASC key normalizer
The .p8 file from Apple has no PEM headers, just the raw base64
body. Add strategies for: headerless body wrapped with PEM headers,
base64-decoded DER re-wrapped as PEM, and double-decoded DER. Also
show hex bytes in diagnostics for better binary data analysis.
Secret has been re-set with properly PEM-wrapped + base64-encoded
content matching the reusable workflow's expected format.
2026-05-19 18:40:29 -04:00
Adam Moussa
279cd6c94a Robust ASC key normalization with diagnostics for TestFlight deploy
Replace fragile BEGIN/base64 branch with multi-strategy key parser
that handles raw PEM, escaped newlines, base64-encoded PEM, mangled
line wrapping, CR/LF issues, and double-encoding. Validates key with
OpenSSL::PKey.read before passing to Fastlane via key_filepath (temp
file) instead of key_content to bypass Fastlane's own parsing. Prints
safe diagnostics (no key material) if all strategies fail.
2026-05-19 18:32:46 -04:00
Adam Moussa
9cd12ceb79 Fix ASC key format detection: handle both raw PEM and base64
The secret may contain either raw PEM text (with BEGIN header) or
base64-encoded PEM. Detect format and pass appropriately to fastlane
instead of blindly base64-decoding (which corrupts raw PEM content).
2026-05-18 19:30:56 -04:00
Adam Moussa
a0ccf676b8 Use prepend to fix OpenSSL::PKey::EC.new on OpenSSL 3.x
alias_method doesn't reliably wrap C-extension class methods. Switch to
singleton_class.prepend which correctly intercepts the call chain. Falls
back to OpenSSL::PKey.read when EC.new raises on PKCS#8 format keys.
2026-05-18 19:27:26 -04:00
Adam Moussa
e355809b58 Fix OpenSSL 3.x EC key parsing in Fastfile
Fastlane 2.234.0 uses OpenSSL::PKey::EC.new which fails with "invalid
curve name" on PKCS#8 keys under OpenSSL 3.x. Add monkey-patch to fall
back to OpenSSL::PKey.read which handles both formats.
2026-05-18 19:22:46 -04:00
Adam Moussa
efa77c6ce7 Fix ASC key parsing: decode base64 before passing to Fastlane
Some checks are pending
Deploy / Deploy to AWS (push) Waiting to run
The app_store_connect_api_key action fails with "invalid curve name"
when is_key_content_base64 is true on macOS runners with OpenSSL 3.x.
Decoding the key manually and passing the raw PEM content avoids the
OpenSSL incompatibility. Also reverts the Fastlane version pin since
2.235.0 doesn't exist.
2026-05-18 18:52:15 -04:00
Adam Moussa
a4c78048ed Bump Fastlane >= 2.235.0 to fix OpenSSL curve name error
Fastlane 2.234.0 fails with "invalid curve name" on macos-latest
runners due to an OpenSSL 3.x incompatibility in the ASC API key
parsing. Fixed in 2.235.0. Removed lockfile so CI regenerates it
with the correct Ruby/bundler version.
2026-05-18 18:49:28 -04:00
Adam Moussa
68f77a6c0a Add missing RN CLI deps, exclude mobile from AWS deploy
react-native 0.79 requires @react-native-community/cli as an
explicit dev dependency for CocoaPods autolinking. Also adds
paths-ignore for mobile/ on the AWS deploy workflow so mobile-only
changes don't trigger unnecessary infrastructure deploys.
2026-05-18 18:42:36 -04:00
dependabot[bot]
ed6aed9aa7
Bump react-native from 0.79.7 to 0.85.3 in /mobile (#35)
Bumps [react-native](https://github.com/facebook/react-native/tree/HEAD/packages/react-native) from 0.79.7 to 0.85.3.
- [Release notes](https://github.com/facebook/react-native/releases)
- [Changelog](https://github.com/facebook/react-native/blob/main/CHANGELOG-0.7x.md)
- [Commits](https://github.com/facebook/react-native/commits/v0.85.3/packages/react-native)

---
updated-dependencies:
- dependency-name: react-native
  dependency-version: 0.85.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 22:40:56 +00:00
Adam Moussa
e64da7ecd7 Revert babel-preset to 0.79 to match React Native version
Dependabot bumped @react-native/babel-preset from 0.79 to 0.85,
which is incompatible with react-native 0.79. The 0.85 preset
expects CLI infrastructure that doesn't exist in 0.79, breaking
pod install during the mobile deploy.
2026-05-18 18:38:03 -04:00
Adam Moussa
e96c80c78a Add OIDC permissions to mobile deploy workflow
Startup failure — caller workflow needs id-token: write for the
reusable workflow's OIDC credential step to function.
2026-05-18 18:35:00 -04:00
Adam Moussa
28475d8529 Revert suggestions log group from foundation stack
The log group already exists — created by the compute stack's
logRetention setting on the suggestions Lambda. Adding it to the
foundation stack caused a duplicate resource error on deploy.
2026-05-18 18:32:04 -04:00
Adam Moussa
fdaaf5ed4a Fix compliance violations: Lambda defaults, CI node-version, dead code
oss-index-creator Lambda was missing functionName, arm64 architecture,
and explicit log retention — all required by the engineering handbook.
CI workflow was not passing node-version to reusable workflows, risking
drift. Removed unused _api_request helper from all four main Lambdas.
Added missing suggestions log group to foundation stack.
2026-05-18 18:28:31 -04:00
dependabot[bot]
69c989847f
Update requests-aws4auth requirement in /lambdas/oss-index-creator (#41)
Updates the requirements on [requests-aws4auth](https://github.com/tedder/requests-aws4auth) to permit the latest version.
- [Release notes](https://github.com/tedder/requests-aws4auth/releases)
- [Changelog](https://github.com/tedder/requests-aws4auth/blob/main/HISTORY.md)
- [Commits](https://github.com/tedder/requests-aws4auth/compare/v1.2.0...v1.3.2)

---
updated-dependencies:
- dependency-name: requests-aws4auth
  dependency-version: 1.3.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 22:14:39 +00:00
dependabot[bot]
d38049a6ca
Update httpx requirement in /lambdas/suggestions (#46)
Updates the requirements on [httpx](https://github.com/encode/httpx) to permit the latest version.
- [Release notes](https://github.com/encode/httpx/releases)
- [Changelog](https://github.com/encode/httpx/blob/master/CHANGELOG.md)
- [Commits](https://github.com/encode/httpx/compare/0.27.0...0.28.1)

---
updated-dependencies:
- dependency-name: httpx
  dependency-version: 0.28.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 22:13:16 +00:00
dependabot[bot]
8c692c7477
Update boto3 requirement in /lambdas/pdf-extract (#44)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.9...1.43.10)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.10
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 22:12:28 +00:00
dependabot[bot]
4444eeb678
Update boto3 requirement in /lambdas/pdf-generate (#43)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.9...1.43.10)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.10
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 22:12:20 +00:00
dependabot[bot]
b7ab9ccc9d
Bump @tanstack/react-query from 5.100.10 to 5.100.11 in /web (#42)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.100.10 to 5.100.11.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.100.11/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.100.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 22:12:14 +00:00
dependabot[bot]
c3a0868d2f
Update boto3 requirement in /lambdas/library-ingest (#45)
Updates the requirements on [boto3](https://github.com/boto/boto3) to permit the latest version.
- [Release notes](https://github.com/boto/boto3/releases)
- [Commits](https://github.com/boto/boto3/compare/1.43.9...1.43.10)

---
updated-dependencies:
- dependency-name: boto3
  dependency-version: 1.43.10
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 22:12:12 +00:00
Adam Moussa
7dd66caf61 Add missing dependabot entry for lambdas/suggestions 2026-05-18 18:12:02 -04:00
dependabot[bot]
c14d7c2f55
Update requests requirement in /lambdas/oss-index-creator (#40)
Updates the requirements on [requests](https://github.com/psf/requests) to permit the latest version.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.31.0...v2.34.2)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.34.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 22:11:56 +00:00
dependabot[bot]
d40a58a943
Update opensearch-py requirement in /lambdas/oss-index-creator (#39)
Updates the requirements on [opensearch-py](https://github.com/opensearch-project/opensearch-py) to permit the latest version.
- [Release notes](https://github.com/opensearch-project/opensearch-py/releases)
- [Changelog](https://github.com/opensearch-project/opensearch-py/blob/main/CHANGELOG.md)
- [Commits](https://github.com/opensearch-project/opensearch-py/compare/v2.4.0...v3.2.0)

---
updated-dependencies:
- dependency-name: opensearch-py
  dependency-version: 3.2.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 22:11:54 +00:00
Adam Moussa
ef8a3b5f48 Clean up oss-index-creator: remove debug logging, update docs
- Remove verbose print statements from Lambda handler
- Add dependabot pip entry for oss-index-creator
- Update README with new Lambda and deployed stack state
2026-05-18 18:10:35 -04:00
Adam Moussa
7df81b4427 Fix AOSS dependency ordering: pre-create vector index via Custom Resource
Some checks are pending
Deploy / Deploy to AWS (push) Waiting to run
The Bedrock Knowledge Base creation was failing with 403/404 because
the OpenSearch Serverless data access policy hadn't propagated before
the KB tried to connect. Adds a CDK Custom Resource (using opensearch-py)
that creates the vector index with retry logic, ensuring the full
dependency chain: Collection → DataAccessPolicy → Index → KnowledgeBase.
2026-05-18 17:55:03 -04:00
dependabot[bot]
3250d4d927
Bump Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore from 8.0.11 to 8.0.27 (#37)
---
updated-dependencies:
- dependency-name: Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore
  dependency-version: 8.0.27
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 19:37:30 +00:00
Adam Moussa
6263551b02 Fix deploy workflow: add permissions for OIDC token
Caller must declare id-token: write for the reusable workflow's
OIDC authentication to function.
2026-05-18 15:32:15 -04:00
dependabot[bot]
7b4a909751
Bump @react-native/babel-preset from 0.79.7 to 0.85.3 in /mobile (#33)
Bumps [@react-native/babel-preset](https://github.com/facebook/react-native) from 0.79.7 to 0.85.3.
- [Release notes](https://github.com/facebook/react-native/releases)
- [Changelog](https://github.com/facebook/react-native/blob/main/CHANGELOG-0.7x.md)
- [Commits](https://github.com/facebook/react-native/compare/v0.79.7...v0.85.3)

---
updated-dependencies:
- dependency-name: "@react-native/babel-preset"
  dependency-version: 0.85.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 19:32:13 +00:00
dependabot[bot]
cc7dfa3d07
Update httpx requirement in /lambdas/pdf-generate (#32)
Updates the requirements on [httpx](https://github.com/encode/httpx) to permit the latest version.
- [Release notes](https://github.com/encode/httpx/releases)
- [Changelog](https://github.com/encode/httpx/blob/master/CHANGELOG.md)
- [Commits](https://github.com/encode/httpx/compare/0.27.0...0.28.1)

---
updated-dependencies:
- dependency-name: httpx
  dependency-version: 0.28.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 19:32:05 +00:00
dependabot[bot]
9994be3fed
Update httpx requirement in /lambdas/pdf-extract (#28)
Updates the requirements on [httpx](https://github.com/encode/httpx) to permit the latest version.
- [Release notes](https://github.com/encode/httpx/releases)
- [Changelog](https://github.com/encode/httpx/blob/master/CHANGELOG.md)
- [Commits](https://github.com/encode/httpx/compare/0.27.0...0.28.1)

---
updated-dependencies:
- dependency-name: httpx
  dependency-version: 0.28.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 19:31:56 +00:00
dependabot[bot]
923b6a4712
Update httpx requirement in /lambdas/library-ingest (#25)
Updates the requirements on [httpx](https://github.com/encode/httpx) to permit the latest version.
- [Release notes](https://github.com/encode/httpx/releases)
- [Changelog](https://github.com/encode/httpx/blob/master/CHANGELOG.md)
- [Commits](https://github.com/encode/httpx/compare/0.27.0...0.28.1)

---
updated-dependencies:
- dependency-name: httpx
  dependency-version: 0.28.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 19:31:41 +00:00
94 changed files with 5985 additions and 2811 deletions

View file

@ -56,6 +56,20 @@ updates:
open-pull-requests-limit: 3
- package-ecosystem: pip
directory: /lambdas/suggestions
schedule:
interval: weekly
open-pull-requests-limit: 3
- package-ecosystem: pip
directory: /lambdas/oss-index-creator
schedule:
interval: weekly
open-pull-requests-limit: 3
- package-ecosystem: github-actions
directory: /
schedule:

View file

@ -3,6 +3,16 @@ name: CI
on:
pull_request:
branches: [main]
paths-ignore:
- '*.md'
- 'docs/**'
- 'AUDIT-*.md'
- '.claude/**'
- 'LICENSE'
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
@ -21,6 +31,7 @@ jobs:
with:
working-directory: web
cache-dependency-path: web/package-lock.json
node-version: "24"
run-cdk-synth: false
run-conventions-check: false
@ -38,6 +49,7 @@ jobs:
with:
working-directory: mobile
cache-dependency-path: mobile/package-lock.json
node-version: "24"
run-cdk-synth: false
run-conventions-check: false
@ -47,6 +59,7 @@ jobs:
with:
working-directory: infra
cache-dependency-path: infra/package-lock.json
node-version: "24"
dotnet-version: "8.0.x"
dotnet-publish-project: api/src/ProposalSystem.Api/ProposalSystem.Api.csproj
run-typecheck: false

View file

@ -1,17 +1,19 @@
name: Deploy Mobile (iOS)
# Disabled during development. To activate for V1 release, change to:
# on:
# push:
# branches: [main]
# paths: ["mobile/**"]
on:
push:
branches: [main]
paths: ["mobile/**"]
workflow_dispatch:
concurrency:
group: deploy-mobile
cancel-in-progress: false
permissions:
id-token: write
contents: read
jobs:
deploy-ios:
name: Build & Upload to TestFlight

View file

@ -3,11 +3,17 @@ name: Deploy
on:
push:
branches: [main]
paths-ignore:
- "mobile/**"
concurrency:
group: deploy-backend
cancel-in-progress: false
permissions:
id-token: write
contents: read
jobs:
deploy:
name: Deploy to AWS

1
.gitignore vendored
View file

@ -44,6 +44,7 @@ TestResults/
# React Native / Mobile
mobile/ios/Pods/
mobile/ios/build/
mobile/ios/.xcode.env.local
mobile/android/.gradle/
mobile/android/app/build/
mobile/android/build/

View file

@ -6,10 +6,10 @@ Internal proposal management platform for Sea Haven Industries. Dispatchers subm
Monorepo with five primary services:
- **.NET 8 API** -- Clean Architecture REST API hosted on Lambda behind API Gateway
- **.NET 8 API** -- Clean Architecture REST API hosted on Lambda behind API Gateway (JWT-authorized) with Function URL for internal access
- **React 19 Web** -- MUI v7 admin/dispatcher workspace served via CloudFront + S3
- **React Native Mobile** -- iOS-first field app for dispatchers (offline-capable)
- **Python Lambdas** -- PDF extraction, PDF generation, library ingestion, AI suggestions
- **Python Lambdas** -- PDF extraction, PDF generation, library ingestion, AI suggestions, AOSS index provisioning
- **CDK Infrastructure** -- Three TypeScript stacks managing all AWS resources
## Repository Structure
@ -18,7 +18,7 @@ Monorepo with five primary services:
proposal-system/
├── api/ .NET 8 Web API (Lambda-hosted, EF Core + PostgreSQL)
├── web/ React 19 + MUI v7 + Vite frontend
├── mobile/ React Native 0.79 iOS app
├── mobile/ React Native 0.85 iOS app
├── lambdas/ Python 3.12 processing functions (arm64)
├── infra/ CDK TypeScript (3 stacks)
├── shared/ TypeScript API contracts (shared between web + mobile)
@ -33,7 +33,7 @@ proposal-system/
|---|---|
| API | .NET 8, ASP.NET Core, EF Core + Npgsql, FluentValidation, Cognito JWT, Amazon.Lambda.AspNetCoreServer |
| Web | React 19, TypeScript, MUI v7, Vite, Redux Toolkit, TanStack Query, axios |
| Mobile | React Native CLI 0.79, React 19, React Native Paper, React Navigation, react-native-app-auth (PKCE), Keychain, offline draft queue |
| Mobile | React Native CLI 0.85, React 19, React Native Paper, React Navigation, react-native-app-auth (PKCE), amazon-cognito-identity-js (SRP), Keychain, offline draft queue |
| Lambdas | Python 3.12, arm64, pdfplumber, reportlab, httpx, boto3 |
| Infrastructure | CDK TypeScript (aws-cdk-lib 2.253.1) |
| AI/RAG | Bedrock Knowledge Base (Titan Embeddings v2), OpenSearch Serverless, Claude via Bedrock Runtime |
@ -41,18 +41,18 @@ proposal-system/
## AWS Resources
All resources are in **us-east-1** (account 328440206208). CDK stacks are defined but not yet deployed to AWS.
All resources are in **us-east-1** (account 328440206208).
| CDK Stack | Key Resources |
|---|---|
| `proposal-system-foundation` | RDS PostgreSQL 15 (t4g.small), S3 buckets, SQS queue + DLQ, Cognito user pool, Secrets Manager |
| `proposal-system-compute` | API Gateway HTTP API, .NET 8 API Lambda, Python Lambdas (pdf-extract, pdf-generate, library-ingest, suggestions), Bedrock KB |
| `proposal-system-compute` | API Gateway HTTP API (JWT authorizer), .NET 8 API Lambda + Function URL, Python Lambdas (pdf-extract, pdf-generate, library-ingest, suggestions, oss-index-creator), OpenSearch Serverless collection, Bedrock KB |
| `proposal-system-frontend` | CloudFront distribution (S3 OAC) |
| Resource Type | Names |
|---|---|
| S3 Buckets | `proposal-system-uploads`, `proposal-system-generated`, `proposal-system-library`, `seahaven-ios-certificates` |
| SQS | `proposal-system-jobs` + `proposal-system-jobs-dlq` (message body filtering by jobType) |
| SQS | `proposal-system-jobs` (720s visibility, reportBatchItemFailures) + `proposal-system-jobs-dlq` (message body filtering by jobType) |
| Secrets | `proposal-system/db-credentials`, `proposal-system/internal-api-key` |
## Local Development
@ -127,11 +127,13 @@ Calls `cd-cdk.yaml` reusable workflow:
Deploy uses OIDC role `githubdeploy-proposal-system`. Concurrency group prevents parallel deploys.
### Mobile Deploy (currently disabled)
### Mobile Deploy
Workflow: `deploy-mobile.yaml` -- triggered by `workflow_dispatch` only (manual).
Workflow: `deploy-mobile.yaml` -- builds and uploads to TestFlight via `cd-mobile-ios.yaml` reusable workflow on `macos-26`.
To activate for release, change the trigger to push on main with path filter `mobile/**`.
Triggers:
- **Automatic**: push to `main` with changes in `mobile/**`
- **Manual**: `workflow_dispatch` for on-demand builds
## Mobile iOS
@ -147,21 +149,29 @@ Build and upload to TestFlight is handled by the `cd-mobile-ios.yaml` reusable w
| `ASC_ISSUER_ID` | App Store Connect issuer |
| `ASC_KEY_CONTENT` | App Store Connect API key (base64) |
To activate automatic deploys, update `deploy-mobile.yaml` trigger from `workflow_dispatch` to:
## Authentication & Authorization
```yaml
on:
push:
branches: [main]
paths: ["mobile/**"]
```
Two-layer auth architecture with defense-in-depth:
| Path | Authorizer | Authentication |
|---|---|---|
| External clients → API Gateway `/{proxy+}` | Cognito JWT authorizer (web + mobile client IDs) | .NET JWT middleware (ValidateAudience=true) |
| `/api/health` | None (public) | None |
| `/api/auth/callback`, `/api/auth/dev-login` | None (unauthenticated) | None (pre-auth endpoints) |
| Internal Lambdas → Function URL | None (NONE auth type) | Internal API key (`X-Internal-Api-Key` header, value from Secrets Manager) |
**Role-based access:** Cognito groups (`dispatchers`, `admins`, `sysadmins`) map to API roles via `cognito:groups` claim. Dispatchers can only see their own proposals (ownership enforced in service layer). VendorProposals and GeneratedPdfs endpoints restricted to admins/sysadmins.
**Internal API key:** Python Lambdas call the .NET API via a Lambda Function URL (bypasses API Gateway JWT check). The `InternalApiKeyMiddleware` validates the key and assigns the `admins` role to the synthetic identity.
## Data Flow
1. Dispatcher submits proposal request (web or mobile)
2. API creates proposal record, publishes SQS message
2. API creates proposal record (with advisory-locked number generation), publishes SQS message
3. If vendor PDF attached: `pdf-extract` Lambda parses and structures data
4. Suggestions Lambda queries Bedrock KB for similar proposals, generates line items via Claude
5. Admin reviews/edits line items in pricing workspace
6. On approval: `pdf-generate` Lambda creates branded PDF
7. On send: `library-ingest` Lambda adds approved proposal to KB for future matching
Failed SQS messages are reported via `batchItemFailures` and retried up to 3 times before moving to the DLQ.

228
RETROSPECTIVE-2026-05-19.md Normal file
View file

@ -0,0 +1,228 @@
# Proposal System — Retrospective (2026-05-19, updated 2026-05-20, session 5 added 2026-05-20)
## Executive Summary
Across four sessions (2026-05-18, 2026-05-19, and two on 2026-05-20), the project progressed from a broken mobile CI pipeline to a functional mobile app on device AND a fully tested web frontend with working dev-mode authentication, proposal lifecycle, and admin workflows.
**Sessions 1-3 (Mobile):** The mobile app went from a broken CI pipeline to a functional app running on a physical device with working email/password authentication. Three distinct launch crashes were resolved, Cognito SRP login was validated end-to-end, and multiple UI issues were fixed. The app boots, authenticates, and renders on iOS 26 hardware. However, it cannot communicate with the backend API from a device, Google OAuth crashes the app, and the branch has not been merged to main.
**Session 4 (Web):** Full local web testing exposed six bugs in the API and frontend: enum serialization failures, identity/role confusion in dev-login, incorrect proposal ownership filtering, Autocomplete binding issues, audit log format errors on Postgres jsonb columns, and missing API idempotency. All were fixed in four logical commits. The web app's core workflow — submit as dispatcher, review/edit/approve/send as admin — is now functional end-to-end in dev mode.
**Session 5 (Automated QA):** Ran 4 parallel test agents covering ~145 test cases across every API endpoint and every frontend page. Found 18 bugs (2 critical, 4 high, 7 medium, 5 low). All 16 actionable bugs fixed in 4 commits. Critical: admin dashboard LINQ crash (EF Core can't translate TimeSpan.TotalHours to SQL) and revision endpoint 500 (unique constraint on ProposalNumber). High: dispatcher dashboard data exposure (missing mine filter), no frontend role guards on admin routes, submittedByName null on mutation responses, invalid role silently defaulting to Admin. Also extracted duplicated STATUS_COLORS and format utilities into shared modules, wired the admin dashboard filter dropdowns, and added debounce to customer search.
**Systemic findings:** The web session revealed two architectural gaps: (1) audit logging was fragile — a format error in a non-critical audit write could roll back an otherwise successful save, and (2) state machine transitions lacked idempotency, meaning retries or UI double-clicks could produce 500 errors instead of graceful no-ops. Both are patterns that would have surfaced in production under real load. Session 5 added a third: the frontend had no authorization enforcement — `ProtectedRoute` checked authentication but not role, so any logged-in user could navigate to admin pages by URL.
## Standards Compliance Status
| Rule | Status | Detail |
|------|--------|--------|
| Naming conventions | PASS | kebab-case throughout, branch name follows pattern |
| CI/CD pipeline exists | PASS | `deploy-mobile.yaml` and `deploy.yaml` both trigger on push to main |
| OIDC deploy role | PASS | `githubdeploy-proposal-system` |
| README accurate | **PARTIAL** | Root README updated (0.85, deploy status). `mobile/README.md` incomplete (no auth/device docs). Web dev mode not documented. |
| Confluence updated | **FAIL** | No Atlassian MCP. Architecture Map missing mobile pipeline, Cognito auth flow, and web dev-mode setup |
| Memory updated | **UPDATED** | Project memory updated with session 4 web fixes. New feedback memories created for API patterns. |
| Git workflow | PASS | All sessions used feature branch `mobile/fix-react-version-and-ui` |
| Commit messages | PASS | Imperative mood, explains "why", logically grouped changes |
| CDK callback URL fix | PASS | Fixed in CDK + live Cognito via AWS CLI |
| Pre-push lint/typecheck | NOT VERIFIED | Did not run typecheck before pushing — should have per CLAUDE.md hook |
| Dev secrets excluded | PASS | `appsettings.Development.json` (dev signing key) kept untracked, not committed |
| API idempotency | **FIXED** | Approve, MarkSent, Revise transitions now idempotent. Audit failures isolated from saves. |
## Required Memory Updates
### Completed this session
1. **`project_proposal_system.md`** — Updated with session 4 web fixes: dev-mode setup, enum serialization, audit log format, idempotent transitions, scoped My Proposals filtering.
2. **`feedback_mobile_deploy_lessons.md`** — All three session-3 lessons added (React pinning, import type, dev API URL). Done in session 3.
3. **`feedback_react_version_pinning.md`** — Created in session 3. Done.
4. **`feedback_api_idempotency.md`** — NEW: State machine transitions must be idempotent. Audit writes must not roll back successful saves.
5. **`feedback_jsonb_audit_format.md`** — NEW: Postgres jsonb columns require valid JSON, not plain strings. Audit details must be wrapped.
### Still outstanding
6. **`reference_mobile_testflight.md`** — The ⚠️ note about "username/password flow needs to be added" is now resolved but not yet updated in the file.
## Required Documentation Updates
| Doc | Status | Action |
|-----|--------|--------|
| Root `README.md` | Updated (session 2) | Needs update: add web dev-mode setup instructions (DevMode, dev-login, local Postgres) |
| `mobile/README.md` | Exists but incomplete | Add: email/password auth via Cognito SRP, `patch-package` for netinfo iOS 26 fix, React version pinning requirement, local device testing setup |
| `api/` dev setup | **MISSING** | No documentation for local API development: `appsettings.Development.json` template (without secrets), Docker Compose for Postgres, dev-login endpoint usage |
| Confluence "AWS Architecture Map" | **OUTSTANDING** | Still blocked — no Atlassian MCP. Needs: mobile CI/CD pipeline, Cognito auth flow, web dev-mode architecture |
| CDK `foundation-stack.ts` | Updated (session 2) | Callback URLs fixed, CfnOutputs added for client IDs |
## Reusable Skills / Automations
| Candidate | Type | ROI | Description |
|-----------|------|-----|-------------|
| React version coherence check | CI step | **CRITICAL** | `node -e` script that reads `node_modules/react-native/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js`, extracts the hardcoded version string, and compares against `node_modules/react/package.json`. Fails if mismatch. Would have caught the exact crash from session 3. |
| API idempotency test suite | Integration test | **HIGH** | For each state-machine endpoint (approve, markSent, revise), call twice with same input and assert both return 200 with matching response. Would have caught all three idempotency bugs from session 4. Pattern: assert `f(f(x)) == f(x)` for all mutation endpoints. |
| Audit isolation pattern | Code pattern | **HIGH** | Wrap all non-critical audit writes in try/catch so they never roll back the primary operation. Consider a `SafeAuditService` decorator or middleware. Session 4's bulk update 500 error was caused by audit failure after a successful save. |
| iOS device smoke test script | Script / Runbook | HIGH | Checklist for post-build device testing: connect device, Metro `--host <LAN_IP>`, build with automatic signing, verify login, test auth flow. |
| `patch-package` audit CI step | CI check | MEDIUM | Verify patches in `mobile/patches/` still apply cleanly and patched packages haven't been updated. |
| Dev-mode login test harness | Script | MEDIUM | Script that exercises all three dev-login roles (SysAdmin, Admin, Dispatcher) and verifies each returns a distinct user identity with correct role. Would have caught the role/identity confusion bugs immediately. |
| Cognito ID token user extraction | Utility | LOW | `parseUserFromIdToken()` in `auth.ts` — reusable for any Cognito-backed app. |
## Key Lessons Learned
### React Native Runtime (Sessions 1-3)
1. **React version MUST be pinned exactly, not with semver range.** RN 0.85.3's bundled `ReactNativeRenderer-dev.js` has a hard check: `if ("19.2.3" !== isomorphicReactPackageVersion)`. The peer dependency says `^19.2.3`, npm resolves to 19.2.6, and the app crashes with an opaque "Cannot read property 'default' of undefined" in `getPaperRenderer`. Pin `"react": "19.2.3"` in package.json.
2. **`import type` is not reliably erased for modules with native initialization.** `import type { CognitoUserSession } from 'amazon-cognito-identity-js'` was NOT stripped by Babel in RN's build pipeline. The module eagerly initialized native crypto at import time, causing a crash. Fix: remove the import entirely and use `any`, or use dynamic `await import()`.
3. **`localhost` in dev config is the phone, not the Mac.** `API_URL: 'http://localhost:5000/api'` in dev mode is unreachable from a physical device. Need either LAN IP or a fallback strategy.
### iOS 26 Specific (Sessions 1-3)
4. **CoreTelephony APIs removed without replacement.** `@react-native-community/netinfo` v12.0.1 still calls deprecated APIs. Required `patch-package` with `respondsToSelector:` guards.
5. **iPhone Mirroring is the fastest way to test on device.** Built into macOS 26, gives full touch control. Developer Mode on the phone is under Settings > Privacy & Security.
### .NET API / Web Frontend (Session 4)
6. **Postgres jsonb columns reject plain strings.** The `details` column on `AuditLogs` is typed `jsonb`. Writing a bare string like `"Added: Widget repair"` produces Postgres error 22P02. Wrap in a JSON object: `JsonSerializer.Serialize(new { message = details })`. This is easy to miss because SQLite and SQL Server `nvarchar` accept anything.
7. **System.Text.Json requires explicit `JsonStringEnumConverter` for enum round-tripping.** Without it, sending `"ServiceCategory": "Plumbing"` from the frontend produces a validation error because the default deserializer expects an integer. Must add `options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())` in `AddJsonOptions`.
8. **State machine transitions must be idempotent.** Approve, MarkSent, and Revise all threw `InvalidOperationException` on repeat calls (e.g., from network retries or UI double-clicks). Fix: if already in the target state, return current entity instead of throwing. This is especially critical for mobile clients with unreliable connectivity.
9. **Audit writes must never roll back successful business operations.** `BulkUpdateAsync` saved line items successfully, then `_audit.LogAsync` threw (due to the jsonb format bug), and the entire request returned 500. The user saw "unexpected error" even though their data was saved. Fix: wrap non-critical audit calls in try/catch.
10. **Dev-login must produce deterministic, distinct identities per role.** Using `Guid.NewGuid()` for CognitoSub meant the same email produced different identities across logins. Using a single hardcoded email for all roles meant switching roles didn't actually switch users. Fix: deterministic sub (`dev-{email}`), distinct email/name per role, and update role on existing user if changed.
11. **"My Proposals" means ownership, not role-based filtering.** Initial implementation filtered by role (show all for admins, filter for dispatchers). The correct behavior: "My Proposals" always shows only proposals the current user submitted, regardless of role. Admins see all proposals in the separate Admin Queue.
### Process (All Sessions)
12. **Feature branch for iterative debugging works.** Session 2 pushed 10+ commits to main. Sessions 3-4 used `mobile/fix-react-version-and-ui` — all fixes stay off main until ready.
13. **User testing catches what type systems and linters can't.** Session 4's six bugs all passed TypeScript compilation and would pass unit tests. They were logic errors in business rules, serialization config, and identity management that only surfaced through manual workflow testing. Interactive testing with role-switching is essential before any deploy.
## Highest ROI Improvements
Ranked by impact-to-effort:
1. **Add API idempotency integration tests** (1 hr) — For each state-machine endpoint, call twice with same input and assert both return 200. Pattern: `assert f(f(x)) == f(x)`. Would have caught 3 of session 4's bugs automatically. Generalizable to any future endpoint.
2. **Add React version coherence CI check** (30 min) — A 10-line node script that extracts the expected version from the bundled renderer and compares to installed React. Prevents the most time-consuming crash from session 3.
3. **Isolate audit writes from business operations** (30 min) — Create a `SafeAuditService` wrapper or add try/catch to all audit calls in services. The pattern already exists in `LineItemService` but should be systematic, not ad-hoc. A single audit format bug caused a 500 on an otherwise successful operation.
4. **Add `.gitignore` to API project** (5 min) — `appsettings.Development.json` contains dev signing keys and must not be committed. Currently relying on manual exclusion. Add it to `.gitignore` with a template file (`.example`) that documents the required keys without values.
5. **Document web dev-mode setup** (15 min) — No docs exist for running the API locally: Docker Compose for Postgres, `appsettings.Development.json` template, dev-login endpoint, role switching. This will block any new developer.
6. **Merge `mobile/fix-react-version-and-ui` and deploy** (5 min) — Branch has 8 commits of critical fixes (sessions 3-4). Current TestFlight build crashes. Must merge before next submission.
7. **Fix dev API_URL for physical devices** (10 min) — `localhost:5000` is unreachable from iPhone. Blocks all API-dependent mobile features during device testing.
8. **Pin all RN ecosystem versions exactly** (5 min) — Already done for React; extend to all `@react-native/*` packages.
## Outstanding Risks or Follow-Ups
| Priority | Item | Risk | Branch/Location |
|----------|------|------|-----------------|
| **BLOCKING** | Feature branch not merged — TestFlight build still crashes | Any TestFlight tester or Apple reviewer will see a crash | `mobile/fix-react-version-and-ui` |
| **BLOCKING** | Google OAuth crashes the app on tap | Apple reviewer may try both login methods | `auth.ts` → `react-native-app-auth` → Cognito Hosted UI |
| **HIGH** | `appsettings.Development.json` not in `.gitignore` | Dev signing key could be accidentally committed | `api/src/ProposalSystem.Api/` |
| **HIGH** | Dev API_URL is `localhost:5000` — all API calls fail on device | Proposals can't be created, viewed, or searched on device | `config.ts` |
| **HIGH** | Placeholder app icons (solid blue squares) | Unprofessional for TestFlight / App Store | `ios/ProposalSystem/Images.xcassets` |
| **HIGH** | No web dev-mode setup documentation | New developer can't run the system locally | Root README / api/ docs |
| ~~HIGH~~ | ~~Audit isolation is ad-hoc, not systematic~~ | ~~Fixed session 4; session 5 verified via testing~~ | ~~LineItemService, ProposalService~~ |
| **MEDIUM** | Confluence Architecture Map still missing mobile pipeline | Documentation debt per CLAUDE.md | Page 1540098 |
| **MEDIUM** | `react-native-paper` has known issues with RN 0.85 | May surface as bugs in production | GitHub issues #4889, #4905 |
| **MEDIUM** | netinfo patch needs monitoring for upstream fix | Patches can silently break on version bumps | `patches/@react-native-community+netinfo+12.0.1.patch` |
| **MEDIUM** | `DeleteAsync` in `LineItemService` doesn't update `TotalBidAmount` | Deleting a line item leaves the proposal total stale | `LineItemService.cs:113` |
| **LOW** | `no-floating-promises` ESLint rule still not added | Class of crash from session 2 can recur | `mobile/.eslintrc` |
| **LOW** | Cognito test user password in memory file | Acceptable for internal test account | `reference_mobile_testflight.md` |
| **LOW** | 4 Dependabot vulnerabilities open | Adam deferred these | GitHub Security tab |
## Session 5 Changelog — Automated QA & Bug Fixes (2026-05-20)
All fixes on `mobile/fix-react-version-and-ui` (4 commits, not yet on main):
### `d00c552` Fix admin dashboard LINQ crash, revise unique constraint, and mutation response data
- **`AdminController.cs`** — Rewrote avgTurnaround query to fetch approved times to memory before computing TotalHours (EF Core/Npgsql cannot translate TimeSpan.TotalHours)
- **`ProposalService.cs` ReviseAsync** — Append `-R{n}` suffix to revision ProposalNumber to avoid unique index violation
- **`ProposalService.cs` Update/Approve/MarkSent** — Added `.Include(p => p.SubmittedBy)` so mutation responses return submittedByName
### `8666010` Validate dev-login input: reject empty email and invalid role
- **`AuthController.cs`** — Return 400 for empty/whitespace email and invalid role strings; default role changed from Admin to Dispatcher (least privilege)
### `4d72b63` Add frontend role guards, fix dashboard data exposure, and harden UX
- **`ProtectedRoute.tsx`** — New `RoleGuard` component for role-based route protection
- **`App.tsx`** — Wrapped admin routes with `RoleGuard`; `/admin/*` requires Admin/SysAdmin, `/admin/users` requires SysAdmin
- **`Dashboard.tsx`** — Added `mine: true` to dashboard query so dispatchers only see their own proposals
- **`AdminWorkspace.tsx`** — Auto-save dirty changes before approving (was silently discarding edits)
- **`ProposalFormPage.tsx`** — Added onError toast handler; added 300ms debounce on customer autocomplete search
- **`admin.ts`** — Stopped swallowing errors in getPdf; fixed AuditEntry.details type to `string | null`
- **`LoginPage.tsx`** — Fixed pre-existing TS error with noUncheckedIndexedAccess
### `5e89e42` Extract shared constants and format utils, wire admin dashboard filters
- **`constants/index.ts`** — Added shared `STATUS_COLORS` and `PRIORITY_COLORS` (removed from 5 files)
- **`lib/format.ts`** — New shared `formatCurrency`, `formatDate`, `formatDateTime` (removed from 4 files)
- **`AdminDashboard.tsx`** — Wired Category and Priority filter dropdowns to `usePaginatedList` extraParams
- **`ProposalDetailPage.tsx`** — Added 'Revised' to STATUS_ORDER so stepper renders correctly
### QA Coverage Summary
| Test Area | Tests | Pass | Fail | Agent |
|-----------|-------|------|------|-------|
| Auth & RBAC | 35 | 31 | 4 | Auth agent |
| Proposal CRUD & State Machine | 38 | 35 | 3 | Proposal agent |
| Line Items, Customers & Misc | 42 | 39 | 3 | Misc agent |
| Web Frontend Code Review + API | ~30 | ~25 | ~5 | Frontend agent |
| **Total** | **~145** | **~130** | **~15** | — |
## Session 3 Changelog — Mobile Device Testing (2026-05-20)
Fixes on `mobile/fix-react-version-and-ui` (not yet on main):
- **Pin React 19.2.3** — fixes renderer version mismatch crash
- **Remove `import type` from cognito-auth.ts** — fixes eager module init crash
- **Auth fallback to ID token** — `loginWithCredentials` parses user from JWT when backend API unreachable
- **Safe area fixes** — Settings gets top+bottom edges; Dashboard/AdminDashboard use bottom-only (nav header handles top)
- **Pull-to-refresh separation** — filter chip taps no longer trigger refresh animation on proposal queue
- **Welcome name** — shows first name or email prefix instead of full email
- **Service category chips** — wrapping `Chip` components replace truncated `SegmentedButtons`
- **ErrorBoundary** — added to App root for crash visibility
Already on main (sessions 2-3):
- **Email/password login screen** — TextInput form + Cognito SRP via `amazon-cognito-identity-js`
- **Cognito config populated** — real values from AWS CLI
- **CDK callback URL fix** — `com.seahavenind.proposals://auth/callback`
- **netinfo iOS 26 patch** — `patch-package` with `respondsToSelector:` guards
- **Auto-deploy enabled** — `deploy-mobile.yaml` triggers on `mobile/**` push to main
- **Root README updated** — RN 0.85, deploy status corrected
- **`mobile/README.md` created** — local dev, signing, CI/CD docs
## Session 4 Changelog — Web Local Testing (2026-05-20)
All fixes on `mobile/fix-react-version-and-ui` (4 commits, not yet on main):
### `f44caba` Fix JSON enum serialization and audit log jsonb format
- **`Program.cs`** — Added `JsonStringEnumConverter` to `AddJsonOptions` so frontend string enums deserialize correctly
- **`AuditService.cs`** — Wrapped plain-string audit details in `JsonSerializer.Serialize(new { message = details })` for Postgres jsonb column
- **`global.json`** — Relaxed SDK version from 8.0.400 to 8.0.100 to match installed .NET SDK
### `f37c2aa` Fix dev-login role switching, distinct users, and user resolution races
- **`AuthController.cs`** — Dev-login now updates role on existing user; CognitoSub is deterministic (`dev-{email}`)
- **`CurrentUserService.cs`** — Added `DbUpdateException` catch on concurrent user creation with retry lookup
- **`LoginPage.tsx`** — Distinct dev user per role (SysAdmin=Adam, Admin=Sarah, Dispatcher=Mike)
### `9b97b7b` Fix proposal workflow: scoped My Proposals, idempotent state transitions
- **`ProposalService.cs`** — New proposals created as `InReview` (not `Draft`); My Proposals filters by `Mine` parameter (not role); `ApproveAsync`, `MarkSentAsync`, `ReviseAsync` all idempotent
- **`LineItemService.cs`** — Audit writes wrapped in try/catch so failures don't roll back successful saves
- **`ProposalDtos.cs`** — Added `bool Mine` filter parameter
- **`proposals.ts` / `ProposalListPage.tsx`** — Frontend passes `mine: true` for My Proposals page
### `2645d97` Fix customer name not binding from Autocomplete free text input
- **`ProposalFormPage.tsx`** — `onInputChange` with `reason === 'input'` now calls `handleChange('customerName', value)` alongside search

View file

@ -1,6 +1,6 @@
{
"sdk": {
"version": "8.0.400",
"version": "8.0.100",
"rollForward": "latestFeature"
}
}

View file

@ -29,11 +29,20 @@ public class AdminController : ControllerBase
var approvedThisWeek = await _db.Proposals
.CountAsync(p => p.ApprovedAt >= weekStart, ct);
var avgTurnaround = await _db.Proposals
.Where(p => p.ApprovedAt.HasValue)
.Select(p => (p.ApprovedAt!.Value - p.SubmittedAt).TotalHours)
.DefaultIfEmpty(0)
.AverageAsync(ct);
var approvedCount = await _db.Proposals
.CountAsync(p => p.ApprovedAt.HasValue, ct);
double avgTurnaround = 0;
if (approvedCount > 0)
{
var recentApproved = await _db.Proposals
.Where(p => p.ApprovedAt.HasValue)
.OrderByDescending(p => p.ApprovedAt)
.Take(200)
.Select(p => new { p.ApprovedAt, p.SubmittedAt })
.ToListAsync(ct);
avgTurnaround = recentApproved.Average(p => (p.ApprovedAt!.Value - p.SubmittedAt).TotalHours);
}
var totalProposals = await _db.Proposals.CountAsync(ct);

View file

@ -5,6 +5,8 @@ using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
using ProposalSystem.Domain.Entities;
using ProposalSystem.Infrastructure.Data;
@ -40,7 +42,35 @@ public class AuthController : ControllerBase
return BadRequest(new { message = "Failed to exchange authorization code" });
var handler = new JwtSecurityTokenHandler();
var idToken = handler.ReadJwtToken(tokenResponse.IdToken);
var authority = _config["Auth:Authority"];
JwtSecurityToken idToken;
if (!string.IsNullOrEmpty(authority))
{
var configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
$"{authority}/.well-known/openid-configuration",
new OpenIdConnectConfigurationRetriever(),
new HttpDocumentRetriever());
var oidcConfig = await configManager.GetConfigurationAsync(ct);
var validationParams = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKeys = oidcConfig.SigningKeys,
ValidateIssuer = true,
ValidIssuer = authority,
ValidateAudience = true,
ValidAudience = clientId,
ValidateLifetime = true,
};
handler.ValidateToken(tokenResponse.IdToken, validationParams, out var validatedToken);
idToken = (JwtSecurityToken)validatedToken;
}
else
{
idToken = handler.ReadJwtToken(tokenResponse.IdToken);
}
var sub = idToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value
?? throw new InvalidOperationException("No sub claim in ID token");
@ -71,12 +101,17 @@ public class AuthController : ControllerBase
_db.Users.Add(user);
await _db.SaveChangesAsync(ct);
}
else if (user.Email != email || user.DisplayName != name)
else
{
user.Email = email;
user.DisplayName = name;
user.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
var changed = false;
if (user.Email != email) { user.Email = email; changed = true; }
if (user.DisplayName != name) { user.DisplayName = name; changed = true; }
if (user.Role != role) { user.Role = role; changed = true; }
if (changed)
{
user.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
}
}
return Ok(new AuthResponse(
@ -99,7 +134,13 @@ public class AuthController : ControllerBase
if (string.IsNullOrEmpty(signingKey))
return StatusCode(500, new { message = "Dev signing key not configured" });
var role = Enum.TryParse<UserRole>(request.Role, true, out var parsed) ? parsed : UserRole.Admin;
if (string.IsNullOrWhiteSpace(request.Email))
return BadRequest(new { message = "Email is required" });
if (!string.IsNullOrEmpty(request.Role) && !Enum.TryParse<UserRole>(request.Role, true, out _))
return BadRequest(new { message = $"Invalid role: '{request.Role}'. Valid roles are: Dispatcher, Admin, SysAdmin" });
var role = Enum.TryParse<UserRole>(request.Role, true, out var parsed) ? parsed : UserRole.Dispatcher;
var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == request.Email, ct);
if (user == null)
@ -107,7 +148,7 @@ public class AuthController : ControllerBase
user = new User
{
Id = Guid.NewGuid(),
CognitoSub = $"dev-{Guid.NewGuid():N}",
CognitoSub = $"dev-{request.Email}",
Email = request.Email,
DisplayName = request.DisplayName ?? request.Email.Split('@')[0],
Role = role,
@ -118,6 +159,12 @@ public class AuthController : ControllerBase
_db.Users.Add(user);
await _db.SaveChangesAsync(ct);
}
else if (user.Role != role)
{
user.Role = role;
user.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
}
var claims = new List<Claim>
{

View file

@ -38,6 +38,7 @@ public class FilesController : ControllerBase
public async Task<ActionResult<PresignedUploadResponse>> UploadAttachment(
Guid proposalId,
[FromQuery] string fileName,
[FromQuery] string? vendorName,
CancellationToken ct)
{
var proposal = await _db.Proposals.FindAsync(new object[] { proposalId }, ct);
@ -56,6 +57,7 @@ public class FilesController : ControllerBase
{
Id = Guid.NewGuid(),
ProposalId = proposalId,
VendorName = vendorName ?? string.Empty,
FileName = fileName,
S3Key = s3Key,
UploadedAt = DateTime.UtcNow,
@ -65,9 +67,30 @@ public class FilesController : ControllerBase
_db.VendorProposals.Add(vendorProposal);
await _db.SaveChangesAsync(ct);
await _jobPublisher.PublishAsync("pdf-extract", new { proposalId, s3Key, vendorProposalId = vendorProposal.Id }, ct);
return Ok(new PresignedUploadResponse(url, s3Key, DateTime.UtcNow.AddMinutes(15), vendorProposal.Id));
}
return Ok(new PresignedUploadResponse(url, s3Key, DateTime.UtcNow.AddMinutes(15)));
[HttpPost("attachments/{vendorProposalId:guid}/confirm")]
public async Task<ActionResult> ConfirmUpload(
Guid proposalId,
Guid vendorProposalId,
CancellationToken ct)
{
var vendorProposal = await _db.VendorProposals
.FirstOrDefaultAsync(v => v.Id == vendorProposalId && v.ProposalId == proposalId, ct);
if (vendorProposal == null) return NotFound();
if (vendorProposal.ProcessingStatus != ProcessingStatus.Pending)
return Ok();
await _jobPublisher.PublishAsync("pdf-extract", new
{
proposalId,
s3Key = vendorProposal.S3Key,
vendorProposalId = vendorProposal.Id,
}, ct);
return Ok();
}
[HttpGet("pdf")]

View file

@ -9,7 +9,7 @@ namespace ProposalSystem.Api.Controllers;
[ApiController]
[Route("api/generated-pdfs")]
[Authorize]
[Authorize(Roles = "admins,sysadmins")]
public class GeneratedPdfsController : ControllerBase
{
private readonly ProposalDbContext _db;

View file

@ -8,7 +8,7 @@ namespace ProposalSystem.Api.Controllers;
[ApiController]
[Route("api/vendor-proposals")]
[Authorize]
[Authorize(Roles = "admins,sysadmins")]
public class VendorProposalsController : ControllerBase
{
private readonly ProposalDbContext _db;
@ -38,14 +38,13 @@ public class VendorProposalsController : ControllerBase
await _db.SaveChangesAsync(ct);
if (vendor.TotalVendorCost > 0)
var proposal = await _db.Proposals.FindAsync(new object[] { vendor.ProposalId }, ct);
if (proposal != null)
{
var proposal = await _db.Proposals.FindAsync(new object[] { vendor.ProposalId }, ct);
if (proposal != null)
{
proposal.VendorTotalCost = vendor.TotalVendorCost;
await _db.SaveChangesAsync(ct);
}
proposal.VendorTotalCost = await _db.VendorProposals
.Where(v => v.ProposalId == vendor.ProposalId)
.SumAsync(v => v.TotalVendorCost, ct);
await _db.SaveChangesAsync(ct);
}
return NoContent();

View file

@ -58,10 +58,10 @@ public class GlobalExceptionHandler : IMiddleware
}
),
InvalidOperationException => (
HttpStatusCode.Conflict,
HttpStatusCode.BadRequest,
new ProblemDetails
{
Status = 409,
Status = 400,
Title = "Invalid Operation",
Detail = exception.Message,
}

View file

@ -8,10 +8,12 @@ public class InternalApiKeyMiddleware
{
private readonly RequestDelegate _next;
private readonly byte[] _apiKeyBytes;
private readonly ILogger<InternalApiKeyMiddleware> _logger;
public InternalApiKeyMiddleware(RequestDelegate next, IConfiguration configuration)
public InternalApiKeyMiddleware(RequestDelegate next, IConfiguration configuration, ILogger<InternalApiKeyMiddleware> logger)
{
_next = next;
_logger = logger;
var key = configuration["INTERNAL_API_KEY"] ?? "";
_apiKeyBytes = Encoding.UTF8.GetBytes(key);
}
@ -38,6 +40,11 @@ public class InternalApiKeyMiddleware
var identity = new ClaimsIdentity(claims, "InternalApiKey");
context.User = new ClaimsPrincipal(identity);
}
else
{
_logger.LogWarning("Invalid internal API key from {RemoteIp} on {Path}",
context.Connection.RemoteIpAddress, context.Request.Path);
}
}
await _next(context);

View file

@ -63,11 +63,14 @@ if (!string.IsNullOrEmpty(cognitoAuthority))
.AddJwtBearer(options =>
{
options.Authority = cognitoAuthority;
var webClientId = builder.Configuration["COGNITO_WEB_CLIENT_ID"] ?? "";
var mobileClientId = builder.Configuration["COGNITO_MOBILE_CLIENT_ID"] ?? "";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
ValidateIssuer = true,
ValidateAudience = false,
ValidateAudience = true,
ValidAudiences = new[] { webClientId, mobileClientId }.Where(s => !string.IsNullOrEmpty(s)).ToList(),
ValidateLifetime = true,
RoleClaimType = "cognito:groups",
};
@ -137,6 +140,9 @@ builder.Services.AddValidatorsFromAssemblyContaining<CreateProposalValidator>();
builder.Services.AddControllers(options =>
{
options.Filters.Add<ValidationFilter>();
}).AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});
// Middleware
@ -151,9 +157,10 @@ builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.WithOrigins(
"https://proposals.seahaven.com",
"http://localhost:5173")
var origins = new List<string> { "https://proposals.seahaven.com" };
if (builder.Environment.IsDevelopment())
origins.Add("http://localhost:5173");
policy.WithOrigins(origins.ToArray())
.AllowAnyMethod()
.AllowAnyHeader();
});

View file

@ -21,7 +21,7 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.27" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.27" />
</ItemGroup>
</Project>

View file

@ -80,16 +80,22 @@ public class CurrentUserService : ICurrentUserService
};
_db.Users.Add(_cachedUser);
await _db.SaveChangesAsync();
try
{
await _db.SaveChangesAsync();
}
catch (DbUpdateException)
{
_db.Entry(_cachedUser).State = EntityState.Detached;
_cachedUser = await _db.Users.FirstOrDefaultAsync(u => u.Email == email)
?? throw new UnauthorizedAccessException("Could not resolve current user");
}
}
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.");
}
}

View file

@ -3,7 +3,8 @@ namespace ProposalSystem.Application.DTOs;
public record PresignedUploadResponse(
string UploadUrl,
string S3Key,
DateTime ExpiresAt
DateTime ExpiresAt,
Guid VendorProposalId
);
public record PdfDownloadResponse(

View file

@ -67,6 +67,7 @@ public record ProposalFilterRequest(
DateTime? FromDate,
DateTime? ToDate,
string? Search,
bool Mine = false,
int Page = 1,
int PageSize = 25
);

View file

@ -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);
});
}
}

View file

@ -1,3 +1,4 @@
using System.Text.Json;
using ProposalSystem.Application.Interfaces;
using ProposalSystem.Domain.Entities;
using ProposalSystem.Infrastructure.Data;
@ -17,13 +18,17 @@ public class AuditService : IAuditService
public async Task LogAsync(AuditAction action, Guid? proposalId, string? details = null, CancellationToken ct = default)
{
var jsonDetails = details != null
? JsonSerializer.Serialize(new { message = details })
: null;
var entry = new AuditLog
{
Id = Guid.NewGuid(),
ProposalId = proposalId,
UserId = _currentUser.UserId,
Action = action,
Details = details,
Details = jsonDetails,
Timestamp = DateTime.UtcNow,
IpAddress = _currentUser.IpAddress,
};

View file

@ -54,7 +54,11 @@ public class LineItemService : ILineItemService
_db.LineItems.Add(lineItem);
await _db.SaveChangesAsync(ct);
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Added: {request.Description}", ct);
try
{
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Added: {request.Description}", ct);
}
catch { /* audit failure should not roll back a successful save */ }
return MapToResponse(lineItem);
}
@ -97,7 +101,11 @@ public class LineItemService : ILineItemService
await _db.SaveChangesAsync(ct);
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Bulk update: {newItems.Count} items", ct);
try
{
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Bulk update: {newItems.Count} items", ct);
}
catch { /* audit failure should not roll back a successful save */ }
return newItems.OrderBy(li => li.SortOrder).Select(MapToResponse).ToList();
}

View file

@ -18,8 +18,13 @@ public class ProposalNumberGenerator : IProposalNumberGenerator
var year = DateTime.UtcNow.Year;
var prefix = $"SHI-{year}-";
// Advisory lock prevents concurrent number generation within the same transaction
await _db.Database.ExecuteSqlRawAsync(
"SELECT pg_advisory_xact_lock(hashtext('proposal_number_gen'))", ct);
var lastNumber = await _db.Proposals
.Where(p => p.ProposalNumber.StartsWith(prefix))
.Where(p => !p.ProposalNumber.Contains("-R"))
.OrderByDescending(p => p.ProposalNumber)
.Select(p => p.ProposalNumber)
.FirstOrDefaultAsync(ct);

View file

@ -30,6 +30,8 @@ public class ProposalService : IProposalService
public async Task<ProposalResponse> CreateAsync(CreateProposalRequest request, CancellationToken ct = default)
{
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
var proposalNumber = await _numberGenerator.GenerateAsync(ct);
var now = DateTime.UtcNow;
@ -43,7 +45,7 @@ public class ProposalService : IProposalService
ScopeOfWork = request.ScopeOfWork,
ServiceCategory = request.ServiceCategory,
Priority = request.Priority,
Status = ProposalStatus.Draft,
Status = ProposalStatus.InReview,
Notes = request.Notes ?? string.Empty,
SubmittedById = _currentUser.UserId,
SubmittedAt = now,
@ -53,10 +55,19 @@ public class ProposalService : IProposalService
_db.Proposals.Add(proposal);
await _db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
await _audit.LogAsync(AuditAction.Submit, proposal.Id, null, ct);
try
{
await _audit.LogAsync(AuditAction.Submit, proposal.Id, null, ct);
}
catch { }
await _jobPublisher.PublishAsync("suggestions", new { proposalId = proposal.Id, trigger = "generate" }, ct);
try
{
await _jobPublisher.PublishAsync("suggestions", new { proposalId = proposal.Id, trigger = "generate" }, ct);
}
catch { }
return MapToResponse(proposal);
}
@ -69,17 +80,23 @@ public class ProposalService : IProposalService
.Include(p => p.ApprovedBy)
.FirstOrDefaultAsync(p => p.Id == id, ct);
return proposal == null ? null : MapToResponse(proposal);
if (proposal == null) return null;
if (_currentUser.Role == UserRole.Dispatcher && proposal.SubmittedById != _currentUser.UserId)
return null;
return MapToResponse(proposal);
}
public async Task<PagedResponse<ProposalListResponse>> GetAllAsync(ProposalFilterRequest filter, CancellationToken ct = default)
{
var page = Math.Max(1, filter.Page);
var pageSize = Math.Clamp(filter.PageSize, 1, 100);
var query = _db.Proposals
.Include(p => p.SubmittedBy)
.Include(p => p.AssignedAdmin)
.AsQueryable();
if (_currentUser.Role == UserRole.Dispatcher)
if (_currentUser.Role == UserRole.Dispatcher || filter.Mine)
{
query = query.Where(p => p.SubmittedById == _currentUser.UserId);
}
@ -113,8 +130,8 @@ public class ProposalService : IProposalService
var items = await query
.OrderByDescending(p => p.Priority)
.ThenByDescending(p => p.SubmittedAt)
.Skip((filter.Page - 1) * filter.PageSize)
.Take(filter.PageSize)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(p => new ProposalListResponse(
p.Id,
p.ProposalNumber,
@ -130,12 +147,16 @@ public class ProposalService : IProposalService
))
.ToListAsync(ct);
return new PagedResponse<ProposalListResponse>(items, totalCount, filter.Page, filter.PageSize);
return new PagedResponse<ProposalListResponse>(items, totalCount, page, pageSize);
}
public async Task<ProposalResponse> UpdateAsync(Guid id, UpdateProposalRequest request, CancellationToken ct = default)
{
var proposal = await _db.Proposals.FindAsync(new object[] { id }, ct)
var proposal = await _db.Proposals
.Include(p => p.SubmittedBy)
.Include(p => p.AssignedAdmin)
.Include(p => p.ApprovedBy)
.FirstOrDefaultAsync(p => p.Id == id, ct)
?? throw new KeyNotFoundException($"Proposal {id} not found");
if (request.RefinedScope != null)
@ -147,8 +168,8 @@ public class ProposalService : IProposalService
if (request.AssignedAdminId.HasValue)
proposal.AssignedAdminId = request.AssignedAdminId.Value;
if (request.Status.HasValue && proposal.Status == ProposalStatus.Draft
&& request.Status.Value == ProposalStatus.InReview)
if (request.Status.HasValue && request.Status.Value == ProposalStatus.InReview
&& (proposal.Status == ProposalStatus.Draft || proposal.Status == ProposalStatus.Revised))
proposal.Status = request.Status.Value;
proposal.UpdatedAt = DateTime.UtcNow;
@ -163,9 +184,14 @@ public class ProposalService : IProposalService
{
var proposal = await _db.Proposals
.Include(p => p.LineItems)
.Include(p => p.SubmittedBy)
.Include(p => p.ApprovedBy)
.FirstOrDefaultAsync(p => p.Id == id, ct)
?? throw new KeyNotFoundException($"Proposal {id} not found");
if (proposal.Status == ProposalStatus.Approved)
return MapToResponse(proposal);
if (proposal.Status != ProposalStatus.InReview)
throw new InvalidOperationException("Only proposals in review can be approved");
@ -186,9 +212,15 @@ public class ProposalService : IProposalService
public async Task<ProposalResponse> MarkSentAsync(Guid id, CancellationToken ct = default)
{
var proposal = await _db.Proposals.FindAsync(new object[] { id }, ct)
var proposal = await _db.Proposals
.Include(p => p.SubmittedBy)
.Include(p => p.ApprovedBy)
.FirstOrDefaultAsync(p => p.Id == id, ct)
?? throw new KeyNotFoundException($"Proposal {id} not found");
if (proposal.Status == ProposalStatus.Sent)
return MapToResponse(proposal);
if (proposal.Status != ProposalStatus.Approved)
throw new InvalidOperationException("Only approved proposals can be marked as sent");
@ -211,13 +243,21 @@ public class ProposalService : IProposalService
.FirstOrDefaultAsync(p => p.Id == id, ct)
?? throw new KeyNotFoundException($"Proposal {id} not found");
if (proposal.Status == ProposalStatus.Revised)
{
var existingRevision = await _db.Proposals
.FirstOrDefaultAsync(p => p.ParentProposalId == proposal.Id, ct);
if (existingRevision != null)
return MapToResponse(existingRevision);
}
if (proposal.Status != ProposalStatus.Sent)
throw new InvalidOperationException("Only sent proposals can be revised");
var revision = new Proposal
{
Id = Guid.NewGuid(),
ProposalNumber = proposal.ProposalNumber,
ProposalNumber = $"{proposal.ProposalNumber}-R{proposal.CurrentRevision + 1}",
WorkOrderNumber = proposal.WorkOrderNumber,
CustomerName = proposal.CustomerName,
CustomerAddress = proposal.CustomerAddress,
@ -227,6 +267,7 @@ public class ProposalService : IProposalService
Priority = proposal.Priority,
Status = ProposalStatus.InReview,
Notes = proposal.Notes,
TotalBidAmount = proposal.TotalBidAmount,
SubmittedById = proposal.SubmittedById,
SubmittedAt = proposal.SubmittedAt,
AssignedAdminId = _currentUser.UserId,

View file

@ -9,10 +9,12 @@ namespace ProposalSystem.Infrastructure.Services;
public class SimilarProposalService : ISimilarProposalService
{
private readonly ProposalDbContext _db;
private readonly ICurrentUserService _currentUser;
public SimilarProposalService(ProposalDbContext db)
public SimilarProposalService(ProposalDbContext db, ICurrentUserService currentUser)
{
_db = db;
_currentUser = currentUser;
}
public async Task<IReadOnlyList<SimilarProposalResponse>> GetSimilarProposalsAsync(
@ -25,15 +27,23 @@ public class SimilarProposalService : ISimilarProposalService
.Take(5)
.ToListAsync(ct);
var results = new List<SimilarProposalResponse>();
if (references.Count == 0)
return [];
var libraryItemIds = references.Select(r => r.ReferencedLibraryItemId).ToList();
var proposals = await _db.Proposals
.Include(p => p.LineItems)
.Where(p => libraryItemIds.Contains(p.ProposalNumber))
.ToListAsync(ct);
var proposalsByNumber = proposals.ToDictionary(p => p.ProposalNumber);
var results = new List<SimilarProposalResponse>();
foreach (var reference in references)
{
var referencedProposal = await _db.Proposals
.Include(p => p.LineItems)
.FirstOrDefaultAsync(p => p.ProposalNumber == reference.ReferencedLibraryItemId, ct);
if (referencedProposal == null) continue;
if (!proposalsByNumber.TryGetValue(reference.ReferencedLibraryItemId, out var referencedProposal))
continue;
results.Add(new SimilarProposalResponse(
referencedProposal.ProposalNumber,
@ -69,7 +79,7 @@ public class SimilarProposalService : ISimilarProposalService
ReferencedLibraryItemId = request.ReferencedLibraryItemId,
SimilarityScore = request.SimilarityScore,
ReferencedAt = DateTime.UtcNow,
ReferencedById = Guid.Empty,
ReferencedById = _currentUser.UserId,
};
_db.SimilarProposalReferences.Add(reference);

View file

@ -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:

View file

@ -28,6 +28,9 @@ 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,
});
new FrontendStack(app, 'proposal-system-frontend', {

View file

@ -2,16 +2,21 @@ import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2';
import * as apigatewayv2Authorizers from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
import * as apigatewayv2Integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
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';
export interface ComputeStackProps extends cdk.StackProps {
@ -23,6 +28,9 @@ export interface ComputeStackProps extends cdk.StackProps {
libraryBucket: s3.IBucket;
jobsQueue: sqs.IQueue;
userPool: cognito.IUserPool;
alarmTopic: sns.ITopic;
webClientId: string;
mobileClientId: string;
}
export class ComputeStack extends cdk.Stack {
@ -56,7 +64,6 @@ export class ComputeStack extends cdk.Stack {
policy: JSON.stringify([{
Rules: [
{ ResourceType: 'collection', Resource: ['collection/proposal-system-kb'] },
{ ResourceType: 'dashboard', Resource: ['collection/proposal-system-kb'] },
],
AllowFromPublic: true,
}]),
@ -90,8 +97,31 @@ export class ComputeStack extends cdk.Stack {
resources: [`arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0`],
}));
// OpenSearch Serverless data access policy
new opensearchserverless.CfnAccessPolicy(this, 'OssDataAccessPolicy', {
// Lambda to pre-create the vector index (retries until AOSS access policy propagates)
const indexCreatorFn = new lambda.Function(this, 'OssIndexCreator', {
functionName: 'proposal-system-oss-index-creator',
runtime: lambda.Runtime.PYTHON_3_12,
architecture: lambda.Architecture.ARM_64,
handler: 'app.handler',
code: lambda.Code.fromAsset('../lambdas/oss-index-creator', {
bundling: {
image: lambda.Runtime.PYTHON_3_12.bundlingImage,
command: [
'bash', '-c',
'pip install -r requirements.txt -t /asset-output && cp -au . /asset-output',
],
},
}),
timeout: cdk.Duration.minutes(6),
logRetention: logs.RetentionDays.TWO_MONTHS,
});
indexCreatorFn.addToRolePolicy(new iam.PolicyStatement({
actions: ['aoss:APIAccessAll'],
resources: [ossCollection.attrArn],
}));
const ossDataAccessPolicy = new opensearchserverless.CfnAccessPolicy(this, 'OssDataAccessPolicy', {
name: 'proposal-system-kb-access',
type: 'data',
policy: JSON.stringify([{
@ -99,11 +129,27 @@ export class ComputeStack extends cdk.Stack {
{ ResourceType: 'collection', Resource: ['collection/proposal-system-kb'], Permission: ['aoss:*'] },
{ ResourceType: 'index', Resource: ['index/proposal-system-kb/*'], Permission: ['aoss:*'] },
],
Principal: [kbRole.roleArn, `arn:aws:iam::${this.account}:root`],
Principal: [kbRole.roleArn, indexCreatorFn.role!.roleArn],
}]),
});
ossDataAccessPolicy.addDependency(ossCollection);
const indexProvider = new cr.Provider(this, 'OssIndexProvider', {
onEventHandler: indexCreatorFn,
});
const ossIndex = new cdk.CustomResource(this, 'OssIndex', {
serviceToken: indexProvider.serviceToken,
properties: {
Endpoint: ossCollection.attrCollectionEndpoint,
IndexName: 'proposal-system-index',
VectorField: 'embedding',
TextField: 'text',
MetadataField: 'metadata',
},
});
ossIndex.node.addDependency(ossDataAccessPolicy);
// Bedrock Knowledge Base
const knowledgeBase = new bedrock.CfnKnowledgeBase(this, 'KnowledgeBase', {
name: 'proposal-system-kb',
roleArn: kbRole.roleArn,
@ -126,6 +172,7 @@ export class ComputeStack extends cdk.Stack {
},
},
});
knowledgeBase.node.addDependency(ossIndex);
// KB Data Source (S3 library bucket)
const dataSource = new bedrock.CfnDataSource(this, 'KbDataSource', {
@ -168,6 +215,11 @@ export class ComputeStack extends cdk.Stack {
LIBRARY_BUCKET: props.libraryBucket.bucketName,
JOBS_QUEUE_URL: props.jobsQueue.queueUrl,
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
Auth__Authority: `https://cognito-idp.${this.region}.amazonaws.com/${props.userPool.userPoolId}`,
Auth__ClientId: props.webClientId,
Auth__CognitoDomain: `proposal-system-seahaven.auth.${this.region}.amazoncognito.com`,
COGNITO_WEB_CLIENT_ID: props.webClientId,
COGNITO_MOBILE_CLIENT_ID: props.mobileClientId,
},
tracing: lambda.Tracing.ACTIVE,
logRetention: logs.RetentionDays.TWO_MONTHS,
@ -185,6 +237,11 @@ export class ComputeStack extends cdk.Stack {
resources: [props.userPool.userPoolArn],
}));
// Function URL for internal Lambda-to-API calls (bypasses API Gateway JWT authorizer)
const apiFunctionUrl = apiFunction.addFunctionUrl({
authType: lambda.FunctionUrlAuthType.NONE,
});
// API Gateway HTTP API
const httpApi = new apigatewayv2.HttpApi(this, 'HttpApi', {
apiName: 'proposal-system-gateway',
@ -205,15 +262,40 @@ export class ComputeStack extends cdk.Stack {
},
});
const defaultStage = httpApi.defaultStage!.node.defaultChild as apigatewayv2.CfnStage;
defaultStage.defaultRouteSettings = {
throttlingBurstLimit: 50,
throttlingRateLimit: 100,
};
const apiIntegration = new apigatewayv2Integrations.HttpLambdaIntegration(
'ApiIntegration',
apiFunction
);
const jwtAuthorizer = new apigatewayv2Authorizers.HttpJwtAuthorizer(
'CognitoAuthorizer',
`https://cognito-idp.${this.region}.amazonaws.com/${props.userPool.userPoolId}`,
{ jwtAudience: [props.webClientId, props.mobileClientId] },
);
httpApi.addRoutes({
path: '/api/health',
methods: [apigatewayv2.HttpMethod.GET],
integration: apiIntegration,
});
httpApi.addRoutes({
path: '/api/auth/{proxy+}',
methods: [apigatewayv2.HttpMethod.POST],
integration: apiIntegration,
});
httpApi.addRoutes({
path: '/{proxy+}',
methods: [apigatewayv2.HttpMethod.ANY],
integration: apiIntegration,
authorizer: jwtAuthorizer,
});
// Python Lambda: Suggestions Engine
@ -231,7 +313,7 @@ export class ComputeStack extends cdk.Stack {
environment: {
KNOWLEDGE_BASE_ID: knowledgeBase.attrKnowledgeBaseId,
MODEL_ID: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
API_BASE_URL: httpApi.apiEndpoint,
API_BASE_URL: apiFunctionUrl.url,
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
},
logRetention: logs.RetentionDays.TWO_MONTHS,
@ -262,7 +344,7 @@ export class ComputeStack extends cdk.Stack {
environment: {
UPLOADS_BUCKET: props.uploadsBucket.bucketName,
MODEL_ID: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
API_BASE_URL: httpApi.apiEndpoint,
API_BASE_URL: apiFunctionUrl.url,
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
},
logRetention: logs.RetentionDays.TWO_MONTHS,
@ -289,7 +371,7 @@ export class ComputeStack extends cdk.Stack {
securityGroups: [props.lambdaSecurityGroup],
environment: {
GENERATED_BUCKET: props.generatedBucket.bucketName,
API_BASE_URL: httpApi.apiEndpoint,
API_BASE_URL: apiFunctionUrl.url,
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
},
logRetention: logs.RetentionDays.TWO_MONTHS,
@ -314,7 +396,7 @@ export class ComputeStack extends cdk.Stack {
LIBRARY_BUCKET: props.libraryBucket.bucketName,
KNOWLEDGE_BASE_ID: knowledgeBase.attrKnowledgeBaseId,
DATA_SOURCE_ID: dataSource.attrDataSourceId,
API_BASE_URL: httpApi.apiEndpoint,
API_BASE_URL: apiFunctionUrl.url,
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
},
logRetention: logs.RetentionDays.TWO_MONTHS,
@ -330,6 +412,7 @@ export class ComputeStack extends cdk.Stack {
// SQS Event Sources with message filtering
suggestionsFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, {
batchSize: 1,
reportBatchItemFailures: true,
filters: [
lambda.FilterCriteria.filter({
body: { jobType: lambda.FilterRule.isEqual('suggestions') },
@ -339,6 +422,7 @@ export class ComputeStack extends cdk.Stack {
pdfExtractFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, {
batchSize: 1,
reportBatchItemFailures: true,
filters: [
lambda.FilterCriteria.filter({
body: { jobType: lambda.FilterRule.isEqual('pdf-extract') },
@ -348,6 +432,7 @@ export class ComputeStack extends cdk.Stack {
pdfGenerateFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, {
batchSize: 1,
reportBatchItemFailures: true,
filters: [
lambda.FilterCriteria.filter({
body: { jobType: lambda.FilterRule.isEqual('pdf-generate') },
@ -357,6 +442,7 @@ export class ComputeStack extends cdk.Stack {
libraryIngestFunction.addEventSource(new lambdaEventSources.SqsEventSource(props.jobsQueue, {
batchSize: 1,
reportBatchItemFailures: true,
filters: [
lambda.FilterCriteria.filter({
body: { jobType: lambda.FilterRule.isEqual('library-ingest') },
@ -364,6 +450,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 });

View file

@ -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,9 @@ 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;
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
@ -118,7 +125,10 @@ export class FoundationStack extends cdk.Stack {
cors: [
{
allowedMethods: [s3.HttpMethods.PUT, s3.HttpMethods.POST],
allowedOrigins: ['*'],
allowedOrigins: [
'https://proposals.seahaven.com',
'http://localhost:5173',
],
allowedHeaders: ['*'],
maxAge: 3600,
},
@ -156,7 +166,7 @@ export class FoundationStack extends cdk.Stack {
this.jobsQueue = new sqs.Queue(this, 'JobsQueue', {
queueName: 'proposal-system-jobs',
visibilityTimeout: cdk.Duration.seconds(180),
visibilityTimeout: cdk.Duration.seconds(720),
deadLetterQueue: {
queue: dlq,
maxReceiveCount: 3,
@ -210,7 +220,7 @@ export class FoundationStack extends cdk.Stack {
});
// Web App Client (PKCE)
userPool.addClient('WebClient', {
const webClient = userPool.addClient('WebClient', {
userPoolClientName: 'proposal-system-web',
generateSecret: false,
authFlows: {
@ -234,8 +244,10 @@ export class FoundationStack extends cdk.Stack {
},
});
this.webClientId = webClient.userPoolClientId;
// Mobile App Client (PKCE)
userPool.addClient('MobileClient', {
const mobileClient = userPool.addClient('MobileClient', {
userPoolClientName: 'proposal-system-mobile',
generateSecret: false,
authFlows: {
@ -248,11 +260,72 @@ export class FoundationStack extends cdk.Stack {
cognito.OAuthScope.EMAIL,
cognito.OAuthScope.PROFILE,
],
callbackUrls: ['proposalsystem://callback'],
logoutUrls: ['proposalsystem://logout'],
callbackUrls: ['com.seahavenind.proposals://auth/callback'],
logoutUrls: ['com.seahavenind.proposals://auth/logout'],
},
});
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',
@ -278,5 +351,8 @@ export class FoundationStack extends cdk.Stack {
new cdk.CfnOutput(this, 'LibraryBucketName', { value: this.libraryBucket.bucketName });
new cdk.CfnOutput(this, 'JobsQueueUrl', { value: this.jobsQueue.queueUrl });
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 });
}
}

View file

@ -8,12 +8,12 @@
"name": "proposal-system-infra",
"version": "1.0.0",
"dependencies": {
"aws-cdk-lib": "2.253.1",
"aws-cdk-lib": "2.257.0",
"constructs": "^10.4.2"
},
"devDependencies": {
"@types/node": "^22.0.0",
"aws-cdk": "^2.1122.0",
"aws-cdk": "^2.1124.1",
"typescript": "~5.7.0"
}
},
@ -30,16 +30,16 @@
"license": "Apache-2.0"
},
"node_modules/@aws-cdk/cloud-assembly-schema": {
"version": "53.24.0",
"resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-53.24.0.tgz",
"integrity": "sha512-rCwsd0Y9z4CUmV5FhzjbO2MprXjKG0rxCACuLEY40lD+wInvgcHer0lSDo7Xq9Sxe/IoNe/Wxb2YP3GgxvT3GA==",
"version": "53.27.0",
"resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-53.27.0.tgz",
"integrity": "sha512-OTxe/L2Vc60r2nYih7kFaFpn93sa7shJu9T0tQZsryeDE3HHOAuazKNmS6tLInOjJjVx/dvM5XGyUA+lZAiKxA==",
"bundleDependencies": [
"jsonschema",
"semver"
],
"license": "Apache-2.0",
"dependencies": {
"jsonschema": "~1.4.1",
"jsonschema": "^1.5.0",
"semver": "^7.8.0"
},
"engines": {
@ -47,7 +47,7 @@
}
},
"node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": {
"version": "1.4.1",
"version": "1.5.0",
"inBundle": true,
"license": "MIT",
"engines": {
@ -76,9 +76,9 @@
}
},
"node_modules/aws-cdk": {
"version": "2.1122.0",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1122.0.tgz",
"integrity": "sha512-AI2Ks9qioWLvBPD4IoEtTet3wUG/o/q6U3WR3VCQKH5sNYLLPALo8o9sermNpMnfd1OQkqhL20tp4cEyojrIZg==",
"version": "2.1124.1",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1124.1.tgz",
"integrity": "sha512-sRYdPMdkX+02EHaT946AFV0w0CMfbHKWpLZPv525xTCkaVu1eYu6DzHFuTdimxdSN0uGQ2D4LHrD1sr94tRhow==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -89,9 +89,9 @@
}
},
"node_modules/aws-cdk-lib": {
"version": "2.253.1",
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.253.1.tgz",
"integrity": "sha512-vy+hA15/ZfSQpivkNdlIn2ZDA2hesp3WJgmtIZJDFwu6xzwv7wH7glbAdu5xCHGcOjepOaTKZSvCPC6sN+0/Vw==",
"version": "2.257.0",
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.257.0.tgz",
"integrity": "sha512-GoHfWklrBJcMwLtDlY64pvaT7cD2KyDXC8sik89DR6jHl6nQsBtYTKSJCM+C/k4jgXaecbv8myNX75FySejq0A==",
"bundleDependencies": [
"@balena/dockerignore",
"@aws-cdk/cloud-assembly-api",
@ -110,8 +110,8 @@
"dependencies": {
"@aws-cdk/asset-awscli-v1": "2.2.273",
"@aws-cdk/asset-node-proxy-agent-v6": "^2.1.1",
"@aws-cdk/cloud-assembly-api": "^2.2.2",
"@aws-cdk/cloud-assembly-schema": "^53.18.0",
"@aws-cdk/cloud-assembly-api": "^2.2.4",
"@aws-cdk/cloud-assembly-schema": "^53.25.0",
"@balena/dockerignore": "^1.0.2",
"case": "1.6.3",
"fs-extra": "^11.3.3",
@ -132,22 +132,29 @@
}
},
"node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": {
"version": "2.2.2",
"bundleDependencies": [
"jsonschema",
"semver"
],
"version": "2.2.4",
"inBundle": true,
"license": "Apache-2.0",
"dependencies": {
"jsonschema": "~1.4.1",
"semver": "^7.7.4"
"jsonschema": "^1.5.0",
"semver": "^7.8.0"
},
"engines": {
"node": ">= 18.0.0"
},
"peerDependencies": {
"@aws-cdk/cloud-assembly-schema": ">=53.15.0"
"@aws-cdk/cloud-assembly-schema": ">=53.25.0"
}
},
"node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/semver": {
"version": "7.8.0",
"inBundle": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": {
@ -254,7 +261,7 @@
"license": "MIT"
},
"node_modules/aws-cdk-lib/node_modules/fast-uri": {
"version": "3.1.0",
"version": "3.1.2",
"funding": [
{
"type": "github",

View file

@ -10,12 +10,12 @@
"deploy": "cdk deploy --all --require-approval never"
},
"dependencies": {
"aws-cdk-lib": "2.253.1",
"aws-cdk-lib": "2.257.0",
"constructs": "^10.4.2"
},
"devDependencies": {
"@types/node": "^22.0.0",
"aws-cdk": "^2.1122.0",
"aws-cdk": "^2.1124.1",
"typescript": "~5.7.0"
}
}

View file

@ -42,12 +42,17 @@ def _get_api_key() -> str:
def handler(event, context):
batch_item_failures = []
for record in event.get("Records", []):
body = json.loads(record["body"])
payload = body.get("payload", body)
proposal_id = payload["proposalId"]
process_ingestion(proposal_id)
return {"statusCode": 200}
try:
body = json.loads(record["body"])
payload = body.get("payload", body)
proposal_id = payload["proposalId"]
process_ingestion(proposal_id)
except Exception as e:
logger.error("Failed to process record %s: %s", record.get("messageId"), e)
batch_item_failures.append({"itemIdentifier": record["messageId"]})
return {"batchItemFailures": batch_item_failures}
def process_ingestion(proposal_id: str):
@ -68,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()
@ -82,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()
@ -201,25 +206,20 @@ def _api_headers() -> dict:
return headers
def _api_request(method: str, url: str, retries: int = 3, **kwargs) -> httpx.Response:
kwargs.setdefault("headers", _api_headers())
def _retry_request(
method: str, url: str, *, max_retries: int = 3, **kwargs
) -> httpx.Response:
kwargs.setdefault("timeout", 10)
for attempt in range(retries):
for attempt in range(max_retries):
try:
resp = httpx.request(method, url, **kwargs)
if resp.status_code < 500:
return resp
logger.warning(
"API returned %s on attempt %d for %s",
resp.status_code,
attempt + 1,
url,
)
except httpx.TransportError as e:
logger.warning(
"Transport error on attempt %d for %s: %s", attempt + 1, url, e
)
if attempt == retries - 1:
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]

View file

@ -1,2 +1,2 @@
boto3>=1.43.9,<2.0
httpx>=0.27.0,<1.0
boto3>=1.43.14,<2.0
httpx>=0.28.1,<1.0

View file

@ -0,0 +1,71 @@
import time
import boto3
from opensearchpy import OpenSearch, RequestsHttpConnection
from requests_aws4auth import AWS4Auth
def handler(event, context):
if event["RequestType"] == "Delete":
return {"PhysicalResourceId": event.get("PhysicalResourceId", "none")}
props = event["ResourceProperties"]
endpoint = props["Endpoint"].replace("https://", "")
index_name = props["IndexName"]
vector_field = props["VectorField"]
text_field = props["TextField"]
metadata_field = props["MetadataField"]
session = boto3.Session()
credentials = session.get_credentials().get_frozen_credentials()
region = session.region_name
awsauth = AWS4Auth(
credentials.access_key,
credentials.secret_key,
region,
"aoss",
session_token=credentials.token,
)
client = OpenSearch(
hosts=[{"host": endpoint, "port": 443}],
http_auth=awsauth,
use_ssl=True,
verify_certs=True,
connection_class=RequestsHttpConnection,
timeout=30,
)
index_body = {
"settings": {"index": {"knn": True, "knn.algo_param.ef_search": 512}},
"mappings": {
"properties": {
vector_field: {
"type": "knn_vector",
"dimension": 1024,
"method": {
"engine": "faiss",
"name": "hnsw",
"space_type": "l2",
},
},
text_field: {"type": "text"},
metadata_field: {"type": "text"},
}
},
}
for attempt in range(30):
try:
client.indices.create(index=index_name, body=index_body)
return {"PhysicalResourceId": index_name}
except Exception as e:
error_str = str(e)
if "resource_already_exists_exception" in error_str:
return {"PhysicalResourceId": index_name}
if "403" in error_str and attempt < 29:
time.sleep(10)
continue
raise
raise Exception("Timeout waiting for AOSS access policy propagation")

View file

@ -0,0 +1,3 @@
opensearch-py>=3.2.0
requests-aws4auth>=1.3.2
requests>=2.34.2

View file

@ -42,19 +42,24 @@ def _get_api_key() -> str:
def handler(event, context):
batch_item_failures = []
for record in event.get("Records", []):
body = json.loads(record["body"])
payload = body.get("payload", body)
proposal_id = payload["proposalId"]
s3_key = payload.get("s3Key", "")
vendor_proposal_id = payload.get("vendorProposalId", "")
try:
body = json.loads(record["body"])
payload = body.get("payload", body)
proposal_id = payload["proposalId"]
s3_key = payload.get("s3Key", "")
vendor_proposal_id = payload.get("vendorProposalId", "")
if not s3_key:
logger.warning("No s3Key in payload for proposal %s", proposal_id)
continue
if not s3_key:
logger.warning("No s3Key in payload for proposal %s", proposal_id)
continue
process_pdf(proposal_id, s3_key, vendor_proposal_id)
return {"statusCode": 200}
process_pdf(proposal_id, s3_key, vendor_proposal_id)
except Exception as e:
logger.error("Failed to process record %s: %s", record.get("messageId"), e)
batch_item_failures.append({"itemIdentifier": record["messageId"]})
return {"batchItemFailures": batch_item_failures}
def process_pdf(proposal_id: str, s3_key: str, vendor_proposal_id: str):
@ -300,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"],
@ -309,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(
@ -323,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)
@ -341,25 +346,20 @@ def _api_headers() -> dict:
return headers
def _api_request(method: str, url: str, retries: int = 3, **kwargs) -> httpx.Response:
kwargs.setdefault("headers", _api_headers())
def _retry_request(
method: str, url: str, *, max_retries: int = 3, **kwargs
) -> httpx.Response:
kwargs.setdefault("timeout", 10)
for attempt in range(retries):
for attempt in range(max_retries):
try:
resp = httpx.request(method, url, **kwargs)
if resp.status_code < 500:
return resp
logger.warning(
"API returned %s on attempt %d for %s",
resp.status_code,
attempt + 1,
url,
)
except httpx.TransportError as e:
logger.warning(
"Transport error on attempt %d for %s: %s", attempt + 1, url, e
)
if attempt == retries - 1:
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]

View file

@ -1,3 +1,3 @@
pdfplumber>=0.11.9,<1.0
boto3>=1.43.9,<2.0
httpx>=0.27.0,<1.0
boto3>=1.43.14,<2.0
httpx>=0.28.1,<1.0

View file

@ -66,12 +66,17 @@ def _get_api_key() -> str:
def handler(event, context):
batch_item_failures = []
for record in event.get("Records", []):
body = json.loads(record["body"])
payload = body.get("payload", body)
proposal_id = payload["proposalId"]
generate_pdf(proposal_id)
return {"statusCode": 200}
try:
body = json.loads(record["body"])
payload = body.get("payload", body)
proposal_id = payload["proposalId"]
generate_pdf(proposal_id)
except Exception as e:
logger.error("Failed to process record %s: %s", record.get("messageId"), e)
batch_item_failures.append({"itemIdentifier": record["messageId"]})
return {"batchItemFailures": batch_item_failures}
def generate_pdf(proposal_id: str):
@ -97,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()
@ -111,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()
@ -525,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)
@ -545,25 +550,20 @@ def _api_headers() -> dict:
return headers
def _api_request(method: str, url: str, retries: int = 3, **kwargs) -> httpx.Response:
kwargs.setdefault("headers", _api_headers())
def _retry_request(
method: str, url: str, *, max_retries: int = 3, **kwargs
) -> httpx.Response:
kwargs.setdefault("timeout", 10)
for attempt in range(retries):
for attempt in range(max_retries):
try:
resp = httpx.request(method, url, **kwargs)
if resp.status_code < 500:
return resp
logger.warning(
"API returned %s on attempt %d for %s",
resp.status_code,
attempt + 1,
url,
)
except httpx.TransportError as e:
logger.warning(
"Transport error on attempt %d for %s: %s", attempt + 1, url, e
)
if attempt == retries - 1:
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]

View file

@ -1,3 +1,3 @@
reportlab>=4.5.1,<5.0
boto3>=1.43.9,<2.0
httpx>=0.27.0,<1.0
boto3>=1.43.14,<2.0
httpx>=0.28.1,<1.0

View file

@ -39,13 +39,18 @@ def _get_api_key() -> str:
def handler(event, context):
batch_item_failures = []
for record in event.get("Records", []):
body = json.loads(record["body"])
payload = body.get("payload", body)
proposal_id = payload["proposalId"]
trigger = payload.get("trigger", "generate")
process_suggestion(proposal_id, trigger)
return {"statusCode": 200}
try:
body = json.loads(record["body"])
payload = body.get("payload", body)
proposal_id = payload["proposalId"]
trigger = payload.get("trigger", "generate")
process_suggestion(proposal_id, trigger)
except Exception as e:
logger.error("Failed to process record %s: %s", record.get("messageId"), e)
batch_item_failures.append({"itemIdentifier": record["messageId"]})
return {"batchItemFailures": batch_item_failures}
def process_suggestion(proposal_id: str, trigger: str):
@ -64,19 +69,24 @@ def process_suggestion(proposal_id: str, trigger: str):
suggested_items = generate_line_items(scope, category, priority, similar_proposals)
if not suggested_items and not existing_items:
logger.warning(
"No suggestions generated and no existing items for %s, skipping status update",
proposal_id,
)
return
post_line_items(proposal_id, suggested_items, existing_items)
store_similar_references(proposal_id, similar_proposals)
update_status_to_in_review(proposal_id)
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()
@ -87,10 +97,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()
@ -264,7 +274,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(),
@ -289,31 +300,19 @@ 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)
def update_status_to_in_review(proposal_id: str):
try:
httpx.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)
def _api_headers() -> dict:
headers = {"Content-Type": "application/json"}
api_key = _get_api_key()
@ -322,25 +321,20 @@ def _api_headers() -> dict:
return headers
def _api_request(method: str, url: str, retries: int = 3, **kwargs) -> httpx.Response:
kwargs.setdefault("headers", _api_headers())
def _retry_request(
method: str, url: str, *, max_retries: int = 3, **kwargs
) -> httpx.Response:
kwargs.setdefault("timeout", 10)
for attempt in range(retries):
for attempt in range(max_retries):
try:
resp = httpx.request(method, url, **kwargs)
if resp.status_code < 500:
return resp
logger.warning(
"API returned %s on attempt %d for %s",
resp.status_code,
attempt + 1,
url,
)
except httpx.TransportError as e:
logger.warning(
"Transport error on attempt %d for %s: %s", attempt + 1, url, e
)
if attempt == retries - 1:
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]

View file

@ -1,2 +1,2 @@
boto3>=1.35.0,<2.0
httpx>=0.27.0,<1.0
boto3>=1.43.14,<2.0
httpx>=0.28.1,<1.0

92
mobile/README.md Normal file
View file

@ -0,0 +1,92 @@
# Proposal System — Mobile (iOS)
React Native 0.85 iOS app for Sea Haven Industries field dispatchers. Submit proposals, capture vendor documents, and manage drafts with offline support.
## Prerequisites
- Node.js 24+
- Ruby 3.x (for Fastlane)
- Xcode 26+ with iOS 26 SDK
- CocoaPods (installed via Bundler)
## Local Development
```bash
# Install JS dependencies
npm install
# Install Ruby dependencies (Fastlane, CocoaPods)
bundle install
# Install native pods
cd ios && bundle exec pod install && cd ..
# Start Metro bundler
npm start
# Run on iOS simulator
npm run ios
```
### Environment
The app reads configuration from `src/config.ts`. In development mode (`__DEV__`), the API URL points to `http://localhost:5000/api`. Run the .NET API locally or use the development proxy.
### Authentication
Two login methods are supported:
- **Email/Password** — direct Cognito SRP auth via `amazon-cognito-identity-js`
- **Google OAuth** — Cognito Hosted UI PKCE flow via `react-native-app-auth`
The iOS URL scheme `com.seahavenind.proposals` is registered in `Info.plist` for OAuth callbacks.
## Code Signing
Certificates and provisioning profiles are managed by **Fastlane Match** using S3 storage:
- **Bucket**: `seahaven-ios-certificates` (us-east-1)
- **Bundle ID**: `com.seahavenind.proposals`
- **Team ID**: `9KAQYC653W`
Match is configured in `fastlane/Matchfile`. The `MATCH_PASSWORD` secret decrypts signing assets.
## CI/CD
The `deploy-mobile.yaml` workflow triggers on push to `main` (with `mobile/**` path filter) or manual `workflow_dispatch`. It calls the `cd-mobile-ios.yaml` reusable workflow which:
1. Sets up `macos-26` runner with Xcode 26
2. Installs dependencies and pods
3. Retrieves signing assets via Match (S3)
4. Builds the IPA with Fastlane
5. Uploads to TestFlight
### Required GitHub Secrets
| Secret | Purpose |
|---|---|
| `AWS_DEPLOY_ROLE_ARN` | OIDC role for Match S3 access |
| `MATCH_PASSWORD` | Signing asset decryption passphrase |
| `ASC_KEY_ID` | App Store Connect API key ID |
| `ASC_ISSUER_ID` | App Store Connect API issuer |
| `ASC_KEY_CONTENT` | App Store Connect `.p8` key (base64) |
## Project Structure
```
mobile/
├── src/
│ ├── screens/ Auth, dispatcher, and admin screens
│ ├── lib/api/ API clients (auth, proposals, line items, admin)
│ ├── store/ Redux Toolkit (auth slice)
│ ├── navigation/ React Navigation (RootNavigator)
│ ├── components/ Reusable UI components
│ ├── hooks/ useAuth, useOfflineDraft, usePaginatedList
│ ├── theme/ Material Design 3 theming
│ ├── constants/ App-wide constants
│ └── config.ts Cognito + API configuration
├── ios/ Xcode project, assets, Info.plist
├── fastlane/ Fastfile, Matchfile, Appfile
├── Gemfile Ruby dependencies
└── package.json React Native 0.85.3
```

View file

@ -1,18 +1,137 @@
require 'openssl'
require 'base64'
require 'tempfile'
# OpenSSL 3.x rejects PKCS#8 keys via EC.new ("invalid curve name").
# Prepend a wrapper that falls back to PKey.read for both formats.
module OpenSSLECNewFix
def new(arg = nil, *rest)
super
rescue OpenSSL::PKey::ECError
raise if arg.nil? || !arg.is_a?(String)
OpenSSL::PKey.read(arg)
end
end
OpenSSL::PKey::EC.singleton_class.prepend(OpenSSLECNewFix)
def normalize_p8_key(raw)
candidates = []
cleaned = raw.gsub("\r", "")
with_newlines = cleaned.gsub('\n', "\n")
candidates << ["raw (newlines normalized)", with_newlines]
if with_newlines.include?("BEGIN")
b64_body = with_newlines.gsub(/-----(?:BEGIN|END)[^-]+-----/, '').gsub(/\s+/, '')
rewrapped = "-----BEGIN PRIVATE KEY-----\n#{b64_body.scan(/.{1,64}/).join("\n")}\n-----END PRIVATE KEY-----\n"
candidates << ["rewrapped PEM", rewrapped]
end
unless with_newlines.include?("BEGIN")
body = cleaned.strip.gsub(/\s+/, '')
pem = "-----BEGIN PRIVATE KEY-----\n#{body.scan(/.{1,64}/).join("\n")}\n-----END PRIVATE KEY-----\n"
candidates << ["headerless body → PEM", pem]
end
begin
decoded = Base64.decode64(cleaned.strip)
if decoded.include?("BEGIN")
decoded_clean = decoded.gsub("\r", "").gsub('\n', "\n")
candidates << ["base64→PEM", decoded_clean]
b64_body = decoded_clean.gsub(/-----(?:BEGIN|END)[^-]+-----/, '').gsub(/\s+/, '')
rewrapped = "-----BEGIN PRIVATE KEY-----\n#{b64_body.scan(/.{1,64}/).join("\n")}\n-----END PRIVATE KEY-----\n"
candidates << ["base64→PEM rewrapped", rewrapped]
elsif decoded.length.between?(32, 256)
candidates << ["base64→DER", decoded]
der_pem = "-----BEGIN PRIVATE KEY-----\n#{Base64.strict_encode64(decoded).scan(/.{1,64}/).join("\n")}\n-----END PRIVATE KEY-----\n"
candidates << ["base64→DER→PEM", der_pem]
end
rescue StandardError
# not valid base64
end
begin
double = Base64.decode64(Base64.decode64(cleaned.strip).strip)
if double.include?("BEGIN")
candidates << ["double-base64→PEM", double.gsub("\r", "")]
elsif double.length.between?(32, 256)
der_pem = "-----BEGIN PRIVATE KEY-----\n#{Base64.strict_encode64(double).scan(/.{1,64}/).join("\n")}\n-----END PRIVATE KEY-----\n"
candidates << ["double-base64→DER→PEM", der_pem]
end
rescue StandardError
# not double-encoded
end
candidates.each do |name, content|
begin
OpenSSL::PKey.read(content)
UI.success("ASC key parsed with strategy: #{name}")
return content
rescue StandardError => e
UI.message("Strategy '#{name}' failed: #{e.class} — #{e.message}")
end
end
UI.error("=== ASC KEY DIAGNOSTIC (no key material shown) ===")
UI.error("Raw byte length: #{raw.bytesize}")
UI.error("Starts with BEGIN: #{raw.strip.start_with?('-----BEGIN')}")
UI.error("Ends with -----: #{raw.strip.end_with?('-----')}")
UI.error("Has real newlines: #{raw.include?("\n")}")
UI.error("Has literal backslash-n: #{raw.include?('\\n')}")
UI.error("Has carriage returns: #{raw.include?("\r")}")
UI.error("Printable ASCII ratio: #{(raw.count(' -~').to_f / raw.bytesize * 100).round(1)}%")
UI.error("Header (first 27 chars): #{raw[0..26]}")
begin
d = Base64.decode64(raw.strip)
hex = d.bytes[0..15].map { |b| format('%02x', b) }.join(' ')
UI.error("After base64 decode — length: #{d.bytesize}, first 16 bytes hex: #{hex}")
rescue StandardError
UI.error("base64 decode raised an exception")
end
UI.error("=== END DIAGNOSTIC ===")
raise "Could not parse ASC_KEY_CONTENT in any known format. See diagnostics above."
end
default_platform(:ios)
platform :ios do
desc "Build and upload to TestFlight"
lane :beta do
setup_ci
setup_ci(force: true)
key_pem = normalize_p8_key(ENV["ASC_KEY_CONTENT"])
key_path = File.join(Dir.tmpdir, "asc_api_key.p8")
File.write(key_path, key_pem)
app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_content: ENV["ASC_KEY_CONTENT"],
is_key_content_base64: true
key_filepath: key_path
)
match(type: "appstore", readonly: true)
File.delete(key_path) if File.exist?(key_path)
match(
type: "appstore",
readonly: true,
keychain_name: "fastlane_tmp_keychain",
keychain_password: ""
)
update_code_signing_settings(
use_automatic_signing: false,
team_id: "9KAQYC653W",
code_sign_identity: "Apple Distribution",
profile_name: "match AppStore com.seahavenind.proposals",
path: "ios/ProposalSystem.xcodeproj"
)
node_path = sh("which node").strip
xcode_env_path = File.join(__dir__, "..", "ios", ".xcode.env.local")
File.write(xcode_env_path, "export NODE_BINARY=#{node_path}\n")
UI.message("NODE_BINARY set to #{node_path} at #{xcode_env_path}")
increment_build_number(
build_number: ENV["GITHUB_RUN_NUMBER"],
@ -24,8 +143,10 @@ platform :ios do
scheme: "ProposalSystem",
configuration: "Release",
export_method: "app-store",
export_team_id: "9KAQYC653W",
output_directory: "build",
output_name: "ProposalSystem.ipa"
output_name: "ProposalSystem.ipa",
xcodebuild_formatter: "cat"
)
upload_to_testflight(skip_waiting_for_build_processing: true)

File diff suppressed because it is too large Load diff

View file

@ -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 {

View file

@ -1,46 +1,55 @@
{
"images" : [
{
"filename" : "Icon-40.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "Icon-60.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "Icon-58.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "Icon-87.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "Icon-80.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "Icon-120.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "Icon-120.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "Icon-180.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "Icon-1024.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 B

View file

@ -8,6 +8,8 @@
<string>Sea Haven Proposals</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconName</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
@ -20,6 +22,17 @@
<string>$(MARKETING_VERSION)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.seahavenind.proposals</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.seahavenind.proposals</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
@ -31,35 +44,14 @@
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>com.seahavenind.proposals</string>
</array>
<key>CFBundleURLName</key>
<string>com.seahavenind.proposals</string>
</dict>
</array>
<key>NSCameraUsageDescription</key>
<string>Sea Haven Proposals needs camera access to capture vendor proposal documents.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Sea Haven Proposals needs photo library access to attach vendor proposal images.</string>
<key>NSFaceIDUsageDescription</key>
<string>Sea Haven Proposals uses Face ID to secure your login session.</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSPhotoLibraryUsageDescription</key>
<string>Sea Haven Proposals needs photo library access to attach vendor proposal images.</string>
<key>RCTNewArchEnabled</key>
<true/>
<key>UIAppFonts</key>
<array>
<string>MaterialCommunityIcons.ttf</string>
@ -82,6 +74,18 @@
<string>FontAwesome6_Brands.ttf</string>
<string>MaterialIcons.ttf</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>

4326
mobile/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,37 +6,42 @@
"start": "react-native start",
"ios": "react-native run-ios",
"android": "react-native run-android",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"postinstall": "patch-package"
},
"dependencies": {
"@proposal-system/api-contracts": "file:../shared/api-contracts",
"@react-native-async-storage/async-storage": "^2.1.0",
"@react-native-community/netinfo": "^11.4.0",
"@react-native-community/netinfo": "^12.0.1",
"@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/native": "^7.0.0",
"@react-navigation/native-stack": "^7.2.0",
"@reduxjs/toolkit": "^2.11.2",
"@tanstack/react-query": "^5.100.10",
"amazon-cognito-identity-js": "^6.3.0",
"axios": "^1.16.0",
"react": "^19.0.0",
"react-native": "^0.79.0",
"react-native-app-auth": "^8.0.0",
"react": "19.2.6",
"react-native": "^0.85.3",
"react-native-app-auth": "^8.3.0",
"react-native-document-picker": "^9.3.0",
"react-native-haptic-feedback": "^2.3.0",
"react-native-image-picker": "^7.2.0",
"react-native-image-picker": "^8.2.1",
"react-native-keychain": "^9.2.0",
"react-native-paper": "^5.13.0",
"react-native-safe-area-context": "^5.0.0",
"react-native-safe-area-context": "^5.8.0",
"react-native-screens": "^4.5.0",
"react-native-share": "^11.0.0",
"react-native-vector-icons": "^10.2.0",
"react-redux": "^9.2.0"
},
"devDependencies": {
"@react-native/babel-preset": "^0.79.0",
"@react-native/metro-config": "^0.79.0",
"@types/react": "^19.0.0",
"@react-native-community/cli": "^20.1.3",
"@react-native-community/cli-platform-ios": "^20.1.3",
"@react-native/babel-preset": "^0.85.3",
"@react-native/metro-config": "^0.85.3",
"@types/react": "^19.2.15",
"@types/react-native-vector-icons": "^6.4.18",
"patch-package": "^8.0.1",
"typescript": "~5.7.0"
}
}

View file

@ -0,0 +1,65 @@
diff --git a/node_modules/@react-native-community/netinfo/ios/RNCConnectionState.m b/node_modules/@react-native-community/netinfo/ios/RNCConnectionState.m
index 5a807a4..94e53f8 100644
--- a/node_modules/@react-native-community/netinfo/ios/RNCConnectionState.m
+++ b/node_modules/@react-native-community/netinfo/ios/RNCConnectionState.m
@@ -47,24 +47,28 @@
_expensive = true;
CTTelephonyNetworkInfo *netinfo = [[CTTelephonyNetworkInfo alloc] init];
- if (netinfo) {
- if ([netinfo.currentRadioAccessTechnology isEqualToString:CTRadioAccessTechnologyGPRS] ||
- [netinfo.currentRadioAccessTechnology isEqualToString:CTRadioAccessTechnologyEdge] ||
- [netinfo.currentRadioAccessTechnology isEqualToString:CTRadioAccessTechnologyCDMA1x]) {
+ if (netinfo && [netinfo respondsToSelector:@selector(currentRadioAccessTechnology)]) {
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wdeprecated-declarations"
+ NSString *radio = netinfo.currentRadioAccessTechnology;
+#pragma clang diagnostic pop
+ if ([radio isEqualToString:CTRadioAccessTechnologyGPRS] ||
+ [radio isEqualToString:CTRadioAccessTechnologyEdge] ||
+ [radio isEqualToString:CTRadioAccessTechnologyCDMA1x]) {
_cellularGeneration = RNCCellularGeneration2g;
- } else if ([netinfo.currentRadioAccessTechnology isEqualToString:CTRadioAccessTechnologyWCDMA] ||
- [netinfo.currentRadioAccessTechnology isEqualToString:CTRadioAccessTechnologyHSDPA] ||
- [netinfo.currentRadioAccessTechnology isEqualToString:CTRadioAccessTechnologyHSUPA] ||
- [netinfo.currentRadioAccessTechnology isEqualToString:CTRadioAccessTechnologyCDMAEVDORev0] ||
- [netinfo.currentRadioAccessTechnology isEqualToString:CTRadioAccessTechnologyCDMAEVDORevA] ||
- [netinfo.currentRadioAccessTechnology isEqualToString:CTRadioAccessTechnologyCDMAEVDORevB] ||
- [netinfo.currentRadioAccessTechnology isEqualToString:CTRadioAccessTechnologyeHRPD]) {
+ } else if ([radio isEqualToString:CTRadioAccessTechnologyWCDMA] ||
+ [radio isEqualToString:CTRadioAccessTechnologyHSDPA] ||
+ [radio isEqualToString:CTRadioAccessTechnologyHSUPA] ||
+ [radio isEqualToString:CTRadioAccessTechnologyCDMAEVDORev0] ||
+ [radio isEqualToString:CTRadioAccessTechnologyCDMAEVDORevA] ||
+ [radio isEqualToString:CTRadioAccessTechnologyCDMAEVDORevB] ||
+ [radio isEqualToString:CTRadioAccessTechnologyeHRPD]) {
_cellularGeneration = RNCCellularGeneration3g;
- } else if ([netinfo.currentRadioAccessTechnology isEqualToString:CTRadioAccessTechnologyLTE]) {
+ } else if ([radio isEqualToString:CTRadioAccessTechnologyLTE]) {
_cellularGeneration = RNCCellularGeneration4g;
} else if (@available(iOS 14.1, *)) {
- if ([netinfo.currentRadioAccessTechnology isEqualToString:CTRadioAccessTechnologyNRNSA] ||
- [netinfo.currentRadioAccessTechnology isEqualToString:CTRadioAccessTechnologyNR]) {
+ if ([radio isEqualToString:CTRadioAccessTechnologyNRNSA] ||
+ [radio isEqualToString:CTRadioAccessTechnologyNR]) {
_cellularGeneration = RNCCellularGeneration5g;
}
}
diff --git a/node_modules/@react-native-community/netinfo/ios/RNCNetInfo.mm b/node_modules/@react-native-community/netinfo/ios/RNCNetInfo.mm
index baa2de0..6b75224 100644
--- a/node_modules/@react-native-community/netinfo/ios/RNCNetInfo.mm
+++ b/node_modules/@react-native-community/netinfo/ios/RNCNetInfo.mm
@@ -190,7 +190,13 @@ RCT_EXPORT_METHOD(configure:(NSDictionary *)config)
return nil;
#else
CTTelephonyNetworkInfo *netinfo = [[CTTelephonyNetworkInfo alloc] init];
+ if (![netinfo respondsToSelector:@selector(subscriberCellularProvider)]) {
+ return nil;
+ }
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wdeprecated-declarations"
CTCarrier *carrier = [netinfo subscriberCellularProvider];
+#pragma clang diagnostic pop
return carrier.carrierName;
#endif
}

View file

@ -1,19 +1,40 @@
import React, { useEffect } from 'react';
import React, { useEffect, Component, ErrorInfo, ReactNode } from 'react';
import { Provider as ReduxProvider, useDispatch } from 'react-redux';
import { PaperProvider } from 'react-native-paper';
import { QueryClientProvider } from '@tanstack/react-query';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import NetInfo from '@react-native-community/netinfo';
import { Alert } from 'react-native';
import { Alert, Text, View } from 'react-native';
import { store } from './store';
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 };
static getDerivedStateFromError(error: Error) { return { error }; }
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('ErrorBoundary caught:', error, info.componentStack);
}
render() {
if (this.state.error) {
return (
<View style={{ flex: 1, justifyContent: 'center', padding: 32, backgroundColor: '#fff' }}>
<Text style={{ fontSize: 18, fontWeight: 'bold', color: 'red', marginBottom: 8 }}>App Error</Text>
<Text style={{ fontSize: 14, color: '#333' }}>{this.state.error.message}</Text>
<Text style={{ fontSize: 12, color: '#666', marginTop: 8 }}>{this.state.error.stack?.slice(0, 500)}</Text>
</View>
);
}
return this.props.children;
}
}
function AuthBootstrap({ children }: { children: React.ReactNode }) {
const dispatch = useDispatch();
@ -36,19 +57,27 @@ function AuthBootstrap({ children }: { children: React.ReactNode }) {
})();
}, [dispatch]);
useEffect(() => {
return onSessionExpired(() => {
dispatch(logoutAction());
});
}, [dispatch]);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected) {
processOfflineQueue((data) =>
proposalsApi.create(data as CreateProposalRequest),
).then((count) => {
if (count > 0) {
Alert.alert(
'Synced',
`${count} offline proposal${count > 1 ? 's' : ''} submitted.`,
);
}
});
)
.then((count) => {
if (count > 0) {
Alert.alert(
'Synced',
`${count} offline proposal${count > 1 ? 's' : ''} submitted.`,
);
}
})
.catch(() => {});
}
});
return unsubscribe;
@ -59,16 +88,18 @@ function AuthBootstrap({ children }: { children: React.ReactNode }) {
export default function App() {
return (
<ReduxProvider store={store}>
<QueryClientProvider client={queryClient}>
<PaperProvider theme={theme}>
<SafeAreaProvider>
<AuthBootstrap>
<RootNavigator />
</AuthBootstrap>
</SafeAreaProvider>
</PaperProvider>
</QueryClientProvider>
</ReduxProvider>
<ErrorBoundary>
<ReduxProvider store={store}>
<QueryClientProvider client={queryClient}>
<PaperProvider theme={theme}>
<SafeAreaProvider>
<AuthBootstrap>
<RootNavigator />
</AuthBootstrap>
</SafeAreaProvider>
</PaperProvider>
</QueryClientProvider>
</ReduxProvider>
</ErrorBoundary>
);
}

View file

@ -2,9 +2,9 @@ const Config = {
API_URL: __DEV__
? 'http://localhost:5000/api'
: 'https://api.proposals.seahaven.com/api',
COGNITO_DOMAIN: 'proposal-system.auth.us-east-1.amazoncognito.com',
COGNITO_CLIENT_ID: '',
COGNITO_USER_POOL_ID: '',
COGNITO_DOMAIN: 'proposal-system-seahaven.auth.us-east-1.amazoncognito.com',
COGNITO_CLIENT_ID: '3egjbljml6o9qg3q155t784018',
COGNITO_USER_POOL_ID: 'us-east-1_DfWcl2q5z',
COGNITO_REDIRECT_URI: 'com.seahavenind.proposals://auth/callback',
COGNITO_SCOPES: ['openid', 'email', 'profile'],
};

View file

@ -21,6 +21,7 @@ export function usePaginatedList<T>(
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [err, setErr] = useState('');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -37,8 +38,7 @@ export function usePaginatedList<T>(
};
}, [search]);
const reload = useCallback(() => {
setLoading(true);
const fetchData = useCallback(() => {
setErr('');
fetchFn({ search: debouncedSearch, page, pageSize, ...extraParams })
.then(({ items, totalCount: total }) => {
@ -46,13 +46,22 @@ export function usePaginatedList<T>(
setTotalCount(total);
})
.catch((e: Error) => setErr(e.message || 'Failed to load'))
.finally(() => setLoading(false));
.finally(() => {
setLoading(false);
setRefreshing(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchFn, debouncedSearch, page, pageSize, extraKey]);
useEffect(() => {
reload();
}, [reload]);
setLoading(true);
fetchData();
}, [fetchData]);
const reload = useCallback(() => {
setRefreshing(true);
fetchData();
}, [fetchData]);
return useMemo(
() => ({
@ -65,9 +74,10 @@ export function usePaginatedList<T>(
setPageSize,
totalCount,
loading,
refreshing,
err,
reload,
}),
[rows, search, page, pageSize, totalCount, loading, err, reload],
[rows, search, page, pageSize, totalCount, loading, refreshing, err, reload],
);
}

View file

@ -1,6 +1,7 @@
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,
userStorage,
@ -8,6 +9,25 @@ import {
StoredUser,
} from '../storage';
function parseUserFromIdToken(idToken: string): StoredUser {
const base64Url = idToken.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
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('sysadmins')
? 'SysAdmin'
: groups.includes('admins')
? 'Admin'
: 'Dispatcher';
return {
id: payload.sub,
email: payload.email,
displayName: payload.name || payload.email,
role: role as StoredUser['role'],
};
}
const cognitoConfig = {
clientId: Config.COGNITO_CLIENT_ID,
redirectUrl: Config.COGNITO_REDIRECT_URI,
@ -38,6 +58,30 @@ export const authApi = {
return profile;
},
loginWithCredentials: async (
email: string,
password: string,
): Promise<StoredUser> => {
const cognitoTokens = await authenticateWithCredentials(email, password);
const tokens: StoredTokens = {
accessToken: cognitoTokens.accessToken,
idToken: cognitoTokens.idToken,
refreshToken: cognitoTokens.refreshToken,
expiresAt: cognitoTokens.expiresAt,
};
await tokenStorage.save(tokens);
let profile: StoredUser;
try {
profile = await authApi.getMe();
} catch {
profile = parseUserFromIdToken(cognitoTokens.idToken);
}
await userStorage.save(profile);
return profile;
},
refreshTokens: async (): Promise<void> => {
const stored = await tokenStorage.get();
if (!stored?.refreshToken) throw new Error('No refresh token');
@ -76,3 +120,5 @@ export const authApi = {
return res.data;
},
};
registerTokenRefresh(() => authApi.refreshTokens());

View file

@ -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) {

View file

@ -0,0 +1,67 @@
import Config from '../../config';
export interface CognitoTokens {
accessToken: string;
idToken: string;
refreshToken: string;
expiresAt: string;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractTokens(session: any): CognitoTokens {
const accessToken = session.getAccessToken();
return {
accessToken: accessToken.getJwtToken(),
idToken: session.getIdToken().getJwtToken(),
refreshToken: session.getRefreshToken().getToken(),
expiresAt: new Date(accessToken.getExpiration() * 1000).toISOString(),
};
}
export async function authenticateWithCredentials(
email: string,
password: string,
): Promise<CognitoTokens> {
const {
CognitoUserPool,
CognitoUser,
AuthenticationDetails,
} = await import('amazon-cognito-identity-js');
const userPool = new CognitoUserPool({
UserPoolId: Config.COGNITO_USER_POOL_ID,
ClientId: Config.COGNITO_CLIENT_ID,
});
return new Promise((resolve, reject) => {
const cognitoUser = new CognitoUser({
Username: email,
Pool: userPool,
});
const authDetails = new AuthenticationDetails({
Username: email,
Password: password,
});
cognitoUser.authenticateUser(authDetails, {
onSuccess: (session) => {
resolve(extractTokens(session));
},
onFailure: (err) => {
if (err.code === 'NotAuthorizedException') {
reject(new Error('Incorrect email or password.'));
} else if (err.code === 'UserNotFoundException') {
reject(new Error('No account found with that email.'));
} else if (err.code === 'UserNotConfirmedException') {
reject(new Error('Account not confirmed. Contact your administrator.'));
} else {
reject(new Error(err.message || 'Authentication failed.'));
}
},
newPasswordRequired: () => {
reject(new Error('Password change required. Contact your administrator.'));
},
});
});
}

View file

@ -8,7 +8,7 @@ export function SettingsScreen() {
const { user, logout } = useAuth();
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.content}>
<Card style={styles.card} mode="elevated">
<Card.Content>

View file

@ -30,6 +30,7 @@ export function ProposalQueueScreen() {
search,
setSearch,
loading,
refreshing,
reload,
} = usePaginatedList<ProposalListItem>(
(params) =>
@ -90,7 +91,7 @@ export function ProposalQueueScreen() {
<ProposalCard proposal={item} onPress={() => handlePress(item)} />
)}
refreshControl={
<RefreshControl refreshing={loading} onRefresh={reload} />
<RefreshControl refreshing={refreshing} onRefresh={reload} />
}
contentContainerStyle={rows.length === 0 ? styles.empty : styles.list}
ListEmptyComponent={

View file

@ -1,23 +1,67 @@
import React, { useState } from 'react';
import { View, StyleSheet } from 'react-native';
import { Button, Text, Surface } from 'react-native-paper';
import {
View,
StyleSheet,
KeyboardAvoidingView,
Platform,
ScrollView,
} from 'react-native';
import {
Button,
Text,
Surface,
TextInput,
Divider,
HelperText,
} from 'react-native-paper';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useDispatch } from 'react-redux';
import { authApi } from '../../lib/api/auth';
import { setUser, setError } from '../../store/slices/authSlice';
import { setUser, setError, clearError } from '../../store/slices/authSlice';
export function LoginScreen() {
const dispatch = useDispatch();
const [loading, setLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [localError, setLocalError] = useState('');
const handleLogin = async () => {
const handleGoogleLogin = async () => {
setLoading(true);
setLocalError('');
dispatch(clearError());
try {
const user = await authApi.login();
dispatch(setUser(user));
} catch (err) {
const message =
err instanceof Error ? err.message : 'Login failed. Please try again.';
setLocalError(message);
dispatch(setError(message));
} finally {
setLoading(false);
}
};
const handleCredentialLogin = async () => {
if (!email.trim() || !password) {
setLocalError('Email and password are required.');
return;
}
setLoading(true);
setLocalError('');
dispatch(clearError());
try {
const user = await authApi.loginWithCredentials(
email.trim().toLowerCase(),
password,
);
dispatch(setUser(user));
} catch (err) {
const message =
err instanceof Error ? err.message : 'Login failed. Please try again.';
setLocalError(message);
dispatch(setError(message));
} finally {
setLoading(false);
@ -26,28 +70,92 @@ export function LoginScreen() {
return (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
<Surface style={styles.logoContainer} elevation={0}>
<Text variant="headlineLarge" style={styles.brand}>
Sea Haven
</Text>
<Text variant="titleMedium" style={styles.brandSub}>
Industries
</Text>
</Surface>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
<Surface style={styles.logoContainer} elevation={0}>
<Text variant="headlineLarge" style={styles.brand}>
Sea Haven
</Text>
<Text variant="titleMedium" style={styles.brandSub}>
Industries
</Text>
</Surface>
<Text variant="headlineSmall" style={styles.title}>
Proposal System
</Text>
<Text variant="bodyLarge" style={styles.subtitle}>
Submit and manage proposals from anywhere
</Text>
<Text variant="headlineSmall" style={styles.title}>
Proposal System
</Text>
<Text variant="bodyLarge" style={styles.subtitle}>
Submit and manage proposals from anywhere
</Text>
<View style={styles.form}>
<TextInput
label="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
textContentType="emailAddress"
mode="outlined"
disabled={loading}
style={styles.input}
/>
<TextInput
label="Password"
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
autoCapitalize="none"
autoComplete="password"
textContentType="password"
mode="outlined"
disabled={loading}
style={styles.input}
right={
<TextInput.Icon
icon={showPassword ? 'eye-off' : 'eye'}
onPress={() => setShowPassword(!showPassword)}
/>
}
/>
{localError ? (
<HelperText type="error" visible>
{localError}
</HelperText>
) : null}
<Button
mode="contained"
onPress={handleCredentialLogin}
loading={loading}
disabled={loading}
contentStyle={styles.buttonContent}
style={styles.button}
>
Sign In
</Button>
</View>
<View style={styles.dividerRow}>
<Divider style={styles.dividerLine} />
<Text variant="bodySmall" style={styles.dividerText}>
OR
</Text>
<Divider style={styles.dividerLine} />
</View>
<View style={styles.actions}>
<Button
mode="contained"
mode="outlined"
icon="google"
onPress={handleLogin}
onPress={handleGoogleLogin}
loading={loading}
disabled={loading}
contentStyle={styles.buttonContent}
@ -55,12 +163,12 @@ export function LoginScreen() {
>
Sign in with Google
</Button>
</View>
<Text variant="bodySmall" style={styles.footer}>
Use your Sea Haven Workspace account
</Text>
</View>
<Text variant="bodySmall" style={styles.footer}>
Use your Sea Haven email to sign in
</Text>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
@ -70,11 +178,15 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: '#FAFAFA',
},
content: {
flex: {
flex: 1,
},
content: {
flexGrow: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 32,
paddingVertical: 24,
},
logoContainer: {
alignItems: 'center',
@ -97,16 +209,33 @@ const styles = StyleSheet.create({
subtitle: {
color: '#757575',
textAlign: 'center',
marginBottom: 48,
marginBottom: 32,
},
actions: {
form: {
width: '100%',
},
input: {
marginBottom: 12,
},
buttonContent: {
paddingVertical: 8,
},
button: {
borderRadius: 8,
width: '100%',
},
dividerRow: {
flexDirection: 'row',
alignItems: 'center',
width: '100%',
marginVertical: 20,
},
dividerLine: {
flex: 1,
},
dividerText: {
color: '#9E9E9E',
marginHorizontal: 16,
},
footer: {
color: '#9E9E9E',

View file

@ -52,7 +52,7 @@ export function DashboardScreen() {
}
>
<Text variant="headlineSmall" style={styles.welcome}>
Welcome, {user?.displayName?.split(' ')[0] || 'there'}
Welcome, {user?.displayName?.includes('@') ? user.displayName.split('@')[0] : user?.displayName?.split(' ')[0] || 'there'}
</Text>
{stats && (

View file

@ -11,6 +11,7 @@ import {
TextInput,
Button,
Text,
Chip,
SegmentedButtons,
Snackbar,
} from 'react-native-paper';
@ -146,16 +147,19 @@ export function NewProposalScreen() {
<Text variant="labelLarge" style={styles.label}>
Service Category
</Text>
<SegmentedButtons
value={draft.serviceCategory}
onValueChange={(v) => updateDraft({ serviceCategory: v })}
buttons={SERVICE_CATEGORIES.map((c) => ({
value: c,
label: c,
}))}
style={styles.segmented}
density="small"
/>
<View style={styles.chipRow}>
{SERVICE_CATEGORIES.map((c) => (
<Chip
key={c}
selected={draft.serviceCategory === c}
showSelectedOverlay
onPress={() => updateDraft({ serviceCategory: c })}
style={styles.chip}
>
{c}
</Chip>
))}
</View>
<Text variant="labelLarge" style={styles.label}>
Priority
@ -247,6 +251,15 @@ const styles = StyleSheet.create({
segmented: {
marginBottom: 16,
},
chipRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginBottom: 16,
},
chip: {
marginBottom: 0,
},
hint: {
color: '#9E9E9E',
marginBottom: 12,

View file

@ -17,3 +17,18 @@ DIST_ID=$(aws cloudformation describe-stacks \
--query "Stacks[0].Outputs[?OutputKey=='DistributionId'].OutputValue" \
--output text)
aws cloudfront create-invalidation --distribution-id "$DIST_ID" --paths "/*"
# Health check: verify API is reachable
API_URL=$(aws cloudformation describe-stacks \
--stack-name proposal-system-compute \
--query "Stacks[0].Outputs[?OutputKey=='ApiEndpoint'].OutputValue" \
--output text)
echo "Running post-deploy health check..."
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${API_URL}/api/health" --max-time 10 || true)
if [ "$HTTP_STATUS" -eq 200 ]; then
echo "Health check passed (HTTP $HTTP_STATUS)"
else
echo "WARNING: Health check returned HTTP $HTTP_STATUS" >&2
exit 1
fi

79
web/package-lock.json generated
View file

@ -13,7 +13,7 @@
"@mui/icons-material": "^7.3.1",
"@mui/material": "^7.3.1",
"@reduxjs/toolkit": "^2.11.2",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-query": "^5.100.13",
"axios": "^1.16.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
@ -22,7 +22,7 @@
"react-toastify": "^11.0.5"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "~5.7.0",
@ -1319,9 +1319,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1336,9 +1333,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1353,9 +1347,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1370,9 +1361,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1387,9 +1375,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1404,9 +1389,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1421,9 +1403,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1438,9 +1417,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1455,9 +1431,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1472,9 +1445,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1489,9 +1459,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1506,9 +1473,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@ -1523,9 +1487,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@ -1629,9 +1590,9 @@
"license": "MIT"
},
"node_modules/@tanstack/query-core": {
"version": "5.100.10",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz",
"integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==",
"version": "5.100.13",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.13.tgz",
"integrity": "sha512-mlKVKMTzZWGTKAC1CKOgt7axAjJ921emkEvYIp27I/PdP1yEYL/BteLY8iK35gn8hoYeKB4mgJ/ve3lrDI6/Fw==",
"license": "MIT",
"funding": {
"type": "github",
@ -1639,12 +1600,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.100.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz",
"integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==",
"version": "5.100.13",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.13.tgz",
"integrity": "sha512-HSBr8CycQEAoXsJR7KNDawBnINJEJ96Eme8oE0hCXjyodE2I97vg3IDzDJBDu18LsbzpVVJcKo80eqLfVCykxw==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.100.10"
"@tanstack/query-core": "5.100.13"
},
"funding": {
"type": "github",
@ -1719,9 +1680,9 @@
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
"integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@ -3100,22 +3061,6 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"extraneous": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}

View file

@ -14,7 +14,7 @@
"@mui/icons-material": "^7.3.1",
"@mui/material": "^7.3.1",
"@reduxjs/toolkit": "^2.11.2",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-query": "^5.100.13",
"axios": "^1.16.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
@ -23,7 +23,7 @@
"react-toastify": "^11.0.5"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "~5.7.0",

View file

@ -3,7 +3,7 @@ import { useSelector } from 'react-redux';
import { Box, Toolbar, Typography } from '@mui/material';
import { selectSidebarOpen } from './app/slices/uiSlice';
import type { RootState } from './app/store';
import ProtectedRoute from './components/ProtectedRoute';
import ProtectedRoute, { RoleGuard } from './components/ProtectedRoute';
import Topbar from './components/Topbar';
import Sidebar from './components/Sidebar';
import LoginPage from './pages/auth/LoginPage';
@ -14,8 +14,7 @@ import ProposalDetailPage from './pages/proposals/detail/ProposalDetailPage';
import ProposalListPage from './pages/proposals/list/ProposalListPage';
import AdminDashboard from './pages/admin/dashboard/AdminDashboard';
import AdminWorkspace from './pages/admin/workspace/AdminWorkspace';
const DRAWER_WIDTH = 220;
import { DRAWER_WIDTH } from './constants';
export default function App() {
const sidebarOpen = useSelector((state: RootState) => selectSidebarOpen(state));
@ -36,7 +35,7 @@ export default function App() {
component="main"
sx={{
flexGrow: 1,
p: '10px',
p: 1.25,
ml: sidebarOpen ? `${DRAWER_WIDTH}px` : 0,
transition: 'margin-left 250ms ease',
minHeight: '100vh',
@ -50,10 +49,10 @@ export default function App() {
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
{/* Admin Routes */}
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/admin/proposals" element={<AdminDashboard />} />
<Route path="/admin/proposals/:id" element={<AdminWorkspace />} />
<Route path="/admin/users" element={<Typography variant="h5" sx={{ p: 2 }}>User Management — Coming Soon</Typography>} />
<Route path="/admin" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard defaultStatus="InReview" /></RoleGuard>} />
<Route path="/admin/proposals" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard /></RoleGuard>} />
<Route path="/admin/proposals/:id" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminWorkspace /></RoleGuard>} />
<Route path="/admin/users" element={<RoleGuard roles={['SysAdmin']}><Typography variant="h5" sx={{ p: 2 }}>User Management — Coming Soon</Typography></RoleGuard>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View file

@ -1,13 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { STORAGE_KEY_TOKEN } from '../../constants';
interface AuthUser {
id: string;
email: string;
displayName: string;
role: 'Dispatcher' | 'Admin' | 'SysAdmin';
token: string;
}
import type { AuthUser } from '../../lib/api/auth';
interface AuthState {
user: AuthUser | null;

View file

@ -10,3 +10,13 @@ export default function ProtectedRoute({ children }: { children: React.ReactNode
return <>{children}</>;
}
export function RoleGuard({ roles, children }: { roles: string[]; children: React.ReactNode }) {
const { user } = useAuth();
if (!user || !roles.includes(user.role)) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}

View file

@ -19,8 +19,7 @@ import PeopleIcon from '@mui/icons-material/People';
import { selectSidebarOpen } from '../app/slices/uiSlice';
import { selectUser } from '../app/slices/authSlice';
import type { RootState } from '../app/store';
const DRAWER_WIDTH = 220;
import { DRAWER_WIDTH } from '../constants';
const dispatcherNav = [
{ label: 'Dashboard', path: '/', icon: <DashboardIcon fontSize="small" /> },

View file

@ -21,6 +21,7 @@ export default function Topbar() {
edge="start"
onClick={() => dispatch(toggleSidebar())}
sx={{ mr: 2 }}
aria-label="Toggle sidebar menu"
>
<MenuIcon />
</IconButton>
@ -46,6 +47,7 @@ export default function Topbar() {
<IconButton
onClick={logout}
size="small"
aria-label="Log out"
sx={{ color: 'rgba(255,255,255,0.5)', '&:hover': { color: '#ff6b6b', bgcolor: 'rgba(255,100,100,0.1)' } }}
>
<LogoutIcon fontSize="small" />

View file

@ -135,11 +135,11 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
<TableCell>
{!disabled && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<IconButton size="small" onClick={() => moveItem(index, -1)} disabled={index === 0}>
<IconButton size="small" onClick={() => moveItem(index, -1)} disabled={index === 0} aria-label={`Move item ${index + 1} up`}>
<ArrowUpwardIcon sx={{ fontSize: 14 }} />
</IconButton>
<Typography variant="caption">{index + 1}</Typography>
<IconButton size="small" onClick={() => moveItem(index, 1)} disabled={index === items.length - 1}>
<IconButton size="small" onClick={() => moveItem(index, 1)} disabled={index === items.length - 1} aria-label={`Move item ${index + 1} down`}>
<ArrowDownwardIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>

View file

@ -12,6 +12,7 @@ import {
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { adminApi } from '../../lib/api/admin';
import { formatCurrency } from '../../lib/format';
import type { EditableLineItem } from './LineItemEditor';
interface SimilarProposal {
@ -38,10 +39,7 @@ interface SimilarProposalsPanelProps {
export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disabled }: SimilarProposalsPanelProps) {
const { data: similar, isLoading } = useQuery<SimilarProposal[]>({
queryKey: ['similarProposals', proposalId],
queryFn: async () => {
const result = await adminApi.getSimilar(proposalId);
return result as SimilarProposal[];
},
queryFn: () => adminApi.getSimilar(proposalId) as Promise<SimilarProposal[]>,
enabled: !!proposalId,
});
@ -104,12 +102,13 @@ export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disa
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" noWrap>{li.description}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{li.quantity} {li.unit} @ ${li.totalPrice.toFixed(2)}
{li.quantity} {li.unit} @ {formatCurrency(li.totalPrice)}
</Typography>
</Box>
{!disabled && (
<Button
size="small"
aria-label={`Copy line item: ${li.description}`}
sx={{ minWidth: 'auto', p: 0.5 }}
onClick={() =>
onPullLineItem({

View file

@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { Box, Typography, Skeleton, Chip } from '@mui/material';
import apiClient from '../../lib/api/client';
import { formatCurrency } from '../../lib/format';
interface VendorProposal {
id: string;
@ -63,7 +64,7 @@ export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) {
{vp.totalVendorCost > 0 && (
<Typography variant="body2" sx={{ mt: 0.5, fontWeight: 600 }}>
Vendor Total: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(vp.totalVendorCost)}
Vendor Total: {formatCurrency(vp.totalVendorCost)}
</Typography>
)}
@ -76,7 +77,7 @@ export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) {
</Typography>
{li.total != null && (
<Typography variant="caption" color="text.secondary">
${li.total.toFixed(2)}
{formatCurrency(li.total)}
</Typography>
)}
</Box>

View file

@ -19,3 +19,19 @@ export const STORAGE_KEY_SIDEBAR = 'sidebarOpen';
export const PROPOSAL_STATUSES = ['Draft', 'InReview', 'Approved', 'Sent', 'Revised'] as const;
export const SERVICE_CATEGORIES = ['HVAC', 'Plumbing', 'Electrical', 'General', 'Renovation'] as const;
export const PRIORITIES = ['Standard', 'Urgent', 'Emergency'] as const;
export const STATUS_COLORS: Record<string, 'default' | 'info' | 'warning' | 'success' | 'error'> = {
Draft: 'default',
InReview: 'info',
Approved: 'success',
Sent: 'success',
Revised: 'warning',
};
export const DRAWER_WIDTH = 220;
export const PRIORITY_COLORS: Record<string, 'default' | 'warning' | 'error'> = {
Standard: 'default',
Urgent: 'warning',
Emergency: 'error',
};

View file

@ -0,0 +1,10 @@
import { useEffect } from 'react';
const BASE_TITLE = 'Proposal System';
export function useDocumentTitle(title?: string) {
useEffect(() => {
document.title = title ? `${title} | ${BASE_TITLE}` : BASE_TITLE;
return () => { document.title = BASE_TITLE; };
}, [title]);
}

View file

@ -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('');

View file

@ -19,7 +19,7 @@ export interface AuditEntry {
userId: string;
userName: string;
action: string;
details: Record<string, unknown>;
details: string | null;
timestamp: string;
ipAddress: string | null;
}
@ -66,11 +66,8 @@ export const adminApi = {
},
getPdf: async (id: string): Promise<{ downloadUrl: string; expiresAt: string } | null> => {
try {
const res = await apiClient.get(`/proposals/${id}/pdf`);
return res.data;
} catch {
return null;
}
const res = await apiClient.get(`/proposals/${id}/pdf`);
if (res.status === 202) return null;
return res.data;
},
};

View file

@ -67,6 +67,7 @@ export interface ProposalFilters {
priority?: string;
fromDate?: string;
toDate?: string;
mine?: boolean;
}
export const proposalsApi = {
@ -83,6 +84,9 @@ export const proposalsApi = {
if (filters.status) params.append('status', filters.status);
if (filters.serviceCategory) params.append('serviceCategory', filters.serviceCategory);
if (filters.priority) params.append('priority', filters.priority);
if (filters.fromDate) params.append('fromDate', filters.fromDate);
if (filters.toDate) params.append('toDate', filters.toDate);
if (filters.mine) params.append('mine', 'true');
const res = await apiClient.get(`/proposals?${params.toString()}`);
return res.data;
@ -93,11 +97,17 @@ export const proposalsApi = {
return res.data;
},
uploadAttachment: async (proposalId: string, fileName: string): Promise<{ uploadUrl: string; s3Key: string }> => {
const res = await apiClient.post(`/proposals/${proposalId}/attachments?fileName=${encodeURIComponent(fileName)}`);
uploadAttachment: async (proposalId: string, fileName: string, vendorName?: string): Promise<{ uploadUrl: string; s3Key: string; vendorProposalId: string }> => {
const params = new URLSearchParams({ fileName });
if (vendorName) params.append('vendorName', vendorName);
const res = await apiClient.post(`/proposals/${proposalId}/attachments?${params.toString()}`);
return res.data;
},
confirmUpload: async (proposalId: string, vendorProposalId: string): Promise<void> => {
await apiClient.post(`/proposals/${proposalId}/attachments/${vendorProposalId}/confirm`);
},
getStats: async (): Promise<ProposalStats> => {
const res = await apiClient.get('/proposals/stats');
return res.data;

18
web/src/lib/format.ts Normal file
View file

@ -0,0 +1,18 @@
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
}
export function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
export function formatDateTime(iso: string | null): string {
if (!iso) return '-';
return new Date(iso).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}

View file

@ -1,3 +1,4 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
@ -28,29 +29,9 @@ import { usePaginatedList } from '../../../hooks/usePaginatedList';
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
import { adminApi, type DashboardStats } from '../../../lib/api/admin';
import { QUERY_KEYS } from '../../../constants/queryKeys';
import { SERVICE_CATEGORIES, PRIORITIES } from '../../../constants';
const STATUS_COLORS: Record<string, 'default' | 'info' | 'warning' | 'success'> = {
Draft: 'default',
InReview: 'info',
Approved: 'success',
Sent: 'success',
Revised: 'warning',
};
const PRIORITY_COLORS: Record<string, 'default' | 'warning' | 'error'> = {
Standard: 'default',
Urgent: 'warning',
Emergency: 'error',
};
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
import { SERVICE_CATEGORIES, PRIORITIES, PROPOSAL_STATUSES, STATUS_COLORS, PRIORITY_COLORS } from '../../../constants';
import { formatCurrency, formatDate } from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
function StatCard({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: string; color: string }) {
return (
@ -68,7 +49,8 @@ function StatCard({ icon, label, value, color }: { icon: React.ReactNode; label:
);
}
export default function AdminDashboard() {
export default function AdminDashboard({ defaultStatus }: { defaultStatus?: string }) {
useDocumentTitle(defaultStatus ? 'Admin Queue' : 'Admin Dashboard');
const navigate = useNavigate();
const { data: stats, isLoading: statsLoading } = useQuery<DashboardStats>({
@ -76,6 +58,10 @@ export default function AdminDashboard() {
queryFn: adminApi.getDashboard,
});
const [statusFilter, setStatusFilter] = useState(defaultStatus ?? '');
const [categoryFilter, setCategoryFilter] = useState('');
const [priorityFilter, setPriorityFilter] = useState('');
const {
rows,
search,
@ -87,7 +73,11 @@ export default function AdminDashboard() {
totalCount,
loading,
err,
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll);
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll, {
...(statusFilter && { status: statusFilter }),
...(categoryFilter && { serviceCategory: categoryFilter }),
...(priorityFilter && { priority: priorityFilter }),
});
return (
<Box>
@ -167,9 +157,9 @@ export default function AdminDashboard() {
size="small"
select
label="Category"
value=""
value={categoryFilter}
sx={{ width: 160 }}
onChange={() => {}}
onChange={(e) => setCategoryFilter(e.target.value)}
>
<MenuItem value="">All</MenuItem>
{SERVICE_CATEGORIES.map((c) => (
@ -180,15 +170,28 @@ export default function AdminDashboard() {
size="small"
select
label="Priority"
value=""
value={priorityFilter}
sx={{ width: 140 }}
onChange={() => {}}
onChange={(e) => setPriorityFilter(e.target.value)}
>
<MenuItem value="">All</MenuItem>
{PRIORITIES.map((p) => (
<MenuItem key={p} value={p}>{p}</MenuItem>
))}
</TextField>
<TextField
size="small"
select
label="Status"
value={statusFilter}
sx={{ width: 140 }}
onChange={(e) => setStatusFilter(e.target.value)}
>
<MenuItem value="">All</MenuItem>
{PROPOSAL_STATUSES.map((s) => (
<MenuItem key={s} value={s}>{s}</MenuItem>
))}
</TextField>
</Box>
{err && (

View file

@ -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,
@ -34,14 +34,7 @@ import { QUERY_KEYS } from '../../../constants/queryKeys';
import LineItemEditor, { type EditableLineItem } from '../../../components/admin/LineItemEditor';
import VendorDataPanel from '../../../components/admin/VendorDataPanel';
import SimilarProposalsPanel from '../../../components/admin/SimilarProposalsPanel';
const STATUS_COLORS: Record<string, 'default' | 'info' | 'warning' | 'success'> = {
Draft: 'default',
InReview: 'info',
Approved: 'success',
Sent: 'success',
Revised: 'warning',
};
import { STATUS_COLORS } from '../../../constants';
export default function AdminWorkspace() {
const { id } = useParams<{ id: string }>();
@ -50,6 +43,8 @@ export default function AdminWorkspace() {
const [lineItems, setLineItems] = useState<EditableLineItem[]>([]);
const [refinedScope, setRefinedScope] = useState('');
const [approveDialogOpen, setApproveDialogOpen] = useState(false);
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [reviseDialogOpen, setReviseDialogOpen] = useState(false);
const [dirty, setDirty] = useState(false);
const { data: proposal, isLoading } = useQuery<ProposalDetail>({
@ -88,6 +83,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 });
@ -115,10 +127,17 @@ export default function AdminWorkspace() {
});
const approveMutation = useMutation({
mutationFn: () => adminApi.approveProposal(id!),
mutationFn: async () => {
if (dirty) {
await saveMutation.mutateAsync();
}
await adminApi.approveProposal(id!);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] });
setApproveDialogOpen(false);
setDirty(false);
toast.success('Proposal approved');
},
});
@ -127,6 +146,7 @@ export default function AdminWorkspace() {
mutationFn: () => adminApi.sendProposal(id!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
setSendDialogOpen(false);
toast.success('Proposal marked as sent');
},
});
@ -135,6 +155,7 @@ export default function AdminWorkspace() {
mutationFn: () => adminApi.reviseProposal(id!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
setReviseDialogOpen(false);
toast.success('Revision created');
},
});
@ -366,7 +387,7 @@ export default function AdminWorkspace() {
variant="contained"
color="success"
startIcon={<SendIcon />}
onClick={() => sendMutation.mutate()}
onClick={() => setSendDialogOpen(true)}
disabled={sendMutation.isPending}
>
Mark as Sent
@ -378,7 +399,7 @@ export default function AdminWorkspace() {
<Button
variant="outlined"
startIcon={<HistoryIcon />}
onClick={() => reviseMutation.mutate()}
onClick={() => setReviseDialogOpen(true)}
disabled={reviseMutation.isPending}
>
Create Revision
@ -387,6 +408,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>
@ -418,6 +453,49 @@ export default function AdminWorkspace() {
</Button>
</DialogActions>
</Dialog>
{/* Mark as Sent Confirmation Dialog */}
<Dialog open={sendDialogOpen} onClose={() => setSendDialogOpen(false)}>
<DialogTitle>Mark as Sent</DialogTitle>
<DialogContent>
<Typography>
Mark <strong>{proposal.proposalNumber}</strong> as sent to {proposal.customerName}?
This action cannot be undone.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setSendDialogOpen(false)}>Cancel</Button>
<Button
variant="contained"
color="success"
onClick={() => sendMutation.mutate()}
disabled={sendMutation.isPending}
>
{sendMutation.isPending ? 'Sending...' : 'Confirm Send'}
</Button>
</DialogActions>
</Dialog>
{/* Create Revision Confirmation Dialog */}
<Dialog open={reviseDialogOpen} onClose={() => setReviseDialogOpen(false)}>
<DialogTitle>Create Revision</DialogTitle>
<DialogContent>
<Typography>
Create a new revision of <strong>{proposal.proposalNumber}</strong>?
The current version will be marked as revised and a new editable copy will be created.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setReviseDialogOpen(false)}>Cancel</Button>
<Button
variant="contained"
onClick={() => reviseMutation.mutate()}
disabled={reviseMutation.isPending}
>
{reviseMutation.isPending ? 'Creating...' : 'Confirm Revision'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

@ -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;
@ -39,10 +39,17 @@ export default function LoginPage() {
}
}, [isAuthenticated, navigate]);
const devUsers: Record<string, { email: string; name: string }> = {
SysAdmin: { email: 'adam@seahavenind.com', name: 'Adam Moussa' },
Admin: { email: 'sarah@seahavenind.com', name: 'Sarah Chen' },
Dispatcher: { email: 'mike@seahavenind.com', name: 'Mike Torres' },
};
const handleDevLogin = async (role: string) => {
setLoading(true);
try {
const user = await authApi.devLogin('adam@seahavenind.com', 'Adam Moussa', role);
const dev = devUsers[role] ?? devUsers['SysAdmin']!;
const user = await authApi.devLogin(dev.email, dev.name, role);
dispatch(setUser(user));
navigate('/', { replace: true });
} catch (err) {
@ -96,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 }}>

View file

@ -23,22 +23,9 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import SendIcon from '@mui/icons-material/Send';
import { proposalsApi, type ProposalListItem, type ProposalStats } from '../../lib/api/proposals';
import { QUERY_KEYS } from '../../constants/queryKeys';
const STATUS_COLORS: Record<string, 'default' | 'info' | 'warning' | 'success' | 'error'> = {
Draft: 'default',
InReview: 'info',
Approved: 'success',
Sent: 'success',
Revised: 'warning',
};
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
import { STATUS_COLORS } from '../../constants';
import { formatCurrency, formatDate } from '../../lib/format';
import { useDocumentTitle } from '../../hooks/useDocumentTitle';
function KpiCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
@ -57,11 +44,12 @@ function KpiCard({ icon, label, value }: { icon: React.ReactNode; label: string;
}
export default function Dashboard() {
useDocumentTitle('Dashboard');
const navigate = useNavigate();
const { data: recentData, isLoading: recentLoading } = useQuery<{ items: ProposalListItem[]; totalCount: number }>({
const { data: recentData, isLoading: recentLoading, error: recentError } = useQuery<{ items: ProposalListItem[]; totalCount: number }>({
queryKey: [QUERY_KEYS.proposals, 'dashboard-recent'],
queryFn: () => proposalsApi.getAll({ page: 1, pageSize: 5 }),
queryFn: () => proposalsApi.getAll({ page: 1, pageSize: 5, mine: true }),
});
const { data: stats, isLoading: statsLoading } = useQuery<ProposalStats>({
@ -169,7 +157,16 @@ export default function Dashboard() {
<TableCell>{formatDate(row.submittedAt)}</TableCell>
</TableRow>
))}
{!isLoading && proposals.length === 0 && (
{!isLoading && recentError && (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
<Typography color="error">
Failed to load proposals. Please try again.
</Typography>
</TableCell>
</TableRow>
)}
{!isLoading && !recentError && proposals.length === 0 && (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary">

View file

@ -16,30 +16,11 @@ import {
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals';
import { STATUS_COLORS } from '../../../constants';
import { formatCurrency, formatDateTime } from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
const STATUS_ORDER = ['Draft', 'InReview', 'Approved', 'Sent'];
const STATUS_COLORS: Record<string, 'default' | 'info' | 'warning' | 'success' | 'error'> = {
Draft: 'default',
InReview: 'info',
Approved: 'success',
Sent: 'success',
Revised: 'warning',
};
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
}
function formatDateTime(iso: string | null): string {
if (!iso) return '-';
return new Date(iso).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
@ -62,6 +43,8 @@ export default function ProposalDetailPage() {
enabled: !!id,
});
useDocumentTitle(proposal?.proposalNumber ?? 'Proposal');
if (isLoading) {
return (
<Box>
@ -107,13 +90,19 @@ export default function ProposalDetailPage() {
<Typography variant="subtitle2" sx={{ mb: 2 }}>
Status Timeline
</Typography>
<Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel>
{STATUS_ORDER.map((label) => (
<Step key={label} completed={STATUS_ORDER.indexOf(label) <= activeStep}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
{proposal.status === 'Revised' ? (
<Box sx={{ textAlign: 'center', py: 1 }}>
<Chip label="Revised — a new revision has been created" color="warning" />
</Box>
) : (
<Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel>
{STATUS_ORDER.map((label) => (
<Step key={label} completed={STATUS_ORDER.indexOf(label) <= activeStep}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
)}
</CardContent>
</Card>

View file

@ -1,4 +1,4 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import {
@ -37,6 +37,13 @@ export default function ProposalFormPage() {
const [customerLoading, setCustomerLoading] = useState(false);
const [addresses, setAddresses] = useState<string[]>([]);
const [vendorFile, setVendorFile] = useState<File | null>(null);
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
};
}, []);
const handleChange = (field: keyof CreateProposalRequest, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
@ -75,12 +82,17 @@ export default function ProposalFormPage() {
const proposal = await proposalsApi.create(form);
if (vendorFile) {
const { uploadUrl } = await proposalsApi.uploadAttachment(proposal.id, vendorFile.name);
await fetch(uploadUrl, {
method: 'PUT',
body: vendorFile,
headers: { 'Content-Type': vendorFile.type || 'application/pdf' },
});
try {
const { uploadUrl, vendorProposalId } = await proposalsApi.uploadAttachment(proposal.id, vendorFile.name);
await fetch(uploadUrl, {
method: 'PUT',
body: vendorFile,
headers: { 'Content-Type': vendorFile.type || 'application/pdf' },
});
await proposalsApi.confirmUpload(proposal.id, vendorProposalId);
} catch {
toast.warning(`Proposal ${proposal.proposalNumber} was created, but vendor PDF upload failed. You can re-upload from the proposal detail page.`);
}
}
return proposal;
@ -90,6 +102,9 @@ export default function ProposalFormPage() {
toast.success(`Proposal ${proposal.proposalNumber} submitted`);
navigate(`/proposals/${proposal.id}`);
},
onError: (error: Error) => {
toast.error(error.message || 'Failed to submit proposal');
},
});
const handleSubmit = (e: React.FormEvent) => {
@ -130,7 +145,13 @@ export default function ProposalFormPage() {
options={customers}
getOptionLabel={(opt) => (typeof opt === 'string' ? opt : opt.name)}
loading={customerLoading}
onInputChange={(_, value) => searchCustomers(value)}
onInputChange={(_, value, reason) => {
if (reason === 'input') {
handleChange('customerName', value);
}
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
searchDebounceRef.current = setTimeout(() => searchCustomers(value), 300);
}}
onChange={handleCustomerSelect}
renderInput={(params) => (
<TextField

View file

@ -21,25 +21,14 @@ import SearchIcon from '@mui/icons-material/Search';
import AddCircleIcon from '@mui/icons-material/AddCircle';
import { usePaginatedList } from '../../../hooks/usePaginatedList';
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
const STATUS_COLORS: Record<string, 'default' | 'info' | 'warning' | 'success' | 'error'> = {
Draft: 'default',
InReview: 'info',
Approved: 'success',
Sent: 'success',
Revised: 'warning',
};
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
import { STATUS_COLORS } from '../../../constants';
import { formatCurrency, formatDate } from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
export default function ProposalListPage() {
useDocumentTitle('My Proposals');
const navigate = useNavigate();
const mineParams = { mine: true };
const {
rows,
search,
@ -51,7 +40,7 @@ export default function ProposalListPage() {
totalCount,
loading,
err,
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll);
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll, mineParams);
return (
<Box>