Compare commits

..

26 Commits

Author SHA1 Message Date
Mahmoud-Emad
7e95391a9c feat: Refactor governance models and views
- Moved governance models (`Vote`, `VoteType`, `VotingResults`) from
  `models/governance.rs` to `controllers/governance.rs` for better
  organization and to avoid circular dependencies.  This improves
  maintainability and reduces complexity.
- Updated governance views to use the new model locations.
- Added a limit to the number of recent activities displayed on the
  dashboard for performance optimization.
2025-06-03 15:31:50 +03:00
Mahmoud-Emad
9802d51acc feat: Migrate to hero-models for calendar event data
- Replaced custom `CalendarEvent` model with `heromodels`' `Event` model.
- Updated database interactions and controller logic to use the new model.
- Removed unnecessary `CalendarEvent` model and related code.
- Updated views to reflect changes in event data structure.
2025-06-03 15:12:53 +03:00
Mahmoud-Emad
2299b61e79 feat: Enhance calendar display of all-day events
- Improve display of all-day events by adding a message
  indicating when there are no all-day events scheduled.
- Add visual improvements to all-day event display using
  bootstrap classes.
- Clarify messaging when there are no events scheduled for a
  given day.
2025-05-29 14:17:48 +03:00
Mahmoud-Emad
b8928379de feat: Fix timezone issues in event creation
- Correctly handle timezones when creating events, ensuring that
  start and end times are accurately represented regardless of the
  user's timezone.
- Add 1-day compensation to event times to handle timezone shifts
  during conversion to UTC.
- Improve default time setting for date-specific events.
2025-05-29 14:07:03 +03:00
Mahmoud-Emad
45c4f4985e feat: Enhance calendar display and event management
- Improve event display: Show only the first two events for each day
  in the calendar, with a "+X more" link to show the rest.
- Add event details modal:  Allows viewing and deleting events.
- Enhance event creation modal: Improve user experience and add color
  selection for events.
- Improve year view: Show the number of events for each month.
- Improve day view: Display all day events separately.
- Improve styling and layout: Enhance the visual appeal and
  responsiveness of the calendar.
2025-05-28 16:59:24 +03:00
Mahmoud-Emad
58d1cde1ce feat: Migrate calendar functionality to a database
- Replaced Redis-based calendar with a database-backed solution
- Implemented database models for calendars and events
- Improved error handling and logging for database interactions
- Added new database functions for calendar management
- Updated calendar views to reflect the database changes
- Enhanced event creation and deletion processes
- Refined date/time handling for better consistency
2025-05-28 15:48:54 +03:00
Mahmoud-Emad
d815d9d365 feat: Add custom Tera filters for date/time formatting
- Add three new Tera filters: `format_hour`, `extract_hour`, and
  `format_time` for flexible date/time formatting in templates.
- Improve template flexibility and maintainability by allowing
  customizable date/time display.
- Enhance the user experience with more precise date/time rendering.
2025-05-28 10:43:02 +03:00
Mahmoud-Emad
2827cfebc9 refactor: Rename proposals module to governance
The `proposals` module has been renamed to `governance` to better
reflect its purpose and content.  This improves code clarity and
consistency.

- Renamed the `proposals` module to `governance` throughout the
  project to reflect the broader scope of governance features.
- Updated all related imports and function calls to use the new
  module name.
2025-05-28 09:29:19 +03:00
Mahmoud-Emad
7b15606da5 refactor: Remove unnecessary debug print statements
- Removed several `println!` statements from the `governance`
  controller and `proposals` database module to improve code
  cleanliness and reduce unnecessary console output.
- Updated the `all_activities.html` template to use the
  `created_at` field instead of `timestamp` for activity dates.
- Updated the `index.html` template to use the `created_at`
  field instead of `timestamp` for activity timestamps.
- Added `#[allow(unused_assignments)]` attribute to the
  `create_activity` function in `proposals.rs` to suppress a
  potentially unnecessary warning.
2025-05-28 09:24:56 +03:00
Mahmoud-Emad
11d7ae37b6 feat: Enhance governance module with activity tracking and DB refactor
- Refactor database interaction for proposals and activities.
- Add activity tracking for proposal creation and voting.
- Improve logging for better debugging and monitoring.
- Update governance views to display recent activities.
- Add strum and strum_macros crates for enum handling.
- Update Cargo.lock file with new dependencies.
2025-05-27 20:45:30 +03:00
Mahmoud-Emad
70ca9f1605 feat: Enhance governance dashboard with activity tracking
- Add governance activity tracker to record user actions.
- Display recent activities on the governance dashboard.
- Add a dedicated page to view all governance activities.
- Improve header information and styling across governance pages.
- Track proposal creation and voting activities.
2025-05-25 16:02:34 +03:00
Mahmoud-Emad
d12a082ca1 feat: Enhance Governance Controller and Proposal Handling
- Improve proposal search to include description field: This
  allows for more comprehensive search results.
- Fix redirect after voting: The redirect now correctly handles
  the success message.
- Handle potential invalid timestamps in ballots: The code now
  gracefully handles ballots with invalid timestamps, preventing
  crashes and using the current time as a fallback.
- Add local time formatting function:  This provides a way to
  display dates and times in the user's local timezone.
- Update database path: This simplifies the database setup.
- Improve proposal vote handling: Addresses issues with vote
  submission and timestamping.
- Add client-side pagination and filtering to proposal details:
  Improves user experience for viewing large vote lists.
2025-05-25 10:48:02 +03:00
Mahmoud-Emad
97e7a04827 feat: Add pagination and filtering improvements to proposal votes
- Added pagination to the proposal votes table to improve usability
  with large datasets.
- Implemented client-side filtering of votes by type and search
  terms, enhancing the user experience.
- Improved the responsiveness and efficiency of the vote filtering
  and pagination.
2025-05-22 17:13:52 +03:00
Mahmoud-Emad
3d8aca19cc feat: Improve user experience after voting on proposals
- Redirect users to the proposal detail page with a success
  message after a successful vote, improving feedback.
- Automatically remove the success message from the URL after a
  short time to avoid URL clutter and maintain a clean browsing
  experience.
- Add a success alert message on the proposal detail page to
  provide immediate visual confirmation of a successful vote.
- Improve the visual presentation of the votes list on the
  proposal detail page by adding top margin for better spacing.
2025-05-22 17:05:26 +03:00
Mahmoud-Emad
52fbc77e3e feat: Enhance proposal creation and display
- Improve proposal creation form with input validation and
  default date settings for a better user experience.
- Add context variables to the proposals template for
  consistent display across governance pages.
- Enhance proposal detail page with visual improvements,
  voting results display, and user voting functionality.
- Add styles for better visual presentation of proposal details
  and voting information.
2025-05-22 16:31:11 +03:00
Mahmoud-Emad
fad288f67d feat: Add total vote counts to governance views
- Add functionality to calculate total yes, no, and abstain votes
  across all proposals. This provides a summary of community
  voting patterns on the governance page.
- Improve the user experience by displaying total vote counts
  prominently on the "My Votes" page. This gives users a quick
  overview of the overall voting results.
- Enhance the "Create Proposal" page with informative guidelines
  and a helpful alert to guide users through the proposal creation
  process.  This improves clarity and ensures proposals are well-
  structured.
2025-05-22 16:08:12 +03:00
Mahmoud-Emad
4659697ae2 feat: Add filtering and searching to governance proposals page
- Added filtering of proposals by status (Draft, Active, Approved, Rejected, Cancelled).
- Added searching of proposals by title and description.
- Improved UI to persist filter and search values.
- Added a "No proposals found" message for better UX.
2025-05-22 15:47:11 +03:00
Mahmoud-Emad
67b80f237d feat: Enahnced the dashboard 2025-05-21 18:01:22 +03:00
Mahmoud-Emad
b606923102 feat: Finish the proposal dashboard 2025-05-21 15:56:47 +03:00
Mahmoud-Emad
8f1438dc01 feat: Remove mock proposals 2025-05-21 15:43:17 +03:00
Mahmoud-Emad
916f435dbc feat: Load voting 2025-05-21 15:04:45 +03:00
Mahmoud-Emad
5d9eaac1f8 feat: Implemented submit vote 2025-05-21 13:49:20 +03:00
Mahmoud-Emad
9c71c63ec5 feat: Working on the propsal page:
- Integerated the view proposal detail db call
- Use real data instead of mock data
2025-05-21 12:21:38 +03:00
Mahmoud-Emad
4a2f1c7282 feat: Implement Proposals page:
- Added the create new proposal functionality
- Added the list all proposals functionnality
2025-05-21 11:44:06 +03:00
Mahmoud Emad
60198dc2d4 fix: Remove warnings 2025-05-18 09:48:28 +03:00
Mahmoud Emad
e4e403e231 feat: Integerated the DB:
- Added an initialization with the db
- Implemented 'add_new_proposal' function to be used in the form
2025-05-18 09:07:59 +03:00
86 changed files with 6299 additions and 17781 deletions

View File

@ -0,0 +1,2 @@
[net]
git-fetch-with-cli = true

286
actix_mvc_app/Cargo.lock generated
View File

@ -296,6 +296,8 @@ dependencies = [
"env_logger", "env_logger",
"futures", "futures",
"futures-util", "futures-util",
"heromodels",
"heromodels_core",
"jsonwebtoken", "jsonwebtoken",
"lazy_static", "lazy_static",
"log", "log",
@ -309,6 +311,14 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "adapter_macros"
version = "0.1.0"
dependencies = [
"chrono",
"rhai",
]
[[package]] [[package]]
name = "addr2line" name = "addr2line"
version = "0.24.2" version = "0.24.2"
@ -366,6 +376,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"const-random",
"getrandom 0.2.15",
"once_cell", "once_cell",
"version_check", "version_check",
"zerocopy 0.7.35", "zerocopy 0.7.35",
@ -478,6 +490,12 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.88" version = "0.1.88"
@ -547,6 +565,26 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "bincode"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740"
dependencies = [
"bincode_derive",
"serde",
"unty",
]
[[package]]
name = "bincode_derive"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09"
dependencies = [
"virtue",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.9.0" version = "2.9.0"
@ -1285,12 +1323,62 @@ dependencies = [
"hashbrown 0.14.5", "hashbrown 0.14.5",
] ]
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.3.9" version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "heromodels"
version = "0.1.0"
dependencies = [
"adapter_macros",
"bincode",
"chrono",
"heromodels-derive",
"heromodels_core",
"ourdb",
"rhai",
"rhai_autobind_macros",
"rhai_client_macros",
"rhai_wrapper",
"serde",
"serde_json",
"strum",
"strum_macros",
"tst",
]
[[package]]
name = "heromodels-derive"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "heromodels_core"
version = "0.1.0"
dependencies = [
"chrono",
"serde",
]
[[package]] [[package]]
name = "hkdf" name = "hkdf"
version = "0.12.4" version = "0.12.4"
@ -1557,6 +1645,15 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "instant"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.1" version = "1.70.1"
@ -1756,6 +1853,15 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "no-std-compat"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
dependencies = [
"spin",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@ -1824,6 +1930,9 @@ name = "once_cell"
version = "1.21.3" version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
dependencies = [
"portable-atomic",
]
[[package]] [[package]]
name = "opaque-debug" name = "opaque-debug"
@ -1841,6 +1950,16 @@ dependencies = [
"hashbrown 0.14.5", "hashbrown 0.14.5",
] ]
[[package]]
name = "ourdb"
version = "0.1.0"
dependencies = [
"crc32fast",
"log",
"rand 0.8.5",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.3" version = "0.12.3"
@ -1907,7 +2026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6"
dependencies = [ dependencies = [
"memchr", "memchr",
"thiserror", "thiserror 2.0.12",
"ucd-trie", "ucd-trie",
] ]
@ -2210,6 +2329,75 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "rhai"
version = "1.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce4d759a4729a655ddfdbb3ff6e77fb9eadd902dae12319455557796e435d2a6"
dependencies = [
"ahash",
"bitflags",
"instant",
"no-std-compat",
"num-traits",
"once_cell",
"rhai_codegen",
"rust_decimal",
"smallvec",
"smartstring",
"thin-vec",
]
[[package]]
name = "rhai_autobind_macros"
version = "0.1.0"
dependencies = [
"heck 0.4.1",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "rhai_client_macros"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"rhai",
"syn",
]
[[package]]
name = "rhai_codegen"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5a11a05ee1ce44058fa3d5961d05194fdbe3ad6b40f904af764d81b86450e6b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "rhai_macros_derive"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "rhai_wrapper"
version = "0.1.0"
dependencies = [
"chrono",
"rhai",
"rhai_macros_derive",
"serde",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.16.20" version = "0.16.20"
@ -2247,6 +2435,16 @@ dependencies = [
"ordered-multimap", "ordered-multimap",
] ]
[[package]]
name = "rust_decimal"
version = "1.37.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faa7de2ba56ac291bd90c6b9bece784a52ae1411f9506544b3eae36dd2356d50"
dependencies = [
"arrayvec",
"num-traits",
]
[[package]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.24" version = "0.1.24"
@ -2421,7 +2619,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
dependencies = [ dependencies = [
"num-bigint", "num-bigint",
"num-traits", "num-traits",
"thiserror", "thiserror 2.0.12",
"time", "time",
] ]
@ -2456,6 +2654,17 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9"
[[package]]
name = "smartstring"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
dependencies = [
"autocfg",
"static_assertions",
"version_check",
]
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.4.10" version = "0.4.10"
@ -2488,12 +2697,37 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@ -2557,13 +2791,39 @@ dependencies = [
"unic-segment", "unic-segment",
] ]
[[package]]
name = "thin-vec"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "144f754d318415ac792f9d69fc87abbbfc043ce2ef041c60f16ad828f638717d"
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.12" version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl 2.0.12",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
] ]
[[package]] [[package]]
@ -2723,6 +2983,14 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "tst"
version = "0.1.0"
dependencies = [
"ourdb",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.18.0" version = "1.18.0"
@ -2831,6 +3099,12 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
name = "unty"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.4" version = "2.5.4"
@ -2888,6 +3162,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "virtue"
version = "0.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1"
[[package]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"

View File

@ -15,6 +15,8 @@ env_logger = "0.11.2"
log = "0.4.21" log = "0.4.21"
dotenv = "0.15.0" dotenv = "0.15.0"
chrono = { version = "0.4.35", features = ["serde"] } chrono = { version = "0.4.35", features = ["serde"] }
heromodels = { path = "../../db/heromodels" }
heromodels_core = { path = "../../db/heromodels_core" }
config = "0.14.0" config = "0.14.0"
num_cpus = "1.16.0" num_cpus = "1.16.0"
futures = "0.3.30" futures = "0.3.30"
@ -27,3 +29,8 @@ redis = { version = "0.23.0", features = ["tokio-comp"] }
jsonwebtoken = "8.3.0" jsonwebtoken = "8.3.0"
pulldown-cmark = "0.13.0" pulldown-cmark = "0.13.0"
urlencoding = "2.1.3" urlencoding = "2.1.3"
[patch."https://git.ourworld.tf/herocode/db.git"]
rhai_autobind_macros = { path = "../../rhaj/rhai_autobind_macros" }
rhai_wrapper = { path = "../../rhaj/rhai_wrapper" }

View File

@ -1,6 +1,6 @@
use std::env;
use config::{Config, ConfigError, File}; use config::{Config, ConfigError, File};
use serde::Deserialize; use serde::Deserialize;
use std::env;
/// Application configuration /// Application configuration
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
@ -13,6 +13,7 @@ pub struct AppConfig {
/// Server configuration /// Server configuration
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
pub struct ServerConfig { pub struct ServerConfig {
/// Host address to bind to /// Host address to bind to
pub host: String, pub host: String,
@ -50,7 +51,8 @@ impl AppConfig {
} }
// Override with environment variables (e.g., SERVER__HOST, SERVER__PORT) // Override with environment variables (e.g., SERVER__HOST, SERVER__PORT)
config_builder = config_builder.add_source(config::Environment::with_prefix("APP").separator("__")); config_builder =
config_builder.add_source(config::Environment::with_prefix("APP").separator("__"));
// Build and deserialize the config // Build and deserialize the config
let config = config_builder.build()?; let config = config_builder.build()?;
@ -61,4 +63,4 @@ impl AppConfig {
/// Returns the application configuration /// Returns the application configuration
pub fn get_config() -> AppConfig { pub fn get_config() -> AppConfig {
AppConfig::new().expect("Failed to load configuration") AppConfig::new().expect("Failed to load configuration")
} }

File diff suppressed because it is too large Load Diff

View File

@ -25,6 +25,7 @@ lazy_static! {
/// Controller for handling authentication-related routes /// Controller for handling authentication-related routes
pub struct AuthController; pub struct AuthController;
#[allow(dead_code)]
impl AuthController { impl AuthController {
/// Generate a JWT token for a user /// Generate a JWT token for a user
fn generate_token(email: &str, role: &UserRole) -> Result<String, jsonwebtoken::errors::Error> { fn generate_token(email: &str, role: &UserRole) -> Result<String, jsonwebtoken::errors::Error> {

View File

@ -1,12 +1,17 @@
use actix_web::{web, HttpResponse, Responder, Result};
use actix_session::Session; use actix_session::Session;
use actix_web::{HttpResponse, Responder, Result, web};
use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc}; use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tera::Tera;
use serde_json::Value; use serde_json::Value;
use tera::Tera;
use crate::models::{CalendarEvent, CalendarViewMode}; use crate::db::calendar::{
use crate::utils::{RedisCalendarService, render_template}; add_event_to_calendar, create_new_event, delete_event, get_events, get_or_create_user_calendar,
};
use crate::models::CalendarViewMode;
use crate::utils::render_template;
use heromodels::models::calendar::Event;
use heromodels_core::Model;
/// Controller for handling calendar-related routes /// Controller for handling calendar-related routes
pub struct CalendarController; pub struct CalendarController;
@ -14,9 +19,11 @@ pub struct CalendarController;
impl CalendarController { impl CalendarController {
/// Helper function to get user from session /// Helper function to get user from session
fn get_user_from_session(session: &Session) -> Option<Value> { fn get_user_from_session(session: &Session) -> Option<Value> {
session.get::<String>("user").ok().flatten().and_then(|user_json| { session
serde_json::from_str(&user_json).ok() .get::<String>("user")
}) .ok()
.flatten()
.and_then(|user_json| serde_json::from_str(&user_json).ok())
} }
/// Handles the calendar page route /// Handles the calendar page route
@ -27,113 +34,176 @@ impl CalendarController {
) -> Result<impl Responder> { ) -> Result<impl Responder> {
let mut ctx = tera::Context::new(); let mut ctx = tera::Context::new();
ctx.insert("active_page", "calendar"); ctx.insert("active_page", "calendar");
// Parse the view mode from the query parameters // Parse the view mode from the query parameters
let view_mode = CalendarViewMode::from_str(&query.view.clone().unwrap_or_else(|| "month".to_string())); let view_mode =
CalendarViewMode::from_str(&query.view.clone().unwrap_or_else(|| "month".to_string()));
ctx.insert("view_mode", &view_mode.to_str()); ctx.insert("view_mode", &view_mode.to_str());
// Parse the date from the query parameters or use the current date // Parse the date from the query parameters or use the current date
let date = if let Some(date_str) = &query.date { let date = if let Some(date_str) = &query.date {
match NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { match NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
Ok(naive_date) => Utc.from_utc_datetime(&naive_date.and_hms_opt(0, 0, 0).unwrap()).into(), Ok(naive_date) => Utc
.from_utc_datetime(&naive_date.and_hms_opt(0, 0, 0).unwrap())
.into(),
Err(_) => Utc::now(), Err(_) => Utc::now(),
} }
} else { } else {
Utc::now() Utc::now()
}; };
ctx.insert("current_date", &date.format("%Y-%m-%d").to_string()); ctx.insert("current_date", &date.format("%Y-%m-%d").to_string());
ctx.insert("current_year", &date.year()); ctx.insert("current_year", &date.year());
ctx.insert("current_month", &date.month()); ctx.insert("current_month", &date.month());
ctx.insert("current_day", &date.day()); ctx.insert("current_day", &date.day());
// Add user to context if available // Add user to context if available and ensure user has a calendar
if let Some(user) = Self::get_user_from_session(&_session) { if let Some(user) = Self::get_user_from_session(&_session) {
ctx.insert("user", &user); ctx.insert("user", &user);
// Get or create user calendar
if let (Some(user_id), Some(user_name)) = (
user.get("id").and_then(|v| v.as_u64()).map(|v| v as u32),
user.get("full_name").and_then(|v| v.as_str()),
) {
match get_or_create_user_calendar(user_id, user_name) {
Ok(calendar) => {
log::info!(
"User calendar ready: ID {}, Name: '{}'",
calendar.get_id(),
calendar.name
);
ctx.insert("user_calendar", &calendar);
}
Err(e) => {
log::error!("Failed to get or create user calendar: {}", e);
// Continue without calendar - the app should still work
}
}
}
} }
// Get events for the current view // Get events for the current view
let (start_date, end_date) = match view_mode { let (start_date, end_date) = match view_mode {
CalendarViewMode::Year => { CalendarViewMode::Year => {
let start = Utc.with_ymd_and_hms(date.year(), 1, 1, 0, 0, 0).unwrap(); let start = Utc.with_ymd_and_hms(date.year(), 1, 1, 0, 0, 0).unwrap();
let end = Utc.with_ymd_and_hms(date.year(), 12, 31, 23, 59, 59).unwrap(); let end = Utc
.with_ymd_and_hms(date.year(), 12, 31, 23, 59, 59)
.unwrap();
(start, end) (start, end)
}, }
CalendarViewMode::Month => { CalendarViewMode::Month => {
let start = Utc.with_ymd_and_hms(date.year(), date.month(), 1, 0, 0, 0).unwrap(); let start = Utc
.with_ymd_and_hms(date.year(), date.month(), 1, 0, 0, 0)
.unwrap();
let last_day = Self::last_day_of_month(date.year(), date.month()); let last_day = Self::last_day_of_month(date.year(), date.month());
let end = Utc.with_ymd_and_hms(date.year(), date.month(), last_day, 23, 59, 59).unwrap(); let end = Utc
.with_ymd_and_hms(date.year(), date.month(), last_day, 23, 59, 59)
.unwrap();
(start, end) (start, end)
}, }
CalendarViewMode::Week => { CalendarViewMode::Week => {
// Calculate the start of the week (Sunday) // Calculate the start of the week (Sunday)
let _weekday = date.weekday().num_days_from_sunday(); let _weekday = date.weekday().num_days_from_sunday();
let start_date = date.date_naive().pred_opt().unwrap().pred_opt().unwrap().pred_opt().unwrap().pred_opt().unwrap().pred_opt().unwrap().pred_opt().unwrap().pred_opt().unwrap(); let start_date = date
.date_naive()
.pred_opt()
.unwrap()
.pred_opt()
.unwrap()
.pred_opt()
.unwrap()
.pred_opt()
.unwrap()
.pred_opt()
.unwrap()
.pred_opt()
.unwrap()
.pred_opt()
.unwrap();
let start = Utc.from_utc_datetime(&start_date.and_hms_opt(0, 0, 0).unwrap()); let start = Utc.from_utc_datetime(&start_date.and_hms_opt(0, 0, 0).unwrap());
let end = start + chrono::Duration::days(7); let end = start + chrono::Duration::days(7);
(start, end) (start, end)
}, }
CalendarViewMode::Day => { CalendarViewMode::Day => {
let start = Utc.with_ymd_and_hms(date.year(), date.month(), date.day(), 0, 0, 0).unwrap(); let start = Utc
let end = Utc.with_ymd_and_hms(date.year(), date.month(), date.day(), 23, 59, 59).unwrap(); .with_ymd_and_hms(date.year(), date.month(), date.day(), 0, 0, 0)
.unwrap();
let end = Utc
.with_ymd_and_hms(date.year(), date.month(), date.day(), 23, 59, 59)
.unwrap();
(start, end) (start, end)
}, }
}; };
// Get events from Redis // Get events from database
let events = match RedisCalendarService::get_events_in_range(start_date, end_date) { let events = match get_events() {
Ok(events) => events, Ok(db_events) => {
// Filter events for the date range
db_events
.into_iter()
.filter(|event| {
// Event overlaps with the date range
event.start_time < end_date && event.end_time > start_date
})
.collect()
}
Err(e) => { Err(e) => {
log::error!("Failed to get events from Redis: {}", e); log::error!("Failed to get events from database: {}", e);
vec![] vec![]
} }
}; };
ctx.insert("events", &events); ctx.insert("events", &events);
// Generate calendar data based on the view mode // Generate calendar data based on the view mode
match view_mode { match view_mode {
CalendarViewMode::Year => { CalendarViewMode::Year => {
let months = (1..=12).map(|month| { let months = (1..=12)
let month_name = match month { .map(|month| {
1 => "January", let month_name = match month {
2 => "February", 1 => "January",
3 => "March", 2 => "February",
4 => "April", 3 => "March",
5 => "May", 4 => "April",
6 => "June", 5 => "May",
7 => "July", 6 => "June",
8 => "August", 7 => "July",
9 => "September", 8 => "August",
10 => "October", 9 => "September",
11 => "November", 10 => "October",
12 => "December", 11 => "November",
_ => "", 12 => "December",
}; _ => "",
};
let month_events = events.iter()
.filter(|event| { let month_events = events
event.start_time.month() == month || event.end_time.month() == month .iter()
}) .filter(|event| {
.cloned() event.start_time.month() == month || event.end_time.month() == month
.collect::<Vec<_>>(); })
.cloned()
CalendarMonth { .collect::<Vec<_>>();
month,
name: month_name.to_string(), CalendarMonth {
events: month_events, month,
} name: month_name.to_string(),
}).collect::<Vec<_>>(); events: month_events,
}
})
.collect::<Vec<_>>();
ctx.insert("months", &months); ctx.insert("months", &months);
}, }
CalendarViewMode::Month => { CalendarViewMode::Month => {
let days_in_month = Self::last_day_of_month(date.year(), date.month()); let days_in_month = Self::last_day_of_month(date.year(), date.month());
let first_day = Utc.with_ymd_and_hms(date.year(), date.month(), 1, 0, 0, 0).unwrap(); let first_day = Utc
.with_ymd_and_hms(date.year(), date.month(), 1, 0, 0, 0)
.unwrap();
let first_weekday = first_day.weekday().num_days_from_sunday(); let first_weekday = first_day.weekday().num_days_from_sunday();
let mut calendar_days = Vec::new(); let mut calendar_days = Vec::new();
// Add empty days for the start of the month // Add empty days for the start of the month
for _ in 0..first_weekday { for _ in 0..first_weekday {
calendar_days.push(CalendarDay { calendar_days.push(CalendarDay {
@ -142,27 +212,34 @@ impl CalendarController {
is_current_month: false, is_current_month: false,
}); });
} }
// Add days for the current month // Add days for the current month
for day in 1..=days_in_month { for day in 1..=days_in_month {
let day_events = events.iter() let day_events = events
.iter()
.filter(|event| { .filter(|event| {
let day_start = Utc.with_ymd_and_hms(date.year(), date.month(), day, 0, 0, 0).unwrap(); let day_start = Utc
let day_end = Utc.with_ymd_and_hms(date.year(), date.month(), day, 23, 59, 59).unwrap(); .with_ymd_and_hms(date.year(), date.month(), day, 0, 0, 0)
.unwrap();
(event.start_time <= day_end && event.end_time >= day_start) || let day_end = Utc
(event.all_day && event.start_time.day() <= day && event.end_time.day() >= day) .with_ymd_and_hms(date.year(), date.month(), day, 23, 59, 59)
.unwrap();
(event.start_time <= day_end && event.end_time >= day_start)
|| (event.all_day
&& event.start_time.day() <= day
&& event.end_time.day() >= day)
}) })
.cloned() .cloned()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
calendar_days.push(CalendarDay { calendar_days.push(CalendarDay {
day, day,
events: day_events, events: day_events,
is_current_month: true, is_current_month: true,
}); });
} }
// Fill out the rest of the calendar grid (6 rows of 7 days) // Fill out the rest of the calendar grid (6 rows of 7 days)
let remaining_days = 42 - calendar_days.len(); let remaining_days = 42 - calendar_days.len();
for day in 1..=remaining_days { for day in 1..=remaining_days {
@ -172,149 +249,250 @@ impl CalendarController {
is_current_month: false, is_current_month: false,
}); });
} }
ctx.insert("calendar_days", &calendar_days); ctx.insert("calendar_days", &calendar_days);
ctx.insert("month_name", &Self::month_name(date.month())); ctx.insert("month_name", &Self::month_name(date.month()));
}, }
CalendarViewMode::Week => { CalendarViewMode::Week => {
// Calculate the start of the week (Sunday) // Calculate the start of the week (Sunday)
let weekday = date.weekday().num_days_from_sunday(); let weekday = date.weekday().num_days_from_sunday();
let week_start = date - chrono::Duration::days(weekday as i64); let week_start = date - chrono::Duration::days(weekday as i64);
let mut week_days = Vec::new(); let mut week_days = Vec::new();
for i in 0..7 { for i in 0..7 {
let day_date = week_start + chrono::Duration::days(i); let day_date = week_start + chrono::Duration::days(i);
let day_events = events.iter() let day_events = events
.iter()
.filter(|event| { .filter(|event| {
let day_start = Utc.with_ymd_and_hms(day_date.year(), day_date.month(), day_date.day(), 0, 0, 0).unwrap(); let day_start = Utc
let day_end = Utc.with_ymd_and_hms(day_date.year(), day_date.month(), day_date.day(), 23, 59, 59).unwrap(); .with_ymd_and_hms(
day_date.year(),
(event.start_time <= day_end && event.end_time >= day_start) || day_date.month(),
(event.all_day && event.start_time.day() <= day_date.day() && event.end_time.day() >= day_date.day()) day_date.day(),
0,
0,
0,
)
.unwrap();
let day_end = Utc
.with_ymd_and_hms(
day_date.year(),
day_date.month(),
day_date.day(),
23,
59,
59,
)
.unwrap();
(event.start_time <= day_end && event.end_time >= day_start)
|| (event.all_day
&& event.start_time.day() <= day_date.day()
&& event.end_time.day() >= day_date.day())
}) })
.cloned() .cloned()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
week_days.push(CalendarDay { week_days.push(CalendarDay {
day: day_date.day(), day: day_date.day(),
events: day_events, events: day_events,
is_current_month: day_date.month() == date.month(), is_current_month: day_date.month() == date.month(),
}); });
} }
ctx.insert("week_days", &week_days); ctx.insert("week_days", &week_days);
}, }
CalendarViewMode::Day => { CalendarViewMode::Day => {
log::info!("Day view selected"); log::info!("Day view selected");
ctx.insert("day_name", &Self::day_name(date.weekday().num_days_from_sunday())); ctx.insert(
"day_name",
&Self::day_name(date.weekday().num_days_from_sunday()),
);
// Add debug info // Add debug info
log::info!("Events count: {}", events.len()); log::info!("Events count: {}", events.len());
log::info!("Current date: {}", date.format("%Y-%m-%d")); log::info!("Current date: {}", date.format("%Y-%m-%d"));
log::info!("Day name: {}", Self::day_name(date.weekday().num_days_from_sunday())); log::info!(
}, "Day name: {}",
Self::day_name(date.weekday().num_days_from_sunday())
);
}
} }
render_template(&tmpl, "calendar/index.html", &ctx) render_template(&tmpl, "calendar/index.html", &ctx)
} }
/// Handles the new event page route /// Handles the new event page route
pub async fn new_event(tmpl: web::Data<Tera>, _session: Session) -> Result<impl Responder> { pub async fn new_event(tmpl: web::Data<Tera>, _session: Session) -> Result<impl Responder> {
let mut ctx = tera::Context::new(); let mut ctx = tera::Context::new();
ctx.insert("active_page", "calendar"); ctx.insert("active_page", "calendar");
// Add user to context if available // Add user to context if available and ensure user has a calendar
if let Some(user) = Self::get_user_from_session(&_session) { if let Some(user) = Self::get_user_from_session(&_session) {
ctx.insert("user", &user); ctx.insert("user", &user);
// Get or create user calendar
if let (Some(user_id), Some(user_name)) = (
user.get("id").and_then(|v| v.as_u64()).map(|v| v as u32),
user.get("full_name").and_then(|v| v.as_str()),
) {
match get_or_create_user_calendar(user_id, user_name) {
Ok(calendar) => {
ctx.insert("user_calendar", &calendar);
}
Err(e) => {
log::error!("Failed to get or create user calendar: {}", e);
}
}
}
} }
render_template(&tmpl, "calendar/new_event.html", &ctx) render_template(&tmpl, "calendar/new_event.html", &ctx)
} }
/// Handles the create event route /// Handles the create event route
pub async fn create_event( pub async fn create_event(
form: web::Form<EventForm>, form: web::Form<EventForm>,
tmpl: web::Data<Tera>, tmpl: web::Data<Tera>,
_session: Session, _session: Session,
) -> Result<impl Responder> { ) -> Result<impl Responder> {
// Log the form data for debugging
log::info!(
"Creating event with form data: title='{}', start_time='{}', end_time='{}', all_day={}",
form.title,
form.start_time,
form.end_time,
form.all_day
);
// Parse the start and end times // Parse the start and end times
let start_time = match DateTime::parse_from_rfc3339(&form.start_time) { let start_time = match DateTime::parse_from_rfc3339(&form.start_time) {
Ok(dt) => dt.with_timezone(&Utc), Ok(dt) => dt.with_timezone(&Utc),
Err(e) => { Err(e) => {
log::error!("Failed to parse start time: {}", e); log::error!("Failed to parse start time '{}': {}", form.start_time, e);
return Ok(HttpResponse::BadRequest().body("Invalid start time")); return Ok(HttpResponse::BadRequest().body("Invalid start time format"));
} }
}; };
let end_time = match DateTime::parse_from_rfc3339(&form.end_time) { let end_time = match DateTime::parse_from_rfc3339(&form.end_time) {
Ok(dt) => dt.with_timezone(&Utc), Ok(dt) => dt.with_timezone(&Utc),
Err(e) => { Err(e) => {
log::error!("Failed to parse end time: {}", e); log::error!("Failed to parse end time '{}': {}", form.end_time, e);
return Ok(HttpResponse::BadRequest().body("Invalid end time")); return Ok(HttpResponse::BadRequest().body("Invalid end time format"));
} }
}; };
// Create the event // Get user information from session
let event = CalendarEvent::new( let user_info = Self::get_user_from_session(&_session);
form.title.clone(), let (user_id, user_name) = if let Some(user) = &user_info {
form.description.clone(), let id = user.get("id").and_then(|v| v.as_u64()).map(|v| v as u32);
let name = user
.get("full_name")
.and_then(|v| v.as_str())
.unwrap_or("Unknown User");
log::info!("User from session: id={:?}, name='{}'", id, name);
(id, name)
} else {
log::warn!("No user found in session");
(None, "Unknown User")
};
// Create the event in the database
match create_new_event(
&form.title,
Some(&form.description),
start_time, start_time,
end_time, end_time,
Some(form.color.clone()), None, // location
Some(&form.color),
form.all_day, form.all_day,
None, // User ID would come from session in a real app user_id,
); None, // category
None, // reminder_minutes
// Save the event to Redis ) {
match RedisCalendarService::save_event(&event) { Ok((event_id, _saved_event)) => {
Ok(_) => { log::info!("Created event with ID: {}", event_id);
// If user is logged in, add the event to their calendar
if let Some(user_id) = user_id {
match get_or_create_user_calendar(user_id, user_name) {
Ok(calendar) => match add_event_to_calendar(calendar.get_id(), event_id) {
Ok(_) => {
log::info!(
"Added event {} to calendar {}",
event_id,
calendar.get_id()
);
}
Err(e) => {
log::error!("Failed to add event to calendar: {}", e);
}
},
Err(e) => {
log::error!("Failed to get user calendar: {}", e);
}
}
}
// Redirect to the calendar page // Redirect to the calendar page
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.append_header(("Location", "/calendar")) .append_header(("Location", "/calendar"))
.finish()) .finish())
}, }
Err(e) => { Err(e) => {
log::error!("Failed to save event to Redis: {}", e); log::error!("Failed to save event to database: {}", e);
// Show an error message // Show an error message
let mut ctx = tera::Context::new(); let mut ctx = tera::Context::new();
ctx.insert("active_page", "calendar"); ctx.insert("active_page", "calendar");
ctx.insert("error", "Failed to save event"); ctx.insert("error", "Failed to save event");
// Add user to context if available // Add user to context if available
if let Some(user) = Self::get_user_from_session(&_session) { if let Some(user) = user_info {
ctx.insert("user", &user); ctx.insert("user", &user);
} }
let result = render_template(&tmpl, "calendar/new_event.html", &ctx)?; let result = render_template(&tmpl, "calendar/new_event.html", &ctx)?;
Ok(HttpResponse::InternalServerError().content_type("text/html").body(result.into_body())) Ok(HttpResponse::InternalServerError()
.content_type("text/html")
.body(result.into_body()))
} }
} }
} }
/// Handles the delete event route /// Handles the delete event route
pub async fn delete_event( pub async fn delete_event(
path: web::Path<String>, path: web::Path<String>,
_session: Session, _session: Session,
) -> Result<impl Responder> { ) -> Result<impl Responder> {
let id = path.into_inner(); let id = path.into_inner();
// Delete the event from Redis // Parse the event ID
match RedisCalendarService::delete_event(&id) { let event_id = match id.parse::<u32>() {
Ok(id) => id,
Err(_) => {
log::error!("Invalid event ID: {}", id);
return Ok(HttpResponse::BadRequest().body("Invalid event ID"));
}
};
// Delete the event from database
match delete_event(event_id) {
Ok(_) => { Ok(_) => {
log::info!("Deleted event with ID: {}", event_id);
// Redirect to the calendar page // Redirect to the calendar page
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.append_header(("Location", "/calendar")) .append_header(("Location", "/calendar"))
.finish()) .finish())
}, }
Err(e) => { Err(e) => {
log::error!("Failed to delete event from Redis: {}", e); log::error!("Failed to delete event from database: {}", e);
Ok(HttpResponse::InternalServerError().body("Failed to delete event")) Ok(HttpResponse::InternalServerError().body("Failed to delete event"))
} }
} }
} }
/// Returns the last day of the month /// Returns the last day of the month
fn last_day_of_month(year: i32, month: u32) -> u32 { fn last_day_of_month(year: i32, month: u32) -> u32 {
match month { match month {
@ -326,11 +504,11 @@ impl CalendarController {
} else { } else {
28 28
} }
}, }
_ => 30, // Default to 30 days _ => 30, // Default to 30 days
} }
} }
/// Returns the name of the month /// Returns the name of the month
fn month_name(month: u32) -> &'static str { fn month_name(month: u32) -> &'static str {
match month { match month {
@ -349,7 +527,7 @@ impl CalendarController {
_ => "", _ => "",
} }
} }
/// Returns the name of the day /// Returns the name of the day
fn day_name(day: u32) -> &'static str { fn day_name(day: u32) -> &'static str {
match day { match day {
@ -387,7 +565,7 @@ pub struct EventForm {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct CalendarDay { struct CalendarDay {
day: u32, day: u32,
events: Vec<CalendarEvent>, events: Vec<Event>,
is_current_month: bool, is_current_month: bool,
} }
@ -396,5 +574,5 @@ struct CalendarDay {
struct CalendarMonth { struct CalendarMonth {
month: u32, month: u32,
name: String, name: String,
events: Vec<CalendarEvent>, events: Vec<Event>,
} }

View File

@ -1,12 +1,12 @@
use actix_web::{web, HttpResponse, Responder, Result};
use actix_web::HttpRequest;
use tera::{Context, Tera};
use serde::Deserialize;
use chrono::Utc;
use crate::utils::render_template; use crate::utils::render_template;
use actix_web::HttpRequest;
use actix_web::{HttpResponse, Result, web};
use serde::Deserialize;
use tera::{Context, Tera};
// Form structs for company operations // Form structs for company operations
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct CompanyRegistrationForm { pub struct CompanyRegistrationForm {
pub company_name: String, pub company_name: String,
pub company_type: String, pub company_type: String,
@ -20,59 +20,69 @@ impl CompanyController {
// Display the company management dashboard // Display the company management dashboard
pub async fn index(tmpl: web::Data<Tera>, req: HttpRequest) -> Result<HttpResponse> { pub async fn index(tmpl: web::Data<Tera>, req: HttpRequest) -> Result<HttpResponse> {
let mut context = Context::new(); let mut context = Context::new();
println!("DEBUG: Starting Company dashboard rendering"); println!("DEBUG: Starting Company dashboard rendering");
// Add active_page for navigation highlighting // Add active_page for navigation highlighting
context.insert("active_page", &"company"); context.insert("active_page", &"company");
// Parse query parameters // Parse query parameters
let query_string = req.query_string(); let query_string = req.query_string();
// Check for success message // Check for success message
if let Some(pos) = query_string.find("success=") { if let Some(pos) = query_string.find("success=") {
let start = pos + 8; // length of "success=" let start = pos + 8; // length of "success="
let end = query_string[start..].find('&').map_or(query_string.len(), |e| e + start); let end = query_string[start..]
.find('&')
.map_or(query_string.len(), |e| e + start);
let success = &query_string[start..end]; let success = &query_string[start..end];
let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into()); let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into());
context.insert("success", &decoded); context.insert("success", &decoded);
} }
// Check for entity context // Check for entity context
if let Some(pos) = query_string.find("entity=") { if let Some(pos) = query_string.find("entity=") {
let start = pos + 7; // length of "entity=" let start = pos + 7; // length of "entity="
let end = query_string[start..].find('&').map_or(query_string.len(), |e| e + start); let end = query_string[start..]
.find('&')
.map_or(query_string.len(), |e| e + start);
let entity = &query_string[start..end]; let entity = &query_string[start..end];
context.insert("entity", &entity); context.insert("entity", &entity);
// Also get entity name if present // Also get entity name if present
if let Some(pos) = query_string.find("entity_name=") { if let Some(pos) = query_string.find("entity_name=") {
let start = pos + 12; // length of "entity_name=" let start = pos + 12; // length of "entity_name="
let end = query_string[start..].find('&').map_or(query_string.len(), |e| e + start); let end = query_string[start..]
.find('&')
.map_or(query_string.len(), |e| e + start);
let entity_name = &query_string[start..end]; let entity_name = &query_string[start..end];
let decoded_name = urlencoding::decode(entity_name).unwrap_or_else(|_| entity_name.into()); let decoded_name =
urlencoding::decode(entity_name).unwrap_or_else(|_| entity_name.into());
context.insert("entity_name", &decoded_name); context.insert("entity_name", &decoded_name);
println!("DEBUG: Entity context set to {} ({})", entity, decoded_name); println!("DEBUG: Entity context set to {} ({})", entity, decoded_name);
} }
} }
println!("DEBUG: Rendering Company dashboard template"); println!("DEBUG: Rendering Company dashboard template");
let response = render_template(&tmpl, "company/index.html", &context); let response = render_template(&tmpl, "company/index.html", &context);
println!("DEBUG: Finished rendering Company dashboard template"); println!("DEBUG: Finished rendering Company dashboard template");
response response
} }
// View company details // View company details
pub async fn view_company(tmpl: web::Data<Tera>, path: web::Path<String>) -> Result<HttpResponse> { pub async fn view_company(
tmpl: web::Data<Tera>,
path: web::Path<String>,
) -> Result<HttpResponse> {
let company_id = path.into_inner(); let company_id = path.into_inner();
let mut context = Context::new(); let mut context = Context::new();
println!("DEBUG: Viewing company details for {}", company_id); println!("DEBUG: Viewing company details for {}", company_id);
// Add active_page for navigation highlighting // Add active_page for navigation highlighting
context.insert("active_page", &"company"); context.insert("active_page", &"company");
context.insert("company_id", &company_id); context.insert("company_id", &company_id);
// In a real application, we would fetch company data from a database // In a real application, we would fetch company data from a database
// For now, we'll use mock data based on the company_id // For now, we'll use mock data based on the company_id
match company_id.as_str() { match company_id.as_str() {
@ -85,14 +95,11 @@ impl CompanyController {
context.insert("plan", &"Startup FZC - $50/month"); context.insert("plan", &"Startup FZC - $50/month");
context.insert("next_billing", &"2025-06-01"); context.insert("next_billing", &"2025-06-01");
context.insert("payment_method", &"Credit Card (****4582)"); context.insert("payment_method", &"Credit Card (****4582)");
// Shareholders data // Shareholders data
let shareholders = vec![ let shareholders = vec![("John Smith", "60%"), ("Sarah Johnson", "40%")];
("John Smith", "60%"),
("Sarah Johnson", "40%"),
];
context.insert("shareholders", &shareholders); context.insert("shareholders", &shareholders);
// Contracts data // Contracts data
let contracts = vec![ let contracts = vec![
("Articles of Incorporation", "Signed"), ("Articles of Incorporation", "Signed"),
@ -100,7 +107,7 @@ impl CompanyController {
("Digital Asset Issuance", "Signed"), ("Digital Asset Issuance", "Signed"),
]; ];
context.insert("contracts", &contracts); context.insert("contracts", &contracts);
}, }
"company2" => { "company2" => {
context.insert("company_name", &"Blockchain Innovations Ltd"); context.insert("company_name", &"Blockchain Innovations Ltd");
context.insert("company_type", &"Growth FZC"); context.insert("company_type", &"Growth FZC");
@ -110,7 +117,7 @@ impl CompanyController {
context.insert("plan", &"Growth FZC - $100/month"); context.insert("plan", &"Growth FZC - $100/month");
context.insert("next_billing", &"2025-06-15"); context.insert("next_billing", &"2025-06-15");
context.insert("payment_method", &"Bank Transfer"); context.insert("payment_method", &"Bank Transfer");
// Shareholders data // Shareholders data
let shareholders = vec![ let shareholders = vec![
("Michael Chen", "35%"), ("Michael Chen", "35%"),
@ -118,7 +125,7 @@ impl CompanyController {
("David Okonkwo", "30%"), ("David Okonkwo", "30%"),
]; ];
context.insert("shareholders", &shareholders); context.insert("shareholders", &shareholders);
// Contracts data // Contracts data
let contracts = vec![ let contracts = vec![
("Articles of Incorporation", "Signed"), ("Articles of Incorporation", "Signed"),
@ -127,7 +134,7 @@ impl CompanyController {
("Physical Asset Holding", "Signed"), ("Physical Asset Holding", "Signed"),
]; ];
context.insert("contracts", &contracts); context.insert("contracts", &contracts);
}, }
"company3" => { "company3" => {
context.insert("company_name", &"Sustainable Energy Cooperative"); context.insert("company_name", &"Sustainable Energy Cooperative");
context.insert("company_type", &"Cooperative FZC"); context.insert("company_type", &"Cooperative FZC");
@ -137,7 +144,7 @@ impl CompanyController {
context.insert("plan", &"Cooperative FZC - $200/month"); context.insert("plan", &"Cooperative FZC - $200/month");
context.insert("next_billing", &"Pending Activation"); context.insert("next_billing", &"Pending Activation");
context.insert("payment_method", &"Pending"); context.insert("payment_method", &"Pending");
// Shareholders data // Shareholders data
let shareholders = vec![ let shareholders = vec![
("Community Energy Group", "40%"), ("Community Energy Group", "40%"),
@ -145,7 +152,7 @@ impl CompanyController {
("Sustainable Living Collective", "30%"), ("Sustainable Living Collective", "30%"),
]; ];
context.insert("shareholders", &shareholders); context.insert("shareholders", &shareholders);
// Contracts data // Contracts data
let contracts = vec![ let contracts = vec![
("Articles of Incorporation", "Signed"), ("Articles of Incorporation", "Signed"),
@ -153,7 +160,7 @@ impl CompanyController {
("Cooperative Governance", "Pending"), ("Cooperative Governance", "Pending"),
]; ];
context.insert("contracts", &contracts); context.insert("contracts", &contracts);
}, }
_ => { _ => {
// If company_id is not recognized, redirect to company index // If company_id is not recognized, redirect to company index
return Ok(HttpResponse::Found() return Ok(HttpResponse::Found()
@ -161,51 +168,56 @@ impl CompanyController {
.finish()); .finish());
} }
} }
println!("DEBUG: Rendering company view template"); println!("DEBUG: Rendering company view template");
let response = render_template(&tmpl, "company/view.html", &context); let response = render_template(&tmpl, "company/view.html", &context);
println!("DEBUG: Finished rendering company view template"); println!("DEBUG: Finished rendering company view template");
response response
} }
// Switch to entity context // Switch to entity context
pub async fn switch_entity(path: web::Path<String>) -> Result<HttpResponse> { pub async fn switch_entity(path: web::Path<String>) -> Result<HttpResponse> {
let company_id = path.into_inner(); let company_id = path.into_inner();
println!("DEBUG: Switching to entity context for {}", company_id); println!("DEBUG: Switching to entity context for {}", company_id);
// Get company name based on ID (in a real app, this would come from a database) // Get company name based on ID (in a real app, this would come from a database)
let company_name = match company_id.as_str() { let company_name = match company_id.as_str() {
"company1" => "Zanzibar Digital Solutions", "company1" => "Zanzibar Digital Solutions",
"company2" => "Blockchain Innovations Ltd", "company2" => "Blockchain Innovations Ltd",
"company3" => "Sustainable Energy Cooperative", "company3" => "Sustainable Energy Cooperative",
_ => "Unknown Company" _ => "Unknown Company",
}; };
// In a real application, we would set a session/cookie for the current entity // In a real application, we would set a session/cookie for the current entity
// Here we'll redirect back to the company page with a success message and entity parameter // Here we'll redirect back to the company page with a success message and entity parameter
let success_message = format!("Switched to {} entity context", company_name); let success_message = format!("Switched to {} entity context", company_name);
let encoded_message = urlencoding::encode(&success_message); let encoded_message = urlencoding::encode(&success_message);
Ok(HttpResponse::Found() Ok(HttpResponse::Found()
.append_header(("Location", format!("/company?success={}&entity={}&entity_name={}", .append_header((
encoded_message, company_id, urlencoding::encode(company_name)))) "Location",
format!(
"/company?success={}&entity={}&entity_name={}",
encoded_message,
company_id,
urlencoding::encode(company_name)
),
))
.finish()) .finish())
} }
// Process company registration // Process company registration
pub async fn register( pub async fn register(mut form: actix_multipart::Multipart) -> Result<HttpResponse> {
mut form: actix_multipart::Multipart, use actix_web::http::header;
) -> Result<HttpResponse> {
use actix_web::{http::header};
use futures_util::stream::StreamExt as _; use futures_util::stream::StreamExt as _;
use std::collections::HashMap; use std::collections::HashMap;
println!("DEBUG: Processing company registration request"); println!("DEBUG: Processing company registration request");
let mut fields: HashMap<String, String> = HashMap::new(); let mut fields: HashMap<String, String> = HashMap::new();
let mut files = Vec::new(); let mut files = Vec::new();
// Parse multipart form // Parse multipart form
while let Some(Ok(mut field)) = form.next().await { while let Some(Ok(mut field)) = form.next().await {
let mut value = Vec::new(); let mut value = Vec::new();
@ -213,33 +225,47 @@ impl CompanyController {
let data = chunk.unwrap(); let data = chunk.unwrap();
value.extend_from_slice(&data); value.extend_from_slice(&data);
} }
// Get field name from content disposition // Get field name from content disposition
let cd = field.content_disposition(); let cd = field.content_disposition();
if let Some(name) = cd.get_name() { if let Some(name) = cd.get_name() {
if name == "company_docs" { if name == "company_docs" {
files.push(value); // Just collect files in memory for now files.push(value); // Just collect files in memory for now
} else { } else {
fields.insert(name.to_string(), String::from_utf8_lossy(&value).to_string()); fields.insert(
name.to_string(),
String::from_utf8_lossy(&value).to_string(),
);
} }
} }
} }
// Extract company details // Extract company details
let company_name = fields.get("company_name").cloned().unwrap_or_default(); let company_name = fields.get("company_name").cloned().unwrap_or_default();
let company_type = fields.get("company_type").cloned().unwrap_or_default(); let company_type = fields.get("company_type").cloned().unwrap_or_default();
let shareholders = fields.get("shareholders").cloned().unwrap_or_default(); let shareholders = fields.get("shareholders").cloned().unwrap_or_default();
// Log received fields (mock DB insert) // Log received fields (mock DB insert)
println!("[Company Registration] Name: {}, Type: {}, Shareholders: {}, Files: {}", println!(
company_name, company_type, shareholders, files.len()); "[Company Registration] Name: {}, Type: {}, Shareholders: {}, Files: {}",
company_name,
company_type,
shareholders,
files.len()
);
// Create success message // Create success message
let success_message = format!("Successfully registered {} as a {}", company_name, company_type); let success_message = format!(
"Successfully registered {} as a {}",
company_name, company_type
);
// Redirect back to /company with success message // Redirect back to /company with success message
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.append_header((header::LOCATION, format!("/company?success={}", urlencoding::encode(&success_message)))) .append_header((
header::LOCATION,
format!("/company?success={}", urlencoding::encode(&success_message)),
))
.finish()) .finish())
} }
} }

View File

@ -1,15 +1,18 @@
use actix_web::{web, HttpResponse, Result, Error};
use tera::{Context, Tera};
use chrono::{Utc, Duration};
use serde::Deserialize;
use serde_json::json;
use actix_web::web::Query; use actix_web::web::Query;
use actix_web::{Error, HttpResponse, Result, web};
use chrono::{Duration, Utc};
use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use tera::{Context, Tera};
use crate::models::contract::{Contract, ContractStatus, ContractType, ContractStatistics, ContractSigner, ContractRevision, SignerStatus, TocItem}; use crate::models::contract::{
Contract, ContractRevision, ContractSigner, ContractStatistics, ContractStatus, ContractType,
SignerStatus, TocItem,
};
use crate::utils::render_template; use crate::utils::render_template;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct ContractForm { pub struct ContractForm {
pub title: String, pub title: String,
pub description: String, pub description: String,
@ -18,6 +21,7 @@ pub struct ContractForm {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct SignerForm { pub struct SignerForm {
pub name: String, pub name: String,
pub email: String, pub email: String,
@ -29,98 +33,99 @@ impl ContractController {
// Display the contracts dashboard // Display the contracts dashboard
pub async fn index(tmpl: web::Data<Tera>) -> Result<HttpResponse, Error> { pub async fn index(tmpl: web::Data<Tera>) -> Result<HttpResponse, Error> {
let mut context = Context::new(); let mut context = Context::new();
let contracts = Self::get_mock_contracts(); let contracts = Self::get_mock_contracts();
let stats = ContractStatistics::new(&contracts); let stats = ContractStatistics::new(&contracts);
// Add active_page for navigation highlighting // Add active_page for navigation highlighting
context.insert("active_page", &"contracts"); context.insert("active_page", &"contracts");
// Add stats // Add stats
context.insert("stats", &serde_json::to_value(stats).unwrap()); context.insert("stats", &serde_json::to_value(stats).unwrap());
// Add recent contracts // Add recent contracts
let recent_contracts: Vec<serde_json::Map<String, serde_json::Value>> = contracts let recent_contracts: Vec<serde_json::Map<String, serde_json::Value>> = contracts
.iter() .iter()
.take(5) .take(5)
.map(|c| Self::contract_to_json(c)) .map(|c| Self::contract_to_json(c))
.collect(); .collect();
context.insert("recent_contracts", &recent_contracts); context.insert("recent_contracts", &recent_contracts);
// Add pending signature contracts // Add pending signature contracts
let pending_signature_contracts: Vec<serde_json::Map<String, serde_json::Value>> = contracts let pending_signature_contracts: Vec<serde_json::Map<String, serde_json::Value>> =
.iter() contracts
.filter(|c| c.status == ContractStatus::PendingSignatures) .iter()
.map(|c| Self::contract_to_json(c)) .filter(|c| c.status == ContractStatus::PendingSignatures)
.collect(); .map(|c| Self::contract_to_json(c))
.collect();
context.insert("pending_signature_contracts", &pending_signature_contracts); context.insert("pending_signature_contracts", &pending_signature_contracts);
// Add draft contracts // Add draft contracts
let draft_contracts: Vec<serde_json::Map<String, serde_json::Value>> = contracts let draft_contracts: Vec<serde_json::Map<String, serde_json::Value>> = contracts
.iter() .iter()
.filter(|c| c.status == ContractStatus::Draft) .filter(|c| c.status == ContractStatus::Draft)
.map(|c| Self::contract_to_json(c)) .map(|c| Self::contract_to_json(c))
.collect(); .collect();
context.insert("draft_contracts", &draft_contracts); context.insert("draft_contracts", &draft_contracts);
render_template(&tmpl, "contracts/index.html", &context) render_template(&tmpl, "contracts/index.html", &context)
} }
// Display the list of all contracts // Display the list of all contracts
pub async fn list(tmpl: web::Data<Tera>) -> Result<HttpResponse, Error> { pub async fn list(tmpl: web::Data<Tera>) -> Result<HttpResponse, Error> {
let mut context = Context::new(); let mut context = Context::new();
let contracts = Self::get_mock_contracts(); let contracts = Self::get_mock_contracts();
let contracts_data: Vec<serde_json::Map<String, serde_json::Value>> = contracts let contracts_data: Vec<serde_json::Map<String, serde_json::Value>> = contracts
.iter() .iter()
.map(|c| Self::contract_to_json(c)) .map(|c| Self::contract_to_json(c))
.collect(); .collect();
// Add active_page for navigation highlighting // Add active_page for navigation highlighting
context.insert("active_page", &"contracts"); context.insert("active_page", &"contracts");
context.insert("contracts", &contracts_data); context.insert("contracts", &contracts_data);
context.insert("filter", &"all"); context.insert("filter", &"all");
render_template(&tmpl, "contracts/contracts.html", &context) render_template(&tmpl, "contracts/contracts.html", &context)
} }
// Display the list of user's contracts // Display the list of user's contracts
pub async fn my_contracts(tmpl: web::Data<Tera>) -> Result<HttpResponse, Error> { pub async fn my_contracts(tmpl: web::Data<Tera>) -> Result<HttpResponse, Error> {
let mut context = Context::new(); let mut context = Context::new();
let contracts = Self::get_mock_contracts(); let contracts = Self::get_mock_contracts();
let contracts_data: Vec<serde_json::Map<String, serde_json::Value>> = contracts let contracts_data: Vec<serde_json::Map<String, serde_json::Value>> = contracts
.iter() .iter()
.map(|c| Self::contract_to_json(c)) .map(|c| Self::contract_to_json(c))
.collect(); .collect();
// Add active_page for navigation highlighting // Add active_page for navigation highlighting
context.insert("active_page", &"contracts"); context.insert("active_page", &"contracts");
context.insert("contracts", &contracts_data); context.insert("contracts", &contracts_data);
render_template(&tmpl, "contracts/my_contracts.html", &context) render_template(&tmpl, "contracts/my_contracts.html", &context)
} }
// Display a specific contract // Display a specific contract
pub async fn detail( pub async fn detail(
tmpl: web::Data<Tera>, tmpl: web::Data<Tera>,
path: web::Path<String>, path: web::Path<String>,
query: Query<HashMap<String, String>> query: Query<HashMap<String, String>>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
let contract_id = path.into_inner(); let contract_id = path.into_inner();
let mut context = Context::new(); let mut context = Context::new();
// Add active_page for navigation highlighting // Add active_page for navigation highlighting
context.insert("active_page", &"contracts"); context.insert("active_page", &"contracts");
// Find the contract by ID // Find the contract by ID
let contracts = Self::get_mock_contracts(); let contracts = Self::get_mock_contracts();
// For demo purposes, if the ID doesn't match exactly, just show the first contract // For demo purposes, if the ID doesn't match exactly, just show the first contract
// In a real app, we would return a 404 if the contract is not found // In a real app, we would return a 404 if the contract is not found
let contract = if let Some(found) = contracts.iter().find(|c| c.id == contract_id) { let contract = if let Some(found) = contracts.iter().find(|c| c.id == contract_id) {
@ -129,7 +134,7 @@ impl ContractController {
// For demo, just use the first contract // For demo, just use the first contract
contracts.first().unwrap() contracts.first().unwrap()
}; };
// Convert contract to JSON // Convert contract to JSON
let contract_json = Self::contract_to_json(contract); let contract_json = Self::contract_to_json(contract);
@ -137,10 +142,13 @@ impl ContractController {
context.insert("contract", &contract_json); context.insert("contract", &contract_json);
// If this contract uses multi-page markdown, load the selected section // If this contract uses multi-page markdown, load the selected section
println!("DEBUG: content_dir = {:?}, toc = {:?}", contract.content_dir, contract.toc); println!(
"DEBUG: content_dir = {:?}, toc = {:?}",
contract.content_dir, contract.toc
);
if let (Some(content_dir), Some(toc)) = (&contract.content_dir, &contract.toc) { if let (Some(content_dir), Some(toc)) = (&contract.content_dir, &contract.toc) {
use pulldown_cmark::{Options, Parser, html};
use std::fs; use std::fs;
use pulldown_cmark::{Parser, Options, html};
// Helper to flatten toc recursively // Helper to flatten toc recursively
fn flatten_toc<'a>(items: &'a Vec<TocItem>, out: &mut Vec<&'a TocItem>) { fn flatten_toc<'a>(items: &'a Vec<TocItem>, out: &mut Vec<&'a TocItem>) {
for item in items { for item in items {
@ -154,15 +162,28 @@ impl ContractController {
flatten_toc(&toc, &mut flat_toc); flatten_toc(&toc, &mut flat_toc);
let section_param = query.get("section"); let section_param = query.get("section");
let selected_file = section_param let selected_file = section_param
.and_then(|f| flat_toc.iter().find(|item| item.file == *f).map(|item| item.file.clone())) .and_then(|f| {
.unwrap_or_else(|| flat_toc.get(0).map(|item| item.file.clone()).unwrap_or_default()); flat_toc
.iter()
.find(|item| item.file == *f)
.map(|item| item.file.clone())
})
.unwrap_or_else(|| {
flat_toc
.get(0)
.map(|item| item.file.clone())
.unwrap_or_default()
});
context.insert("section", &selected_file); context.insert("section", &selected_file);
let rel_path = format!("{}/{}", content_dir, selected_file); let rel_path = format!("{}/{}", content_dir, selected_file);
let abs_path = match std::env::current_dir() { let abs_path = match std::env::current_dir() {
Ok(dir) => dir.join(&rel_path), Ok(dir) => dir.join(&rel_path),
Err(_) => std::path::PathBuf::from(&rel_path), Err(_) => std::path::PathBuf::from(&rel_path),
}; };
println!("DEBUG: Attempting to read markdown file at absolute path: {:?}", abs_path); println!(
"DEBUG: Attempting to read markdown file at absolute path: {:?}",
abs_path
);
match fs::read_to_string(&abs_path) { match fs::read_to_string(&abs_path) {
Ok(md) => { Ok(md) => {
println!("DEBUG: Successfully read markdown file"); println!("DEBUG: Successfully read markdown file");
@ -170,52 +191,63 @@ impl ContractController {
let mut html_output = String::new(); let mut html_output = String::new();
html::push_html(&mut html_output, parser); html::push_html(&mut html_output, parser);
context.insert("contract_section_content", &html_output); context.insert("contract_section_content", &html_output);
}, }
Err(e) => { Err(e) => {
let error_msg = format!("Error: Could not read contract section markdown at '{:?}': {}", abs_path, e); let error_msg = format!(
"Error: Could not read contract section markdown at '{:?}': {}",
abs_path, e
);
println!("{}", error_msg); println!("{}", error_msg);
context.insert("contract_section_content_error", &error_msg); context.insert("contract_section_content_error", &error_msg);
} }
} }
context.insert("toc", &toc); context.insert("toc", &toc);
} }
// Count signed signers for the template // Count signed signers for the template
let signed_signers = contract.signers.iter().filter(|s| s.status == SignerStatus::Signed).count(); let signed_signers = contract
.signers
.iter()
.filter(|s| s.status == SignerStatus::Signed)
.count();
context.insert("signed_signers", &signed_signers); context.insert("signed_signers", &signed_signers);
// Count pending signers for the template // Count pending signers for the template
let pending_signers = contract.signers.iter().filter(|s| s.status == SignerStatus::Pending).count(); let pending_signers = contract
.signers
.iter()
.filter(|s| s.status == SignerStatus::Pending)
.count();
context.insert("pending_signers", &pending_signers); context.insert("pending_signers", &pending_signers);
// For demo purposes, set user_has_signed to false // For demo purposes, set user_has_signed to false
// In a real app, we would check if the current user has already signed // In a real app, we would check if the current user has already signed
context.insert("user_has_signed", &false); context.insert("user_has_signed", &false);
render_template(&tmpl, "contracts/contract_detail.html", &context) render_template(&tmpl, "contracts/contract_detail.html", &context)
} }
// Display the create contract form // Display the create contract form
pub async fn create_form(tmpl: web::Data<Tera>) -> Result<HttpResponse, Error> { pub async fn create_form(tmpl: web::Data<Tera>) -> Result<HttpResponse, Error> {
let mut context = Context::new(); let mut context = Context::new();
// Add active_page for navigation highlighting // Add active_page for navigation highlighting
context.insert("active_page", &"contracts"); context.insert("active_page", &"contracts");
// Add contract types for dropdown // Add contract types for dropdown
let contract_types = vec![ let contract_types = vec![
("Service", "Service Agreement"), ("Service", "Service Agreement"),
("Employment", "Employment Contract"), ("Employment", "Employment Contract"),
("NDA", "Non-Disclosure Agreement"), ("NDA", "Non-Disclosure Agreement"),
("SLA", "Service Level Agreement"), ("SLA", "Service Level Agreement"),
("Other", "Other") ("Other", "Other"),
]; ];
context.insert("contract_types", &contract_types); context.insert("contract_types", &contract_types);
render_template(&tmpl, "contracts/create_contract.html", &context) render_template(&tmpl, "contracts/create_contract.html", &context)
} }
// Process the create contract form // Process the create contract form
pub async fn create( pub async fn create(
_tmpl: web::Data<Tera>, _tmpl: web::Data<Tera>,
@ -223,158 +255,334 @@ impl ContractController {
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
// In a real application, we would save the contract to the database // In a real application, we would save the contract to the database
// For now, we'll just redirect to the contracts list // For now, we'll just redirect to the contracts list
Ok(HttpResponse::Found().append_header(("Location", "/contracts")).finish()) Ok(HttpResponse::Found()
.append_header(("Location", "/contracts"))
.finish())
} }
// Helper method to convert Contract to a JSON object for templates // Helper method to convert Contract to a JSON object for templates
fn contract_to_json(contract: &Contract) -> serde_json::Map<String, serde_json::Value> { fn contract_to_json(contract: &Contract) -> serde_json::Map<String, serde_json::Value> {
let mut map = serde_json::Map::new(); let mut map = serde_json::Map::new();
// Basic contract info // Basic contract info
map.insert("id".to_string(), serde_json::Value::String(contract.id.clone())); map.insert(
map.insert("title".to_string(), serde_json::Value::String(contract.title.clone())); "id".to_string(),
map.insert("description".to_string(), serde_json::Value::String(contract.description.clone())); serde_json::Value::String(contract.id.clone()),
map.insert("status".to_string(), serde_json::Value::String(contract.status.as_str().to_string())); );
map.insert("contract_type".to_string(), serde_json::Value::String(contract.contract_type.as_str().to_string())); map.insert(
map.insert("created_by".to_string(), serde_json::Value::String(contract.created_by.clone())); "title".to_string(),
map.insert("created_at".to_string(), serde_json::Value::String(contract.created_at.format("%Y-%m-%d").to_string())); serde_json::Value::String(contract.title.clone()),
map.insert("updated_at".to_string(), serde_json::Value::String(contract.updated_at.format("%Y-%m-%d").to_string())); );
map.insert(
"description".to_string(),
serde_json::Value::String(contract.description.clone()),
);
map.insert(
"status".to_string(),
serde_json::Value::String(contract.status.as_str().to_string()),
);
map.insert(
"contract_type".to_string(),
serde_json::Value::String(contract.contract_type.as_str().to_string()),
);
map.insert(
"created_by".to_string(),
serde_json::Value::String(contract.created_by.clone()),
);
map.insert(
"created_at".to_string(),
serde_json::Value::String(contract.created_at.format("%Y-%m-%d").to_string()),
);
map.insert(
"updated_at".to_string(),
serde_json::Value::String(contract.updated_at.format("%Y-%m-%d").to_string()),
);
// Organization info // Organization info
if let Some(org) = &contract.organization_id { if let Some(org) = &contract.organization_id {
map.insert("organization".to_string(), serde_json::Value::String(org.clone())); map.insert(
"organization".to_string(),
serde_json::Value::String(org.clone()),
);
} else { } else {
map.insert("organization".to_string(), serde_json::Value::Null); map.insert("organization".to_string(), serde_json::Value::Null);
} }
// Add signers // Add signers
let signers: Vec<serde_json::Value> = contract.signers.iter() let signers: Vec<serde_json::Value> = contract
.signers
.iter()
.map(|s| { .map(|s| {
let mut signer_map = serde_json::Map::new(); let mut signer_map = serde_json::Map::new();
signer_map.insert("id".to_string(), serde_json::Value::String(s.id.clone())); signer_map.insert("id".to_string(), serde_json::Value::String(s.id.clone()));
signer_map.insert("name".to_string(), serde_json::Value::String(s.name.clone())); signer_map.insert(
signer_map.insert("email".to_string(), serde_json::Value::String(s.email.clone())); "name".to_string(),
signer_map.insert("status".to_string(), serde_json::Value::String(s.status.as_str().to_string())); serde_json::Value::String(s.name.clone()),
);
signer_map.insert(
"email".to_string(),
serde_json::Value::String(s.email.clone()),
);
signer_map.insert(
"status".to_string(),
serde_json::Value::String(s.status.as_str().to_string()),
);
if let Some(signed_at) = s.signed_at { if let Some(signed_at) = s.signed_at {
signer_map.insert("signed_at".to_string(), serde_json::Value::String(signed_at.format("%Y-%m-%d").to_string())); signer_map.insert(
"signed_at".to_string(),
serde_json::Value::String(signed_at.format("%Y-%m-%d").to_string()),
);
} else { } else {
// For display purposes, add a placeholder date for pending signers // For display purposes, add a placeholder date for pending signers
if s.status == SignerStatus::Pending { if s.status == SignerStatus::Pending {
signer_map.insert("signed_at".to_string(), serde_json::Value::String("Pending".to_string())); signer_map.insert(
"signed_at".to_string(),
serde_json::Value::String("Pending".to_string()),
);
} else if s.status == SignerStatus::Rejected { } else if s.status == SignerStatus::Rejected {
signer_map.insert("signed_at".to_string(), serde_json::Value::String("Rejected".to_string())); signer_map.insert(
"signed_at".to_string(),
serde_json::Value::String("Rejected".to_string()),
);
} }
} }
if let Some(comments) = &s.comments { if let Some(comments) = &s.comments {
signer_map.insert("comments".to_string(), serde_json::Value::String(comments.clone())); signer_map.insert(
"comments".to_string(),
serde_json::Value::String(comments.clone()),
);
} else { } else {
signer_map.insert("comments".to_string(), serde_json::Value::String("".to_string())); signer_map.insert(
"comments".to_string(),
serde_json::Value::String("".to_string()),
);
} }
serde_json::Value::Object(signer_map) serde_json::Value::Object(signer_map)
}) })
.collect(); .collect();
map.insert("signers".to_string(), serde_json::Value::Array(signers)); map.insert("signers".to_string(), serde_json::Value::Array(signers));
// Add pending_signers count for templates // Add pending_signers count for templates
let pending_signers = contract.signers.iter().filter(|s| s.status == SignerStatus::Pending).count(); let pending_signers = contract
map.insert("pending_signers".to_string(), serde_json::Value::Number(serde_json::Number::from(pending_signers))); .signers
.iter()
.filter(|s| s.status == SignerStatus::Pending)
.count();
map.insert(
"pending_signers".to_string(),
serde_json::Value::Number(serde_json::Number::from(pending_signers)),
);
// Add signed_signers count for templates // Add signed_signers count for templates
let signed_signers = contract.signers.iter().filter(|s| s.status == SignerStatus::Signed).count(); let signed_signers = contract
map.insert("signed_signers".to_string(), serde_json::Value::Number(serde_json::Number::from(signed_signers))); .signers
.iter()
.filter(|s| s.status == SignerStatus::Signed)
.count();
map.insert(
"signed_signers".to_string(),
serde_json::Value::Number(serde_json::Number::from(signed_signers)),
);
// Add revisions // Add revisions
let revisions: Vec<serde_json::Value> = contract.revisions.iter() let revisions: Vec<serde_json::Value> = contract
.revisions
.iter()
.map(|r| { .map(|r| {
let mut revision_map = serde_json::Map::new(); let mut revision_map = serde_json::Map::new();
revision_map.insert("version".to_string(), serde_json::Value::Number(serde_json::Number::from(r.version))); revision_map.insert(
revision_map.insert("content".to_string(), serde_json::Value::String(r.content.clone())); "version".to_string(),
revision_map.insert("created_at".to_string(), serde_json::Value::String(r.created_at.format("%Y-%m-%d").to_string())); serde_json::Value::Number(serde_json::Number::from(r.version)),
revision_map.insert("created_by".to_string(), serde_json::Value::String(r.created_by.clone())); );
revision_map.insert(
"content".to_string(),
serde_json::Value::String(r.content.clone()),
);
revision_map.insert(
"created_at".to_string(),
serde_json::Value::String(r.created_at.format("%Y-%m-%d").to_string()),
);
revision_map.insert(
"created_by".to_string(),
serde_json::Value::String(r.created_by.clone()),
);
if let Some(comments) = &r.comments { if let Some(comments) = &r.comments {
revision_map.insert("comments".to_string(), serde_json::Value::String(comments.clone())); revision_map.insert(
"comments".to_string(),
serde_json::Value::String(comments.clone()),
);
// Add notes field using comments since ContractRevision doesn't have a notes field // Add notes field using comments since ContractRevision doesn't have a notes field
revision_map.insert("notes".to_string(), serde_json::Value::String(comments.clone())); revision_map.insert(
"notes".to_string(),
serde_json::Value::String(comments.clone()),
);
} else { } else {
revision_map.insert("comments".to_string(), serde_json::Value::String("".to_string())); revision_map.insert(
revision_map.insert("notes".to_string(), serde_json::Value::String("".to_string())); "comments".to_string(),
serde_json::Value::String("".to_string()),
);
revision_map.insert(
"notes".to_string(),
serde_json::Value::String("".to_string()),
);
} }
serde_json::Value::Object(revision_map) serde_json::Value::Object(revision_map)
}) })
.collect(); .collect();
map.insert("revisions".to_string(), serde_json::Value::Array(revisions.clone())); map.insert(
"revisions".to_string(),
serde_json::Value::Array(revisions.clone()),
);
// Add current_version // Add current_version
map.insert("current_version".to_string(), serde_json::Value::Number(serde_json::Number::from(contract.current_version))); map.insert(
"current_version".to_string(),
serde_json::Value::Number(serde_json::Number::from(contract.current_version)),
);
// Add latest_revision as an object // Add latest_revision as an object
if !contract.revisions.is_empty() { if !contract.revisions.is_empty() {
// Find the latest revision based on version number // Find the latest revision based on version number
if let Some(latest) = contract.revisions.iter().max_by_key(|r| r.version) { if let Some(latest) = contract.revisions.iter().max_by_key(|r| r.version) {
let mut latest_revision_map = serde_json::Map::new(); let mut latest_revision_map = serde_json::Map::new();
latest_revision_map.insert("version".to_string(), serde_json::Value::Number(serde_json::Number::from(latest.version))); latest_revision_map.insert(
latest_revision_map.insert("content".to_string(), serde_json::Value::String(latest.content.clone())); "version".to_string(),
latest_revision_map.insert("created_at".to_string(), serde_json::Value::String(latest.created_at.format("%Y-%m-%d").to_string())); serde_json::Value::Number(serde_json::Number::from(latest.version)),
latest_revision_map.insert("created_by".to_string(), serde_json::Value::String(latest.created_by.clone())); );
latest_revision_map.insert(
"content".to_string(),
serde_json::Value::String(latest.content.clone()),
);
latest_revision_map.insert(
"created_at".to_string(),
serde_json::Value::String(latest.created_at.format("%Y-%m-%d").to_string()),
);
latest_revision_map.insert(
"created_by".to_string(),
serde_json::Value::String(latest.created_by.clone()),
);
if let Some(comments) = &latest.comments { if let Some(comments) = &latest.comments {
latest_revision_map.insert("comments".to_string(), serde_json::Value::String(comments.clone())); latest_revision_map.insert(
latest_revision_map.insert("notes".to_string(), serde_json::Value::String(comments.clone())); "comments".to_string(),
serde_json::Value::String(comments.clone()),
);
latest_revision_map.insert(
"notes".to_string(),
serde_json::Value::String(comments.clone()),
);
} else { } else {
latest_revision_map.insert("comments".to_string(), serde_json::Value::String("".to_string())); latest_revision_map.insert(
latest_revision_map.insert("notes".to_string(), serde_json::Value::String("".to_string())); "comments".to_string(),
serde_json::Value::String("".to_string()),
);
latest_revision_map.insert(
"notes".to_string(),
serde_json::Value::String("".to_string()),
);
} }
map.insert("latest_revision".to_string(), serde_json::Value::Object(latest_revision_map)); map.insert(
"latest_revision".to_string(),
serde_json::Value::Object(latest_revision_map),
);
} else { } else {
// Create an empty latest_revision object to avoid template errors // Create an empty latest_revision object to avoid template errors
let mut empty_revision = serde_json::Map::new(); let mut empty_revision = serde_json::Map::new();
empty_revision.insert("version".to_string(), serde_json::Value::Number(serde_json::Number::from(0))); empty_revision.insert(
empty_revision.insert("content".to_string(), serde_json::Value::String("No content available".to_string())); "version".to_string(),
empty_revision.insert("created_at".to_string(), serde_json::Value::String("N/A".to_string())); serde_json::Value::Number(serde_json::Number::from(0)),
empty_revision.insert("created_by".to_string(), serde_json::Value::String("N/A".to_string())); );
empty_revision.insert("comments".to_string(), serde_json::Value::String("".to_string())); empty_revision.insert(
empty_revision.insert("notes".to_string(), serde_json::Value::String("".to_string())); "content".to_string(),
serde_json::Value::String("No content available".to_string()),
map.insert("latest_revision".to_string(), serde_json::Value::Object(empty_revision)); );
empty_revision.insert(
"created_at".to_string(),
serde_json::Value::String("N/A".to_string()),
);
empty_revision.insert(
"created_by".to_string(),
serde_json::Value::String("N/A".to_string()),
);
empty_revision.insert(
"comments".to_string(),
serde_json::Value::String("".to_string()),
);
empty_revision.insert(
"notes".to_string(),
serde_json::Value::String("".to_string()),
);
map.insert(
"latest_revision".to_string(),
serde_json::Value::Object(empty_revision),
);
} }
} else { } else {
// Create an empty latest_revision object to avoid template errors // Create an empty latest_revision object to avoid template errors
let mut empty_revision = serde_json::Map::new(); let mut empty_revision = serde_json::Map::new();
empty_revision.insert("version".to_string(), serde_json::Value::Number(serde_json::Number::from(0))); empty_revision.insert(
empty_revision.insert("content".to_string(), serde_json::Value::String("No content available".to_string())); "version".to_string(),
empty_revision.insert("created_at".to_string(), serde_json::Value::String("N/A".to_string())); serde_json::Value::Number(serde_json::Number::from(0)),
empty_revision.insert("created_by".to_string(), serde_json::Value::String("N/A".to_string())); );
empty_revision.insert("comments".to_string(), serde_json::Value::String("".to_string())); empty_revision.insert(
empty_revision.insert("notes".to_string(), serde_json::Value::String("".to_string())); "content".to_string(),
serde_json::Value::String("No content available".to_string()),
map.insert("latest_revision".to_string(), serde_json::Value::Object(empty_revision)); );
empty_revision.insert(
"created_at".to_string(),
serde_json::Value::String("N/A".to_string()),
);
empty_revision.insert(
"created_by".to_string(),
serde_json::Value::String("N/A".to_string()),
);
empty_revision.insert(
"comments".to_string(),
serde_json::Value::String("".to_string()),
);
empty_revision.insert(
"notes".to_string(),
serde_json::Value::String("".to_string()),
);
map.insert(
"latest_revision".to_string(),
serde_json::Value::Object(empty_revision),
);
} }
// Add effective and expiration dates if present // Add effective and expiration dates if present
if let Some(effective_date) = &contract.effective_date { if let Some(effective_date) = &contract.effective_date {
map.insert("effective_date".to_string(), serde_json::Value::String(effective_date.format("%Y-%m-%d").to_string())); map.insert(
"effective_date".to_string(),
serde_json::Value::String(effective_date.format("%Y-%m-%d").to_string()),
);
} }
if let Some(expiration_date) = &contract.expiration_date { if let Some(expiration_date) = &contract.expiration_date {
map.insert("expiration_date".to_string(), serde_json::Value::String(expiration_date.format("%Y-%m-%d").to_string())); map.insert(
"expiration_date".to_string(),
serde_json::Value::String(expiration_date.format("%Y-%m-%d").to_string()),
);
} }
map map
} }
// Generate mock contracts for testing // Generate mock contracts for testing
fn get_mock_contracts() -> Vec<Contract> { fn get_mock_contracts() -> Vec<Contract> {
let mut contracts = Vec::new(); let mut contracts = Vec::new();
// Mock contract 1 - Signed Service Agreement // Mock contract 1 - Signed Service Agreement
let mut contract1 = Contract { let mut contract1 = Contract {
content_dir: None, content_dir: None,
@ -394,7 +602,7 @@ impl ContractController {
revisions: Vec::new(), revisions: Vec::new(),
current_version: 2, current_version: 2,
}; };
// Add signers to contract 1 // Add signers to contract 1
contract1.signers.push(ContractSigner { contract1.signers.push(ContractSigner {
id: "signer-001".to_string(), id: "signer-001".to_string(),
@ -404,7 +612,7 @@ impl ContractController {
signed_at: Some(Utc::now() - Duration::days(5)), signed_at: Some(Utc::now() - Duration::days(5)),
comments: Some("Approved as per our discussion.".to_string()), comments: Some("Approved as per our discussion.".to_string()),
}); });
contract1.signers.push(ContractSigner { contract1.signers.push(ContractSigner {
id: "signer-002".to_string(), id: "signer-002".to_string(),
name: "Nala Okafor".to_string(), name: "Nala Okafor".to_string(),
@ -413,7 +621,7 @@ impl ContractController {
signed_at: Some(Utc::now() - Duration::days(6)), signed_at: Some(Utc::now() - Duration::days(6)),
comments: Some("Terms look good. Happy to proceed.".to_string()), comments: Some("Terms look good. Happy to proceed.".to_string()),
}); });
// Add revisions to contract 1 // Add revisions to contract 1
contract1.revisions.push(ContractRevision { contract1.revisions.push(ContractRevision {
version: 1, version: 1,
@ -422,7 +630,7 @@ impl ContractController {
created_by: "Wei Chen".to_string(), created_by: "Wei Chen".to_string(),
comments: Some("Initial draft of the service agreement.".to_string()), comments: Some("Initial draft of the service agreement.".to_string()),
}); });
contract1.revisions.push(ContractRevision { contract1.revisions.push(ContractRevision {
version: 2, version: 2,
content: "<h1>Digital Hub Service Agreement</h1><p>This Service Agreement (the \"Agreement\") is entered into between Zanzibar Digital Hub (\"Provider\") and the undersigned client (\"Client\").</p><h2>1. Services</h2><p>Provider agrees to provide Client with cloud hosting and digital infrastructure services as specified in Appendix A.</p><h2>2. Term</h2><p>This Agreement shall commence on the Effective Date and continue for a period of one (1) year unless terminated earlier in accordance with the terms herein.</p><h2>3. Fees</h2><p>Client agrees to pay Provider the fees set forth in Appendix B. All fees are due within thirty (30) days of invoice date.</p><h2>4. Confidentiality</h2><p>Each party agrees to maintain the confidentiality of any proprietary information received from the other party during the term of this Agreement.</p><h2>5. Data Protection</h2><p>Provider shall implement appropriate technical and organizational measures to ensure a level of security appropriate to the risk, including encryption of personal data, and shall comply with all applicable data protection laws.</p>".to_string(), content: "<h1>Digital Hub Service Agreement</h1><p>This Service Agreement (the \"Agreement\") is entered into between Zanzibar Digital Hub (\"Provider\") and the undersigned client (\"Client\").</p><h2>1. Services</h2><p>Provider agrees to provide Client with cloud hosting and digital infrastructure services as specified in Appendix A.</p><h2>2. Term</h2><p>This Agreement shall commence on the Effective Date and continue for a period of one (1) year unless terminated earlier in accordance with the terms herein.</p><h2>3. Fees</h2><p>Client agrees to pay Provider the fees set forth in Appendix B. All fees are due within thirty (30) days of invoice date.</p><h2>4. Confidentiality</h2><p>Each party agrees to maintain the confidentiality of any proprietary information received from the other party during the term of this Agreement.</p><h2>5. Data Protection</h2><p>Provider shall implement appropriate technical and organizational measures to ensure a level of security appropriate to the risk, including encryption of personal data, and shall comply with all applicable data protection laws.</p>".to_string(),
@ -430,7 +638,7 @@ impl ContractController {
created_by: "Wei Chen".to_string(), created_by: "Wei Chen".to_string(),
comments: Some("Added data protection clause as requested by legal.".to_string()), comments: Some("Added data protection clause as requested by legal.".to_string()),
}); });
// Mock contract 2 - Pending Signatures // Mock contract 2 - Pending Signatures
let mut contract2 = Contract { let mut contract2 = Contract {
content_dir: None, content_dir: None,
@ -450,7 +658,7 @@ impl ContractController {
revisions: Vec::new(), revisions: Vec::new(),
current_version: 1, current_version: 1,
}; };
// Add signers to contract 2 // Add signers to contract 2
contract2.signers.push(ContractSigner { contract2.signers.push(ContractSigner {
id: "signer-003".to_string(), id: "signer-003".to_string(),
@ -460,7 +668,7 @@ impl ContractController {
signed_at: Some(Utc::now() - Duration::days(2)), signed_at: Some(Utc::now() - Duration::days(2)),
comments: None, comments: None,
}); });
contract2.signers.push(ContractSigner { contract2.signers.push(ContractSigner {
id: "signer-004".to_string(), id: "signer-004".to_string(),
name: "Maya Rodriguez".to_string(), name: "Maya Rodriguez".to_string(),
@ -469,7 +677,7 @@ impl ContractController {
signed_at: None, signed_at: None,
comments: None, comments: None,
}); });
contract2.signers.push(ContractSigner { contract2.signers.push(ContractSigner {
id: "signer-005".to_string(), id: "signer-005".to_string(),
name: "Jamal Washington".to_string(), name: "Jamal Washington".to_string(),
@ -478,7 +686,7 @@ impl ContractController {
signed_at: None, signed_at: None,
comments: None, comments: None,
}); });
// Add revisions to contract 2 // Add revisions to contract 2
contract2.revisions.push(ContractRevision { contract2.revisions.push(ContractRevision {
version: 1, version: 1,
@ -487,7 +695,7 @@ impl ContractController {
created_by: "Dr. Raj Patel".to_string(), created_by: "Dr. Raj Patel".to_string(),
comments: Some("Initial draft of the development agreement.".to_string()), comments: Some("Initial draft of the development agreement.".to_string()),
}); });
// Mock contract 3 - Draft // Mock contract 3 - Draft
let mut contract3 = Contract { let mut contract3 = Contract {
id: "contract-003".to_string(), id: "contract-003".to_string(),
@ -554,7 +762,6 @@ impl ContractController {
]), ]),
}; };
// Add potential signers to contract 3 (still in draft) // Add potential signers to contract 3 (still in draft)
contract3.signers.push(ContractSigner { contract3.signers.push(ContractSigner {
id: "signer-006".to_string(), id: "signer-006".to_string(),
@ -564,7 +771,7 @@ impl ContractController {
signed_at: None, signed_at: None,
comments: None, comments: None,
}); });
contract3.signers.push(ContractSigner { contract3.signers.push(ContractSigner {
id: "signer-007".to_string(), id: "signer-007".to_string(),
name: "Ibrahim Al-Farsi".to_string(), name: "Ibrahim Al-Farsi".to_string(),
@ -573,59 +780,57 @@ impl ContractController {
signed_at: None, signed_at: None,
comments: None, comments: None,
}); });
// Add ToC and content directory to contract 3 // Add ToC and content directory to contract 3
contract3.content_dir = Some("src/content/contract-003".to_string()); contract3.content_dir = Some("src/content/contract-003".to_string());
contract3.toc = Some(vec![ contract3.toc = Some(vec![TocItem {
TocItem { title: "Digital Asset Tokenization Agreement".to_string(),
title: "Digital Asset Tokenization Agreement".to_string(), file: "cover.md".to_string(),
file: "cover.md".to_string(), children: vec![
children: vec![ TocItem {
TocItem { title: "1. Purpose".to_string(),
title: "1. Purpose".to_string(), file: "1-purpose.md".to_string(),
file: "1-purpose.md".to_string(), children: vec![],
children: vec![], },
}, TocItem {
TocItem { title: "2. Tokenization Process".to_string(),
title: "2. Tokenization Process".to_string(), file: "2-tokenization-process.md".to_string(),
file: "2-tokenization-process.md".to_string(), children: vec![],
children: vec![], },
}, TocItem {
TocItem { title: "3. Revenue Sharing".to_string(),
title: "3. Revenue Sharing".to_string(), file: "3-revenue-sharing.md".to_string(),
file: "3-revenue-sharing.md".to_string(), children: vec![],
children: vec![], },
}, TocItem {
TocItem { title: "4. Governance".to_string(),
title: "4. Governance".to_string(), file: "4-governance.md".to_string(),
file: "4-governance.md".to_string(), children: vec![],
children: vec![], },
}, TocItem {
TocItem { title: "Appendix A: Properties".to_string(),
title: "Appendix A: Properties".to_string(), file: "appendix-a.md".to_string(),
file: "appendix-a.md".to_string(), children: vec![],
children: vec![], },
}, TocItem {
TocItem { title: "Appendix B: Specifications".to_string(),
title: "Appendix B: Specifications".to_string(), file: "appendix-b.md".to_string(),
file: "appendix-b.md".to_string(), children: vec![],
children: vec![], },
}, TocItem {
TocItem { title: "Appendix C: Revenue Formula".to_string(),
title: "Appendix C: Revenue Formula".to_string(), file: "appendix-c.md".to_string(),
file: "appendix-c.md".to_string(), children: vec![],
children: vec![], },
}, TocItem {
TocItem { title: "Appendix D: Governance Framework".to_string(),
title: "Appendix D: Governance Framework".to_string(), file: "appendix-d.md".to_string(),
file: "appendix-d.md".to_string(), children: vec![],
children: vec![], },
}, ],
], }]);
}
]);
// No revision content for contract 3, content is in markdown files. // No revision content for contract 3, content is in markdown files.
// Mock contract 4 - Rejected // Mock contract 4 - Rejected
let mut contract4 = Contract { let mut contract4 = Contract {
content_dir: None, content_dir: None,
@ -645,7 +850,7 @@ impl ContractController {
revisions: Vec::new(), revisions: Vec::new(),
current_version: 1, current_version: 1,
}; };
// Add signers to contract 4 with a rejection // Add signers to contract 4 with a rejection
contract4.signers.push(ContractSigner { contract4.signers.push(ContractSigner {
id: "signer-008".to_string(), id: "signer-008".to_string(),
@ -655,7 +860,7 @@ impl ContractController {
signed_at: Some(Utc::now() - Duration::days(10)), signed_at: Some(Utc::now() - Duration::days(10)),
comments: None, comments: None,
}); });
contract4.signers.push(ContractSigner { contract4.signers.push(ContractSigner {
id: "signer-009".to_string(), id: "signer-009".to_string(),
name: "Dr. Amina Diallo".to_string(), name: "Dr. Amina Diallo".to_string(),
@ -664,7 +869,7 @@ impl ContractController {
signed_at: Some(Utc::now() - Duration::days(8)), signed_at: Some(Utc::now() - Duration::days(8)),
comments: Some("Cannot agree to these terms due to privacy concerns. Please revise section 3.2 regarding data retention.".to_string()), comments: Some("Cannot agree to these terms due to privacy concerns. Please revise section 3.2 regarding data retention.".to_string()),
}); });
// Add revisions to contract 4 // Add revisions to contract 4
contract4.revisions.push(ContractRevision { contract4.revisions.push(ContractRevision {
version: 1, version: 1,
@ -673,7 +878,7 @@ impl ContractController {
created_by: "Wei Chen".to_string(), created_by: "Wei Chen".to_string(),
comments: Some("Initial draft of the data sharing agreement.".to_string()), comments: Some("Initial draft of the data sharing agreement.".to_string()),
}); });
// Mock contract 5 - Active // Mock contract 5 - Active
let mut contract5 = Contract { let mut contract5 = Contract {
content_dir: None, content_dir: None,
@ -693,7 +898,7 @@ impl ContractController {
revisions: Vec::new(), revisions: Vec::new(),
current_version: 2, current_version: 2,
}; };
// Add signers to contract 5 // Add signers to contract 5
contract5.signers.push(ContractSigner { contract5.signers.push(ContractSigner {
id: "signer-010".to_string(), id: "signer-010".to_string(),
@ -703,7 +908,7 @@ impl ContractController {
signed_at: Some(Utc::now() - Duration::days(47)), signed_at: Some(Utc::now() - Duration::days(47)),
comments: None, comments: None,
}); });
contract5.signers.push(ContractSigner { contract5.signers.push(ContractSigner {
id: "signer-011".to_string(), id: "signer-011".to_string(),
name: "Li Wei".to_string(), name: "Li Wei".to_string(),
@ -712,7 +917,7 @@ impl ContractController {
signed_at: Some(Utc::now() - Duration::days(45)), signed_at: Some(Utc::now() - Duration::days(45)),
comments: Some("Approved after legal review.".to_string()), comments: Some("Approved after legal review.".to_string()),
}); });
// Add revisions to contract 5 // Add revisions to contract 5
contract5.revisions.push(ContractRevision { contract5.revisions.push(ContractRevision {
version: 1, version: 1,
@ -721,7 +926,7 @@ impl ContractController {
created_by: "Maya Rodriguez".to_string(), created_by: "Maya Rodriguez".to_string(),
comments: Some("Initial draft of the identity verification service agreement.".to_string()), comments: Some("Initial draft of the identity verification service agreement.".to_string()),
}); });
contract5.revisions.push(ContractRevision { contract5.revisions.push(ContractRevision {
version: 2, version: 2,
content: "<h1>Digital Identity Verification Service Agreement</h1><p>This Service Agreement (the \"Agreement\") is entered into between Zanzibar Digital Hub (\"Provider\") and the businesses listed in Appendix A (\"Clients\").</p><h2>1. Services</h2><p>Provider agrees to provide Clients with digital identity verification services as specified in Appendix B.</p><h2>2. Term</h2><p>This Agreement shall commence on the Effective Date and continue for a period of one (1) year unless terminated earlier in accordance with the terms herein.</p><h2>3. Fees</h2><p>Clients agree to pay Provider the fees set forth in Appendix C. All fees are due within thirty (30) days of invoice date.</p><h2>4. Service Level Agreement</h2><p>Provider shall maintain a service uptime of at least 99.9% as measured on a monthly basis.</p><h2>5. Compliance</h2><p>Provider shall comply with all applicable laws and regulations regarding identity verification and data protection, including but not limited to the Zanzibar Digital Economy Act.</p>".to_string(), content: "<h1>Digital Identity Verification Service Agreement</h1><p>This Service Agreement (the \"Agreement\") is entered into between Zanzibar Digital Hub (\"Provider\") and the businesses listed in Appendix A (\"Clients\").</p><h2>1. Services</h2><p>Provider agrees to provide Clients with digital identity verification services as specified in Appendix B.</p><h2>2. Term</h2><p>This Agreement shall commence on the Effective Date and continue for a period of one (1) year unless terminated earlier in accordance with the terms herein.</p><h2>3. Fees</h2><p>Clients agree to pay Provider the fees set forth in Appendix C. All fees are due within thirty (30) days of invoice date.</p><h2>4. Service Level Agreement</h2><p>Provider shall maintain a service uptime of at least 99.9% as measured on a monthly basis.</p><h2>5. Compliance</h2><p>Provider shall comply with all applicable laws and regulations regarding identity verification and data protection, including but not limited to the Zanzibar Digital Economy Act.</p>".to_string(),
@ -729,14 +934,14 @@ impl ContractController {
created_by: "Maya Rodriguez".to_string(), created_by: "Maya Rodriguez".to_string(),
comments: Some("Added compliance clause as requested by legal.".to_string()), comments: Some("Added compliance clause as requested by legal.".to_string()),
}); });
// Add all contracts to the vector // Add all contracts to the vector
contracts.push(contract1); contracts.push(contract1);
contracts.push(contract2); contracts.push(contract2);
contracts.push(contract3); contracts.push(contract3);
contracts.push(contract4); contracts.push(contract4);
contracts.push(contract5); contracts.push(contract5);
contracts contracts
} }
} }

View File

@ -1,12 +1,15 @@
use actix_web::{web, HttpResponse, Result};
use actix_web::HttpRequest; use actix_web::HttpRequest;
use tera::{Context, Tera}; use actix_web::{HttpResponse, Result, web};
use chrono::{Utc, Duration}; use chrono::{Duration, Utc};
use serde::Deserialize; use serde::Deserialize;
use tera::{Context, Tera};
use uuid::Uuid; use uuid::Uuid;
use crate::models::asset::{Asset, AssetType, AssetStatus}; use crate::models::asset::Asset;
use crate::models::defi::{DefiPosition, DefiPositionType, DefiPositionStatus, ProvidingPosition, ReceivingPosition, DEFI_DB}; use crate::models::defi::{
DEFI_DB, DefiPosition, DefiPositionStatus, DefiPositionType, ProvidingPosition,
ReceivingPosition,
};
use crate::utils::render_template; use crate::utils::render_template;
// Form structs for DeFi operations // Form structs for DeFi operations
@ -26,6 +29,7 @@ pub struct ReceivingForm {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct LiquidityForm { pub struct LiquidityForm {
pub first_token: String, pub first_token: String,
pub first_amount: f64, pub first_amount: f64,
@ -35,6 +39,7 @@ pub struct LiquidityForm {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct StakingForm { pub struct StakingForm {
pub asset_id: String, pub asset_id: String,
pub amount: f64, pub amount: f64,
@ -49,6 +54,7 @@ pub struct SwapForm {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct CollateralForm { pub struct CollateralForm {
pub asset_id: String, pub asset_id: String,
pub amount: f64, pub amount: f64,
@ -63,29 +69,29 @@ impl DefiController {
// Display the DeFi dashboard // Display the DeFi dashboard
pub async fn index(tmpl: web::Data<Tera>, req: HttpRequest) -> Result<HttpResponse> { pub async fn index(tmpl: web::Data<Tera>, req: HttpRequest) -> Result<HttpResponse> {
let mut context = Context::new(); let mut context = Context::new();
println!("DEBUG: Starting DeFi dashboard rendering"); println!("DEBUG: Starting DeFi dashboard rendering");
// Get mock assets for the dropdown selectors // Get mock assets for the dropdown selectors
let assets = Self::get_mock_assets(); let assets = Self::get_mock_assets();
println!("DEBUG: Generated {} mock assets", assets.len()); println!("DEBUG: Generated {} mock assets", assets.len());
// Add active_page for navigation highlighting // Add active_page for navigation highlighting
context.insert("active_page", &"defi"); context.insert("active_page", &"defi");
// Add DeFi stats // Add DeFi stats
let defi_stats = Self::get_defi_stats(); let defi_stats = Self::get_defi_stats();
context.insert("defi_stats", &serde_json::to_value(defi_stats).unwrap()); context.insert("defi_stats", &serde_json::to_value(defi_stats).unwrap());
// Add recent assets for selection in forms // Add recent assets for selection in forms
let recent_assets: Vec<serde_json::Map<String, serde_json::Value>> = assets let recent_assets: Vec<serde_json::Map<String, serde_json::Value>> = assets
.iter() .iter()
.take(5) .take(5)
.map(|a| Self::asset_to_json(a)) .map(|a| Self::asset_to_json(a))
.collect(); .collect();
context.insert("recent_assets", &recent_assets); context.insert("recent_assets", &recent_assets);
// Get user's providing positions // Get user's providing positions
let db = DEFI_DB.lock().unwrap(); let db = DEFI_DB.lock().unwrap();
let providing_positions = db.get_user_providing_positions("user123"); let providing_positions = db.get_user_providing_positions("user123");
@ -94,7 +100,7 @@ impl DefiController {
.map(|p| serde_json::to_value(p).unwrap()) .map(|p| serde_json::to_value(p).unwrap())
.collect(); .collect();
context.insert("providing_positions", &providing_positions_json); context.insert("providing_positions", &providing_positions_json);
// Get user's receiving positions // Get user's receiving positions
let receiving_positions = db.get_user_receiving_positions("user123"); let receiving_positions = db.get_user_receiving_positions("user123");
let receiving_positions_json: Vec<serde_json::Value> = receiving_positions let receiving_positions_json: Vec<serde_json::Value> = receiving_positions
@ -102,27 +108,30 @@ impl DefiController {
.map(|p| serde_json::to_value(p).unwrap()) .map(|p| serde_json::to_value(p).unwrap())
.collect(); .collect();
context.insert("receiving_positions", &receiving_positions_json); context.insert("receiving_positions", &receiving_positions_json);
// Add success message if present in query params // Add success message if present in query params
if let Some(success) = req.query_string().strip_prefix("success=") { if let Some(success) = req.query_string().strip_prefix("success=") {
let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into()); let decoded = urlencoding::decode(success).unwrap_or_else(|_| success.into());
context.insert("success_message", &decoded); context.insert("success_message", &decoded);
} }
println!("DEBUG: Rendering DeFi dashboard template"); println!("DEBUG: Rendering DeFi dashboard template");
let response = render_template(&tmpl, "defi/index.html", &context); let response = render_template(&tmpl, "defi/index.html", &context);
println!("DEBUG: Finished rendering DeFi dashboard template"); println!("DEBUG: Finished rendering DeFi dashboard template");
response response
} }
// Process providing request // Process providing request
pub async fn create_providing(_tmpl: web::Data<Tera>, form: web::Form<ProvidingForm>) -> Result<HttpResponse> { pub async fn create_providing(
_tmpl: web::Data<Tera>,
form: web::Form<ProvidingForm>,
) -> Result<HttpResponse> {
println!("DEBUG: Processing providing request: {:?}", form); println!("DEBUG: Processing providing request: {:?}", form);
// Get the asset obligationails (in a real app, this would come from a database) // Get the asset obligationails (in a real app, this would come from a database)
let assets = Self::get_mock_assets(); let assets = Self::get_mock_assets();
let asset = assets.iter().find(|a| a.id == form.asset_id); let asset = assets.iter().find(|a| a.id == form.asset_id);
if let Some(asset) = asset { if let Some(asset) = asset {
// Calculate profit share and return amount // Calculate profit share and return amount
let profit_share = match form.duration { let profit_share = match form.duration {
@ -133,9 +142,10 @@ impl DefiController {
365 => 12.0, 365 => 12.0,
_ => 4.2, // Default to 30 days rate _ => 4.2, // Default to 30 days rate
}; };
let return_amount = form.amount + (form.amount * (profit_share / 100.0) * (form.duration as f64 / 365.0)); let return_amount = form.amount
+ (form.amount * (profit_share / 100.0) * (form.duration as f64 / 365.0));
// Create a new providing position // Create a new providing position
let providing_position = ProvidingPosition { let providing_position = ProvidingPosition {
base: DefiPosition { base: DefiPosition {
@ -156,17 +166,23 @@ impl DefiController {
profit_share_earned: profit_share, profit_share_earned: profit_share,
return_amount, return_amount,
}; };
// Add the position to the database // Add the position to the database
{ {
let mut db = DEFI_DB.lock().unwrap(); let mut db = DEFI_DB.lock().unwrap();
db.add_providing_position(providing_position); db.add_providing_position(providing_position);
} }
// Redirect with success message // Redirect with success message
let success_message = format!("Successfully provided {} {} for {} days", form.amount, asset.name, form.duration); let success_message = format!(
"Successfully provided {} {} for {} days",
form.amount, asset.name, form.duration
);
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message)))) .append_header((
"Location",
format!("/defi?success={}", urlencoding::encode(&success_message)),
))
.finish()) .finish())
} else { } else {
// Asset not found, redirect with error // Asset not found, redirect with error
@ -175,15 +191,18 @@ impl DefiController {
.finish()) .finish())
} }
} }
// Process receiving request // Process receiving request
pub async fn create_receiving(_tmpl: web::Data<Tera>, form: web::Form<ReceivingForm>) -> Result<HttpResponse> { pub async fn create_receiving(
_tmpl: web::Data<Tera>,
form: web::Form<ReceivingForm>,
) -> Result<HttpResponse> {
println!("DEBUG: Processing receiving request: {:?}", form); println!("DEBUG: Processing receiving request: {:?}", form);
// Get the asset obligationails (in a real app, this would come from a database) // Get the asset obligationails (in a real app, this would come from a database)
let assets = Self::get_mock_assets(); let assets = Self::get_mock_assets();
let collateral_asset = assets.iter().find(|a| a.id == form.collateral_asset_id); let collateral_asset = assets.iter().find(|a| a.id == form.collateral_asset_id);
if let Some(collateral_asset) = collateral_asset { if let Some(collateral_asset) = collateral_asset {
// Calculate profit share rate based on duration // Calculate profit share rate based on duration
let profit_share_rate = match form.duration { let profit_share_rate = match form.duration {
@ -194,15 +213,17 @@ impl DefiController {
365 => 10.0, 365 => 10.0,
_ => 5.0, // Default to 30 days rate _ => 5.0, // Default to 30 days rate
}; };
// Calculate profit share and total to repay // Calculate profit share and total to repay
let profit_share = form.amount * (profit_share_rate / 100.0) * (form.duration as f64 / 365.0); let profit_share =
form.amount * (profit_share_rate / 100.0) * (form.duration as f64 / 365.0);
let total_to_repay = form.amount + profit_share; let total_to_repay = form.amount + profit_share;
// Calculate collateral value and ratio // Calculate collateral value and ratio
let collateral_value = form.collateral_amount * collateral_asset.latest_valuation().map_or(0.5, |v| v.value); let collateral_value = form.collateral_amount
* collateral_asset.latest_valuation().map_or(0.5, |v| v.value);
let collateral_ratio = (collateral_value / form.amount) * 100.0; let collateral_ratio = (collateral_value / form.amount) * 100.0;
// Create a new receiving position // Create a new receiving position
let receiving_position = ReceivingPosition { let receiving_position = ReceivingPosition {
base: DefiPosition { base: DefiPosition {
@ -230,18 +251,23 @@ impl DefiController {
total_to_repay, total_to_repay,
collateral_ratio, collateral_ratio,
}; };
// Add the position to the database // Add the position to the database
{ {
let mut db = DEFI_DB.lock().unwrap(); let mut db = DEFI_DB.lock().unwrap();
db.add_receiving_position(receiving_position); db.add_receiving_position(receiving_position);
} }
// Redirect with success message // Redirect with success message
let success_message = format!("Successfully borrowed {} ZDFZ using {} {} as collateral", let success_message = format!(
form.amount, form.collateral_amount, collateral_asset.name); "Successfully borrowed {} ZDFZ using {} {} as collateral",
form.amount, form.collateral_amount, collateral_asset.name
);
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message)))) .append_header((
"Location",
format!("/defi?success={}", urlencoding::encode(&success_message)),
))
.finish()) .finish())
} else { } else {
// Asset not found, redirect with error // Asset not found, redirect with error
@ -250,116 +276,202 @@ impl DefiController {
.finish()) .finish())
} }
} }
// Process liquidity provision // Process liquidity provision
pub async fn add_liquidity(_tmpl: web::Data<Tera>, form: web::Form<LiquidityForm>) -> Result<HttpResponse> { pub async fn add_liquidity(
_tmpl: web::Data<Tera>,
form: web::Form<LiquidityForm>,
) -> Result<HttpResponse> {
println!("DEBUG: Processing liquidity provision: {:?}", form); println!("DEBUG: Processing liquidity provision: {:?}", form);
// In a real application, this would add liquidity to a pool in the database // In a real application, this would add liquidity to a pool in the database
// For now, we'll just redirect back to the DeFi dashboard with a success message // For now, we'll just redirect back to the DeFi dashboard with a success message
let success_message = format!("Successfully added liquidity: {} {} and {} {}", let success_message = format!(
form.first_amount, form.first_token, form.second_amount, form.second_token); "Successfully added liquidity: {} {} and {} {}",
form.first_amount, form.first_token, form.second_amount, form.second_token
);
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message)))) .append_header((
"Location",
format!("/defi?success={}", urlencoding::encode(&success_message)),
))
.finish()) .finish())
} }
// Process staking request // Process staking request
pub async fn create_staking(_tmpl: web::Data<Tera>, form: web::Form<StakingForm>) -> Result<HttpResponse> { pub async fn create_staking(
_tmpl: web::Data<Tera>,
form: web::Form<StakingForm>,
) -> Result<HttpResponse> {
println!("DEBUG: Processing staking request: {:?}", form); println!("DEBUG: Processing staking request: {:?}", form);
// In a real application, this would create a staking position in the database // In a real application, this would create a staking position in the database
// For now, we'll just redirect back to the DeFi dashboard with a success message // For now, we'll just redirect back to the DeFi dashboard with a success message
let success_message = format!("Successfully staked {} {}", form.amount, form.asset_id); let success_message = format!("Successfully staked {} {}", form.amount, form.asset_id);
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message)))) .append_header((
"Location",
format!("/defi?success={}", urlencoding::encode(&success_message)),
))
.finish()) .finish())
} }
// Process token swap // Process token swap
pub async fn swap_tokens(_tmpl: web::Data<Tera>, form: web::Form<SwapForm>) -> Result<HttpResponse> { pub async fn swap_tokens(
_tmpl: web::Data<Tera>,
form: web::Form<SwapForm>,
) -> Result<HttpResponse> {
println!("DEBUG: Processing token swap: {:?}", form); println!("DEBUG: Processing token swap: {:?}", form);
// In a real application, this would perform a token swap in the database // In a real application, this would perform a token swap in the database
// For now, we'll just redirect back to the DeFi dashboard with a success message // For now, we'll just redirect back to the DeFi dashboard with a success message
let success_message = format!("Successfully swapped {} {} to {}", let success_message = format!(
form.from_amount, form.from_token, form.to_token); "Successfully swapped {} {} to {}",
form.from_amount, form.from_token, form.to_token
);
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message)))) .append_header((
"Location",
format!("/defi?success={}", urlencoding::encode(&success_message)),
))
.finish()) .finish())
} }
// Process collateral position creation // Process collateral position creation
pub async fn create_collateral(_tmpl: web::Data<Tera>, form: web::Form<CollateralForm>) -> Result<HttpResponse> { pub async fn create_collateral(
_tmpl: web::Data<Tera>,
form: web::Form<CollateralForm>,
) -> Result<HttpResponse> {
println!("DEBUG: Processing collateral creation: {:?}", form); println!("DEBUG: Processing collateral creation: {:?}", form);
// In a real application, this would create a collateral position in the database // In a real application, this would create a collateral position in the database
// For now, we'll just redirect back to the DeFi dashboard with a success message // For now, we'll just redirect back to the DeFi dashboard with a success message
let purpose_str = match form.purpose.as_str() { let purpose_str = match form.purpose.as_str() {
"funds" => "secure a funds", "funds" => "secure a funds",
"synthetic" => "generate synthetic assets", "synthetic" => "generate synthetic assets",
"leverage" => "leverage trading", "leverage" => "leverage trading",
_ => "collateralization", _ => "collateralization",
}; };
let success_message = format!("Successfully collateralized {} {} for {}", let success_message = format!(
form.amount, form.asset_id, purpose_str); "Successfully collateralized {} {} for {}",
form.amount, form.asset_id, purpose_str
);
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.append_header(("Location", format!("/defi?success={}", urlencoding::encode(&success_message)))) .append_header((
"Location",
format!("/defi?success={}", urlencoding::encode(&success_message)),
))
.finish()) .finish())
} }
// Helper method to get DeFi statistics // Helper method to get DeFi statistics
fn get_defi_stats() -> serde_json::Map<String, serde_json::Value> { fn get_defi_stats() -> serde_json::Map<String, serde_json::Value> {
let mut stats = serde_json::Map::new(); let mut stats = serde_json::Map::new();
// Handle Option<Number> by unwrapping with expect // Handle Option<Number> by unwrapping with expect
stats.insert("total_value_locked".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(1250000.0).expect("Valid float"))); stats.insert(
stats.insert("providing_volume".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(450000.0).expect("Valid float"))); "total_value_locked".to_string(),
stats.insert("receiving_volume".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(320000.0).expect("Valid float"))); serde_json::Value::Number(
stats.insert("liquidity_pools_count".to_string(), serde_json::Value::Number(serde_json::Number::from(12))); serde_json::Number::from_f64(1250000.0).expect("Valid float"),
stats.insert("active_stakers".to_string(), serde_json::Value::Number(serde_json::Number::from(156))); ),
stats.insert("total_swap_volume".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(780000.0).expect("Valid float"))); );
stats.insert(
"providing_volume".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(450000.0).expect("Valid float")),
);
stats.insert(
"receiving_volume".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(320000.0).expect("Valid float")),
);
stats.insert(
"liquidity_pools_count".to_string(),
serde_json::Value::Number(serde_json::Number::from(12)),
);
stats.insert(
"active_stakers".to_string(),
serde_json::Value::Number(serde_json::Number::from(156)),
);
stats.insert(
"total_swap_volume".to_string(),
serde_json::Value::Number(serde_json::Number::from_f64(780000.0).expect("Valid float")),
);
stats stats
} }
// Helper method to convert Asset to a JSON object for templates // Helper method to convert Asset to a JSON object for templates
fn asset_to_json(asset: &Asset) -> serde_json::Map<String, serde_json::Value> { fn asset_to_json(asset: &Asset) -> serde_json::Map<String, serde_json::Value> {
let mut map = serde_json::Map::new(); let mut map = serde_json::Map::new();
map.insert("id".to_string(), serde_json::Value::String(asset.id.clone())); map.insert(
map.insert("name".to_string(), serde_json::Value::String(asset.name.clone())); "id".to_string(),
map.insert("description".to_string(), serde_json::Value::String(asset.description.clone())); serde_json::Value::String(asset.id.clone()),
map.insert("asset_type".to_string(), serde_json::Value::String(asset.asset_type.as_str().to_string())); );
map.insert("status".to_string(), serde_json::Value::String(asset.status.as_str().to_string())); map.insert(
"name".to_string(),
serde_json::Value::String(asset.name.clone()),
);
map.insert(
"description".to_string(),
serde_json::Value::String(asset.description.clone()),
);
map.insert(
"asset_type".to_string(),
serde_json::Value::String(asset.asset_type.as_str().to_string()),
);
map.insert(
"status".to_string(),
serde_json::Value::String(asset.status.as_str().to_string()),
);
// Add current valuation // Add current valuation
if let Some(latest) = asset.latest_valuation() { if let Some(latest) = asset.latest_valuation() {
if let Some(num) = serde_json::Number::from_f64(latest.value) { if let Some(num) = serde_json::Number::from_f64(latest.value) {
map.insert("current_valuation".to_string(), serde_json::Value::Number(num)); map.insert(
"current_valuation".to_string(),
serde_json::Value::Number(num),
);
} else { } else {
map.insert("current_valuation".to_string(), serde_json::Value::Number(serde_json::Number::from(0))); map.insert(
"current_valuation".to_string(),
serde_json::Value::Number(serde_json::Number::from(0)),
);
} }
map.insert("valuation_currency".to_string(), serde_json::Value::String(latest.currency.clone())); map.insert(
map.insert("valuation_date".to_string(), serde_json::Value::String(latest.date.format("%Y-%m-%d").to_string())); "valuation_currency".to_string(),
serde_json::Value::String(latest.currency.clone()),
);
map.insert(
"valuation_date".to_string(),
serde_json::Value::String(latest.date.format("%Y-%m-%d").to_string()),
);
} else { } else {
map.insert("current_valuation".to_string(), serde_json::Value::Number(serde_json::Number::from(0))); map.insert(
map.insert("valuation_currency".to_string(), serde_json::Value::String("USD".to_string())); "current_valuation".to_string(),
map.insert("valuation_date".to_string(), serde_json::Value::String("N/A".to_string())); serde_json::Value::Number(serde_json::Number::from(0)),
);
map.insert(
"valuation_currency".to_string(),
serde_json::Value::String("USD".to_string()),
);
map.insert(
"valuation_date".to_string(),
serde_json::Value::String("N/A".to_string()),
);
} }
map map
} }
// Generate mock assets for testing // Generate mock assets for testing
fn get_mock_assets() -> Vec<Asset> { fn get_mock_assets() -> Vec<Asset> {
// Reuse the asset controller's mock data function // Reuse the asset controller's mock data function

View File

@ -609,6 +609,7 @@ impl FlowController {
/// Form for creating a new flow /// Form for creating a new flow
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct FlowForm { pub struct FlowForm {
/// Flow name /// Flow name
pub name: String, pub name: String,
@ -620,6 +621,7 @@ pub struct FlowForm {
/// Form for marking a step as stuck /// Form for marking a step as stuck
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct StuckForm { pub struct StuckForm {
/// Reason for being stuck /// Reason for being stuck
pub reason: String, pub reason: String,
@ -627,6 +629,7 @@ pub struct StuckForm {
/// Form for adding a log to a step /// Form for adding a log to a step
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct LogForm { pub struct LogForm {
/// Log message /// Log message
pub message: String, pub message: String,

File diff suppressed because it is too large Load Diff

View File

@ -96,6 +96,7 @@ impl HomeController {
/// Represents the data submitted in the contact form /// Represents the data submitted in the contact form
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
#[allow(dead_code)]
pub struct ContactForm { pub struct ContactForm {
pub name: String, pub name: String,
pub email: String, pub email: String,

View File

@ -1,12 +1,11 @@
use actix_web::{web, HttpResponse, Result, http}; use actix_web::{HttpResponse, Result, http, web};
use tera::{Context, Tera}; use chrono::{Duration, Utc};
use chrono::{Utc, Duration};
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use tera::{Context, Tera};
use crate::models::asset::{Asset, AssetType, AssetStatus};
use crate::models::marketplace::{Listing, ListingStatus, ListingType, Bid, BidStatus, MarketplaceStatistics};
use crate::controllers::asset::AssetController; use crate::controllers::asset::AssetController;
use crate::models::asset::{Asset, AssetStatus, AssetType};
use crate::models::marketplace::{Listing, ListingStatus, ListingType, MarketplaceStatistics};
use crate::utils::render_template; use crate::utils::render_template;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -22,6 +21,7 @@ pub struct ListingForm {
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct BidForm { pub struct BidForm {
pub amount: f64, pub amount: f64,
pub currency: String, pub currency: String,
@ -38,30 +38,33 @@ impl MarketplaceController {
// Display the marketplace dashboard // Display the marketplace dashboard
pub async fn index(tmpl: web::Data<Tera>) -> Result<HttpResponse> { pub async fn index(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
let mut context = Context::new(); let mut context = Context::new();
let listings = Self::get_mock_listings(); let listings = Self::get_mock_listings();
let stats = MarketplaceStatistics::new(&listings); let stats = MarketplaceStatistics::new(&listings);
// Get featured listings (up to 4) // Get featured listings (up to 4)
let featured_listings: Vec<&Listing> = listings.iter() let featured_listings: Vec<&Listing> = listings
.iter()
.filter(|l| l.featured && l.status == ListingStatus::Active) .filter(|l| l.featured && l.status == ListingStatus::Active)
.take(4) .take(4)
.collect(); .collect();
// Get recent listings (up to 8) // Get recent listings (up to 8)
let mut recent_listings: Vec<&Listing> = listings.iter() let mut recent_listings: Vec<&Listing> = listings
.iter()
.filter(|l| l.status == ListingStatus::Active) .filter(|l| l.status == ListingStatus::Active)
.collect(); .collect();
// Sort by created_at (newest first) // Sort by created_at (newest first)
recent_listings.sort_by(|a, b| b.created_at.cmp(&a.created_at)); recent_listings.sort_by(|a, b| b.created_at.cmp(&a.created_at));
let recent_listings = recent_listings.into_iter().take(8).collect::<Vec<_>>(); let recent_listings = recent_listings.into_iter().take(8).collect::<Vec<_>>();
// Get recent sales (up to 5) // Get recent sales (up to 5)
let mut recent_sales: Vec<&Listing> = listings.iter() let mut recent_sales: Vec<&Listing> = listings
.iter()
.filter(|l| l.status == ListingStatus::Sold) .filter(|l| l.status == ListingStatus::Sold)
.collect(); .collect();
// Sort by sold_at (newest first) // Sort by sold_at (newest first)
recent_sales.sort_by(|a, b| { recent_sales.sort_by(|a, b| {
let a_sold = a.sold_at.unwrap_or(a.created_at); let a_sold = a.sold_at.unwrap_or(a.created_at);
@ -69,88 +72,101 @@ impl MarketplaceController {
b_sold.cmp(&a_sold) b_sold.cmp(&a_sold)
}); });
let recent_sales = recent_sales.into_iter().take(5).collect::<Vec<_>>(); let recent_sales = recent_sales.into_iter().take(5).collect::<Vec<_>>();
// Add data to context // Add data to context
context.insert("active_page", &"marketplace"); context.insert("active_page", &"marketplace");
context.insert("stats", &stats); context.insert("stats", &stats);
context.insert("featured_listings", &featured_listings); context.insert("featured_listings", &featured_listings);
context.insert("recent_listings", &recent_listings); context.insert("recent_listings", &recent_listings);
context.insert("recent_sales", &recent_sales); context.insert("recent_sales", &recent_sales);
render_template(&tmpl, "marketplace/index.html", &context) render_template(&tmpl, "marketplace/index.html", &context)
} }
// Display all marketplace listings // Display all marketplace listings
pub async fn list_listings(tmpl: web::Data<Tera>) -> Result<HttpResponse> { pub async fn list_listings(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
let mut context = Context::new(); let mut context = Context::new();
let listings = Self::get_mock_listings(); let listings = Self::get_mock_listings();
// Filter active listings // Filter active listings
let active_listings: Vec<&Listing> = listings.iter() let active_listings: Vec<&Listing> = listings
.iter()
.filter(|l| l.status == ListingStatus::Active) .filter(|l| l.status == ListingStatus::Active)
.collect(); .collect();
context.insert("active_page", &"marketplace"); context.insert("active_page", &"marketplace");
context.insert("listings", &active_listings); context.insert("listings", &active_listings);
context.insert("listing_types", &[ context.insert(
ListingType::FixedPrice.as_str(), "listing_types",
ListingType::Auction.as_str(), &[
ListingType::Exchange.as_str(), ListingType::FixedPrice.as_str(),
]); ListingType::Auction.as_str(),
context.insert("asset_types", &[ ListingType::Exchange.as_str(),
AssetType::Token.as_str(), ],
AssetType::Artwork.as_str(), );
AssetType::RealEstate.as_str(), context.insert(
AssetType::IntellectualProperty.as_str(), "asset_types",
AssetType::Commodity.as_str(), &[
AssetType::Share.as_str(), AssetType::Token.as_str(),
AssetType::Bond.as_str(), AssetType::Artwork.as_str(),
AssetType::Other.as_str(), AssetType::RealEstate.as_str(),
]); AssetType::IntellectualProperty.as_str(),
AssetType::Commodity.as_str(),
AssetType::Share.as_str(),
AssetType::Bond.as_str(),
AssetType::Other.as_str(),
],
);
render_template(&tmpl, "marketplace/listings.html", &context) render_template(&tmpl, "marketplace/listings.html", &context)
} }
// Display my listings // Display my listings
pub async fn my_listings(tmpl: web::Data<Tera>) -> Result<HttpResponse> { pub async fn my_listings(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
let mut context = Context::new(); let mut context = Context::new();
let listings = Self::get_mock_listings(); let listings = Self::get_mock_listings();
// Filter by current user (mock user ID) // Filter by current user (mock user ID)
let user_id = "user-123"; let user_id = "user-123";
let my_listings: Vec<&Listing> = listings.iter() let my_listings: Vec<&Listing> =
.filter(|l| l.seller_id == user_id) listings.iter().filter(|l| l.seller_id == user_id).collect();
.collect();
context.insert("active_page", &"marketplace"); context.insert("active_page", &"marketplace");
context.insert("listings", &my_listings); context.insert("listings", &my_listings);
render_template(&tmpl, "marketplace/my_listings.html", &context) render_template(&tmpl, "marketplace/my_listings.html", &context)
} }
// Display listing details // Display listing details
pub async fn listing_detail(tmpl: web::Data<Tera>, path: web::Path<String>) -> Result<HttpResponse> { pub async fn listing_detail(
tmpl: web::Data<Tera>,
path: web::Path<String>,
) -> Result<HttpResponse> {
let listing_id = path.into_inner(); let listing_id = path.into_inner();
let mut context = Context::new(); let mut context = Context::new();
let listings = Self::get_mock_listings(); let listings = Self::get_mock_listings();
// Find the listing // Find the listing
let listing = listings.iter().find(|l| l.id == listing_id); let listing = listings.iter().find(|l| l.id == listing_id);
if let Some(listing) = listing { if let Some(listing) = listing {
// Get similar listings (same asset type, active) // Get similar listings (same asset type, active)
let similar_listings: Vec<&Listing> = listings.iter() let similar_listings: Vec<&Listing> = listings
.filter(|l| l.asset_type == listing.asset_type && .iter()
l.status == ListingStatus::Active && .filter(|l| {
l.id != listing.id) l.asset_type == listing.asset_type
&& l.status == ListingStatus::Active
&& l.id != listing.id
})
.take(4) .take(4)
.collect(); .collect();
// Get highest bid amount and minimum bid for auction listings // Get highest bid amount and minimum bid for auction listings
let (highest_bid_amount, minimum_bid) = if listing.listing_type == ListingType::Auction { let (highest_bid_amount, minimum_bid) = if listing.listing_type == ListingType::Auction
{
if let Some(bid) = listing.highest_bid() { if let Some(bid) = listing.highest_bid() {
(Some(bid.amount), bid.amount + 1.0) (Some(bid.amount), bid.amount + 1.0)
} else { } else {
@ -159,74 +175,79 @@ impl MarketplaceController {
} else { } else {
(None, 0.0) (None, 0.0)
}; };
context.insert("active_page", &"marketplace"); context.insert("active_page", &"marketplace");
context.insert("listing", listing); context.insert("listing", listing);
context.insert("similar_listings", &similar_listings); context.insert("similar_listings", &similar_listings);
context.insert("highest_bid_amount", &highest_bid_amount); context.insert("highest_bid_amount", &highest_bid_amount);
context.insert("minimum_bid", &minimum_bid); context.insert("minimum_bid", &minimum_bid);
// Add current user info for bid/purchase forms // Add current user info for bid/purchase forms
let user_id = "user-123"; let user_id = "user-123";
let user_name = "Alice Hostly"; let user_name = "Alice Hostly";
context.insert("user_id", &user_id); context.insert("user_id", &user_id);
context.insert("user_name", &user_name); context.insert("user_name", &user_name);
render_template(&tmpl, "marketplace/listing_detail.html", &context) render_template(&tmpl, "marketplace/listing_detail.html", &context)
} else { } else {
Ok(HttpResponse::NotFound().finish()) Ok(HttpResponse::NotFound().finish())
} }
} }
// Display create listing form // Display create listing form
pub async fn create_listing_form(tmpl: web::Data<Tera>) -> Result<HttpResponse> { pub async fn create_listing_form(tmpl: web::Data<Tera>) -> Result<HttpResponse> {
let mut context = Context::new(); let mut context = Context::new();
// Get user's assets for selection // Get user's assets for selection
let assets = AssetController::get_mock_assets(); let assets = AssetController::get_mock_assets();
let user_id = "user-123"; // Mock user ID let user_id = "user-123"; // Mock user ID
let user_assets: Vec<&Asset> = assets.iter() let user_assets: Vec<&Asset> = assets
.iter()
.filter(|a| a.owner_id == user_id && a.status == AssetStatus::Active) .filter(|a| a.owner_id == user_id && a.status == AssetStatus::Active)
.collect(); .collect();
context.insert("active_page", &"marketplace"); context.insert("active_page", &"marketplace");
context.insert("assets", &user_assets); context.insert("assets", &user_assets);
context.insert("listing_types", &[ context.insert(
ListingType::FixedPrice.as_str(), "listing_types",
ListingType::Auction.as_str(), &[
ListingType::Exchange.as_str(), ListingType::FixedPrice.as_str(),
]); ListingType::Auction.as_str(),
ListingType::Exchange.as_str(),
],
);
render_template(&tmpl, "marketplace/create_listing.html", &context) render_template(&tmpl, "marketplace/create_listing.html", &context)
} }
// Create a new listing // Create a new listing
pub async fn create_listing( pub async fn create_listing(
tmpl: web::Data<Tera>, tmpl: web::Data<Tera>,
form: web::Form<ListingForm>, form: web::Form<ListingForm>,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
let form = form.into_inner(); let form = form.into_inner();
// Get the asset details // Get the asset details
let assets = AssetController::get_mock_assets(); let assets = AssetController::get_mock_assets();
let asset = assets.iter().find(|a| a.id == form.asset_id); let asset = assets.iter().find(|a| a.id == form.asset_id);
if let Some(asset) = asset { if let Some(asset) = asset {
// Process tags // Process tags
let tags = match form.tags { let tags = match form.tags {
Some(tags_str) => tags_str.split(',') Some(tags_str) => tags_str
.split(',')
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.collect(), .collect(),
None => Vec::new(), None => Vec::new(),
}; };
// Calculate expiration date if provided // Calculate expiration date if provided
let expires_at = form.duration_days.map(|days| { let expires_at = form
Utc::now() + Duration::days(days as i64) .duration_days
}); .map(|days| Utc::now() + Duration::days(days as i64));
// Parse listing type // Parse listing type
let listing_type = match form.listing_type.as_str() { let listing_type = match form.listing_type.as_str() {
"Fixed Price" => ListingType::FixedPrice, "Fixed Price" => ListingType::FixedPrice,
@ -234,11 +255,11 @@ impl MarketplaceController {
"Exchange" => ListingType::Exchange, "Exchange" => ListingType::Exchange,
_ => ListingType::FixedPrice, _ => ListingType::FixedPrice,
}; };
// Mock user data // Mock user data
let user_id = "user-123"; let user_id = "user-123";
let user_name = "Alice Hostly"; let user_name = "Alice Hostly";
// Create the listing // Create the listing
let _listing = Listing::new( let _listing = Listing::new(
form.title, form.title,
@ -255,9 +276,9 @@ impl MarketplaceController {
tags, tags,
asset.image_url.clone(), asset.image_url.clone(),
); );
// In a real application, we would save the listing to a database here // In a real application, we would save the listing to a database here
// Redirect to the marketplace // Redirect to the marketplace
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.insert_header((http::header::LOCATION, "/marketplace")) .insert_header((http::header::LOCATION, "/marketplace"))
@ -267,94 +288,101 @@ impl MarketplaceController {
let mut context = Context::new(); let mut context = Context::new();
context.insert("active_page", &"marketplace"); context.insert("active_page", &"marketplace");
context.insert("error", &"Asset not found"); context.insert("error", &"Asset not found");
render_template(&tmpl, "marketplace/create_listing.html", &context) render_template(&tmpl, "marketplace/create_listing.html", &context)
} }
} }
// Submit a bid on an auction listing // Submit a bid on an auction listing
#[allow(dead_code)]
pub async fn submit_bid( pub async fn submit_bid(
tmpl: web::Data<Tera>, _tmpl: web::Data<Tera>,
path: web::Path<String>, path: web::Path<String>,
form: web::Form<BidForm>, _form: web::Form<BidForm>,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
let listing_id = path.into_inner(); let listing_id = path.into_inner();
let form = form.into_inner(); let _form = _form.into_inner();
// In a real application, we would: // In a real application, we would:
// 1. Find the listing in the database // 1. Find the listing in the database
// 2. Validate the bid // 2. Validate the bid
// 3. Create the bid // 3. Create the bid
// 4. Save it to the database // 4. Save it to the database
// For now, we'll just redirect back to the listing // For now, we'll just redirect back to the listing
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.insert_header((http::header::LOCATION, format!("/marketplace/{}", listing_id))) .insert_header((
http::header::LOCATION,
format!("/marketplace/{}", listing_id),
))
.finish()) .finish())
} }
// Purchase a fixed-price listing // Purchase a fixed-price listing
pub async fn purchase_listing( pub async fn purchase_listing(
tmpl: web::Data<Tera>, _tmpl: web::Data<Tera>,
path: web::Path<String>, path: web::Path<String>,
form: web::Form<PurchaseForm>, form: web::Form<PurchaseForm>,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
let listing_id = path.into_inner(); let listing_id = path.into_inner();
let form = form.into_inner(); let form = form.into_inner();
if !form.agree_to_terms { if !form.agree_to_terms {
// User must agree to terms // User must agree to terms
return Ok(HttpResponse::SeeOther() return Ok(HttpResponse::SeeOther()
.insert_header((http::header::LOCATION, format!("/marketplace/{}", listing_id))) .insert_header((
http::header::LOCATION,
format!("/marketplace/{}", listing_id),
))
.finish()); .finish());
} }
// In a real application, we would: // In a real application, we would:
// 1. Find the listing in the database // 1. Find the listing in the database
// 2. Validate the purchase // 2. Validate the purchase
// 3. Process the transaction // 3. Process the transaction
// 4. Update the listing status // 4. Update the listing status
// 5. Transfer the asset // 5. Transfer the asset
// For now, we'll just redirect to the marketplace // For now, we'll just redirect to the marketplace
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.insert_header((http::header::LOCATION, "/marketplace")) .insert_header((http::header::LOCATION, "/marketplace"))
.finish()) .finish())
} }
// Cancel a listing // Cancel a listing
pub async fn cancel_listing( pub async fn cancel_listing(
tmpl: web::Data<Tera>, _tmpl: web::Data<Tera>,
path: web::Path<String>, path: web::Path<String>,
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
let _listing_id = path.into_inner(); let _listing_id = path.into_inner();
// In a real application, we would: // In a real application, we would:
// 1. Find the listing in the database // 1. Find the listing in the database
// 2. Validate that the current user is the seller // 2. Validate that the current user is the seller
// 3. Update the listing status // 3. Update the listing status
// For now, we'll just redirect to my listings // For now, we'll just redirect to my listings
Ok(HttpResponse::SeeOther() Ok(HttpResponse::SeeOther()
.insert_header((http::header::LOCATION, "/marketplace/my")) .insert_header((http::header::LOCATION, "/marketplace/my"))
.finish()) .finish())
} }
// Generate mock listings for development // Generate mock listings for development
pub fn get_mock_listings() -> Vec<Listing> { pub fn get_mock_listings() -> Vec<Listing> {
let assets = AssetController::get_mock_assets(); let assets = AssetController::get_mock_assets();
let mut listings = Vec::new(); let mut listings = Vec::new();
// Mock user data // Mock user data
let user_ids = vec!["user-123", "user-456", "user-789"]; let user_ids = vec!["user-123", "user-456", "user-789"];
let user_names = vec!["Alice Hostly", "Ethan Cloudman", "Priya Servera"]; let user_names = vec!["Alice Hostly", "Ethan Cloudman", "Priya Servera"];
// Create some fixed price listings // Create some fixed price listings
for i in 0..6 { for i in 0..6 {
let asset_index = i % assets.len(); let asset_index = i % assets.len();
let asset = &assets[asset_index]; let asset = &assets[asset_index];
let user_index = i % user_ids.len(); let user_index = i % user_ids.len();
let price = match asset.asset_type { let price = match asset.asset_type {
AssetType::Token => 50.0 + (i as f64 * 10.0), AssetType::Token => 50.0 + (i as f64 * 10.0),
AssetType::Artwork => 500.0 + (i as f64 * 100.0), AssetType::Artwork => 500.0 + (i as f64 * 100.0),
@ -365,10 +393,13 @@ impl MarketplaceController {
AssetType::Bond => 1500.0 + (i as f64 * 300.0), AssetType::Bond => 1500.0 + (i as f64 * 300.0),
AssetType::Other => 800.0 + (i as f64 * 150.0), AssetType::Other => 800.0 + (i as f64 * 150.0),
}; };
let mut listing = Listing::new( let mut listing = Listing::new(
format!("{} for Sale", asset.name), format!("{} for Sale", asset.name),
format!("This is a great opportunity to own {}. {}", asset.name, asset.description), format!(
"This is a great opportunity to own {}. {}",
asset.name, asset.description
),
asset.id.clone(), asset.id.clone(),
asset.name.clone(), asset.name.clone(),
asset.asset_type.clone(), asset.asset_type.clone(),
@ -381,21 +412,21 @@ impl MarketplaceController {
vec!["digital".to_string(), "asset".to_string()], vec!["digital".to_string(), "asset".to_string()],
asset.image_url.clone(), asset.image_url.clone(),
); );
// Make some listings featured // Make some listings featured
if i % 5 == 0 { if i % 5 == 0 {
listing.set_featured(true); listing.set_featured(true);
} }
listings.push(listing); listings.push(listing);
} }
// Create some auction listings // Create some auction listings
for i in 0..4 { for i in 0..4 {
let asset_index = (i + 6) % assets.len(); let asset_index = (i + 6) % assets.len();
let asset = &assets[asset_index]; let asset = &assets[asset_index];
let user_index = i % user_ids.len(); let user_index = i % user_ids.len();
let starting_price = match asset.asset_type { let starting_price = match asset.asset_type {
AssetType::Token => 40.0 + (i as f64 * 5.0), AssetType::Token => 40.0 + (i as f64 * 5.0),
AssetType::Artwork => 400.0 + (i as f64 * 50.0), AssetType::Artwork => 400.0 + (i as f64 * 50.0),
@ -406,7 +437,7 @@ impl MarketplaceController {
AssetType::Bond => 1200.0 + (i as f64 * 250.0), AssetType::Bond => 1200.0 + (i as f64 * 250.0),
AssetType::Other => 600.0 + (i as f64 * 120.0), AssetType::Other => 600.0 + (i as f64 * 120.0),
}; };
let mut listing = Listing::new( let mut listing = Listing::new(
format!("Auction: {}", asset.name), format!("Auction: {}", asset.name),
format!("Bid on this amazing {}. {}", asset.name, asset.description), format!("Bid on this amazing {}. {}", asset.name, asset.description),
@ -422,12 +453,13 @@ impl MarketplaceController {
vec!["auction".to_string(), "bidding".to_string()], vec!["auction".to_string(), "bidding".to_string()],
asset.image_url.clone(), asset.image_url.clone(),
); );
// Add some bids to the auctions // Add some bids to the auctions
let num_bids = 2 + (i % 3); let num_bids = 2 + (i % 3);
for j in 0..num_bids { for j in 0..num_bids {
let bidder_index = (j + 1) % user_ids.len(); let bidder_index = (j + 1) % user_ids.len();
if bidder_index != user_index { // Ensure seller isn't bidding if bidder_index != user_index {
// Ensure seller isn't bidding
let bid_amount = starting_price * (1.0 + (0.1 * (j + 1) as f64)); let bid_amount = starting_price * (1.0 + (0.1 * (j + 1) as f64));
let _ = listing.add_bid( let _ = listing.add_bid(
user_ids[bidder_index].to_string(), user_ids[bidder_index].to_string(),
@ -437,21 +469,21 @@ impl MarketplaceController {
); );
} }
} }
// Make some listings featured // Make some listings featured
if i % 3 == 0 { if i % 3 == 0 {
listing.set_featured(true); listing.set_featured(true);
} }
listings.push(listing); listings.push(listing);
} }
// Create some exchange listings // Create some exchange listings
for i in 0..3 { for i in 0..3 {
let asset_index = (i + 10) % assets.len(); let asset_index = (i + 10) % assets.len();
let asset = &assets[asset_index]; let asset = &assets[asset_index];
let user_index = i % user_ids.len(); let user_index = i % user_ids.len();
let value = match asset.asset_type { let value = match asset.asset_type {
AssetType::Token => 60.0 + (i as f64 * 15.0), AssetType::Token => 60.0 + (i as f64 * 15.0),
AssetType::Artwork => 600.0 + (i as f64 * 150.0), AssetType::Artwork => 600.0 + (i as f64 * 150.0),
@ -462,33 +494,36 @@ impl MarketplaceController {
AssetType::Bond => 1800.0 + (i as f64 * 350.0), AssetType::Bond => 1800.0 + (i as f64 * 350.0),
AssetType::Other => 1000.0 + (i as f64 * 200.0), AssetType::Other => 1000.0 + (i as f64 * 200.0),
}; };
let listing = Listing::new( let listing = Listing::new(
format!("Trade: {}", asset.name), format!("Trade: {}", asset.name),
format!("Looking to exchange {} for another asset of similar value. Interested in NFTs and tokens.", asset.name), format!(
"Looking to exchange {} for another asset of similar value. Interested in NFTs and tokens.",
asset.name
),
asset.id.clone(), asset.id.clone(),
asset.name.clone(), asset.name.clone(),
asset.asset_type.clone(), asset.asset_type.clone(),
user_ids[user_index].to_string(), user_ids[user_index].to_string(),
user_names[user_index].to_string(), user_names[user_index].to_string(),
value, // Estimated value for exchange value, // Estimated value for exchange
"USD".to_string(), "USD".to_string(),
ListingType::Exchange, ListingType::Exchange,
Some(Utc::now() + Duration::days(60)), Some(Utc::now() + Duration::days(60)),
vec!["exchange".to_string(), "trade".to_string()], vec!["exchange".to_string(), "trade".to_string()],
asset.image_url.clone(), asset.image_url.clone(),
); );
listings.push(listing); listings.push(listing);
} }
// Create some sold listings // Create some sold listings
for i in 0..5 { for i in 0..5 {
let asset_index = (i + 13) % assets.len(); let asset_index = (i + 13) % assets.len();
let asset = &assets[asset_index]; let asset = &assets[asset_index];
let seller_index = i % user_ids.len(); let seller_index = i % user_ids.len();
let buyer_index = (i + 1) % user_ids.len(); let buyer_index = (i + 1) % user_ids.len();
let price = match asset.asset_type { let price = match asset.asset_type {
AssetType::Token => 55.0 + (i as f64 * 12.0), AssetType::Token => 55.0 + (i as f64 * 12.0),
AssetType::Artwork => 550.0 + (i as f64 * 120.0), AssetType::Artwork => 550.0 + (i as f64 * 120.0),
@ -499,9 +534,9 @@ impl MarketplaceController {
AssetType::Bond => 1650.0 + (i as f64 * 330.0), AssetType::Bond => 1650.0 + (i as f64 * 330.0),
AssetType::Other => 900.0 + (i as f64 * 180.0), AssetType::Other => 900.0 + (i as f64 * 180.0),
}; };
let sale_price = price * 0.95; // Slight discount on sale let sale_price = price * 0.95; // Slight discount on sale
let mut listing = Listing::new( let mut listing = Listing::new(
format!("{} - SOLD", asset.name), format!("{} - SOLD", asset.name),
format!("This {} was sold recently.", asset.name), format!("This {} was sold recently.", asset.name),
@ -517,27 +552,27 @@ impl MarketplaceController {
vec!["sold".to_string()], vec!["sold".to_string()],
asset.image_url.clone(), asset.image_url.clone(),
); );
// Mark as sold // Mark as sold
let _ = listing.mark_as_sold( let _ = listing.mark_as_sold(
user_ids[buyer_index].to_string(), user_ids[buyer_index].to_string(),
user_names[buyer_index].to_string(), user_names[buyer_index].to_string(),
sale_price, sale_price,
); );
// Set sold date to be sometime in the past // Set sold date to be sometime in the past
let days_ago = i as i64 + 1; let days_ago = i as i64 + 1;
listing.sold_at = Some(Utc::now() - Duration::days(days_ago)); listing.sold_at = Some(Utc::now() - Duration::days(days_ago));
listings.push(listing); listings.push(listing);
} }
// Create a few cancelled listings // Create a few cancelled listings
for i in 0..2 { for i in 0..2 {
let asset_index = (i + 18) % assets.len(); let asset_index = (i + 18) % assets.len();
let asset = &assets[asset_index]; let asset = &assets[asset_index];
let user_index = i % user_ids.len(); let user_index = i % user_ids.len();
let price = match asset.asset_type { let price = match asset.asset_type {
AssetType::Token => 45.0 + (i as f64 * 8.0), AssetType::Token => 45.0 + (i as f64 * 8.0),
AssetType::Artwork => 450.0 + (i as f64 * 80.0), AssetType::Artwork => 450.0 + (i as f64 * 80.0),
@ -548,7 +583,7 @@ impl MarketplaceController {
AssetType::Bond => 1350.0 + (i as f64 * 270.0), AssetType::Bond => 1350.0 + (i as f64 * 270.0),
AssetType::Other => 750.0 + (i as f64 * 150.0), AssetType::Other => 750.0 + (i as f64 * 150.0),
}; };
let mut listing = Listing::new( let mut listing = Listing::new(
format!("{} - Cancelled", asset.name), format!("{} - Cancelled", asset.name),
format!("This listing for {} was cancelled.", asset.name), format!("This listing for {} was cancelled.", asset.name),
@ -564,13 +599,13 @@ impl MarketplaceController {
vec!["cancelled".to_string()], vec!["cancelled".to_string()],
asset.image_url.clone(), asset.image_url.clone(),
); );
// Cancel the listing // Cancel the listing
let _ = listing.cancel(); let _ = listing.cancel();
listings.push(listing); listings.push(listing);
} }
listings listings
} }
} }

View File

@ -0,0 +1,360 @@
use chrono::{DateTime, Utc};
use heromodels::{
db::{Collection, Db},
models::calendar::{AttendanceStatus, Attendee, Calendar, Event},
};
use super::db::get_db;
/// Creates a new calendar and saves it to the database. Returns the saved calendar and its ID.
pub fn create_new_calendar(
name: &str,
description: Option<&str>,
owner_id: Option<u32>,
is_public: bool,
color: Option<&str>,
) -> Result<(u32, Calendar), String> {
let db = get_db().expect("Can get DB");
// Create a new calendar (with auto-generated ID)
let mut calendar = Calendar::new(None, name);
if let Some(desc) = description {
calendar = calendar.description(desc);
}
if let Some(owner) = owner_id {
calendar = calendar.owner_id(owner);
}
if let Some(col) = color {
calendar = calendar.color(col);
}
calendar = calendar.is_public(is_public);
// Save the calendar to the database
let collection = db
.collection::<Calendar>()
.expect("can open calendar collection");
let (calendar_id, saved_calendar) = collection.set(&calendar).expect("can save calendar");
Ok((calendar_id, saved_calendar))
}
/// Creates a new event and saves it to the database. Returns the saved event and its ID.
pub fn create_new_event(
title: &str,
description: Option<&str>,
start_time: DateTime<Utc>,
end_time: DateTime<Utc>,
location: Option<&str>,
color: Option<&str>,
all_day: bool,
created_by: Option<u32>,
category: Option<&str>,
reminder_minutes: Option<i32>,
) -> Result<(u32, Event), String> {
let db = get_db().expect("Can get DB");
// Create a new event (with auto-generated ID)
let mut event = Event::new(title, start_time, end_time);
if let Some(desc) = description {
event = event.description(desc);
}
if let Some(loc) = location {
event = event.location(loc);
}
if let Some(col) = color {
event = event.color(col);
}
if let Some(user_id) = created_by {
event = event.created_by(user_id);
}
if let Some(cat) = category {
event = event.category(cat);
}
if let Some(reminder) = reminder_minutes {
event = event.reminder_minutes(reminder);
}
event = event.all_day(all_day);
// Save the event to the database
let collection = db.collection::<Event>().expect("can open event collection");
let (event_id, saved_event) = collection.set(&event).expect("can save event");
Ok((event_id, saved_event))
}
/// Loads all calendars from the database and returns them as a Vec<Calendar>.
pub fn get_calendars() -> Result<Vec<Calendar>, String> {
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
let collection = db
.collection::<Calendar>()
.expect("can open calendar collection");
// Try to load all calendars, but handle deserialization errors gracefully
let calendars = match collection.get_all() {
Ok(calendars) => calendars,
Err(e) => {
eprintln!("Error loading calendars: {:?}", e);
vec![] // Return an empty vector if there's an error
}
};
Ok(calendars)
}
/// Loads all events from the database and returns them as a Vec<Event>.
pub fn get_events() -> Result<Vec<Event>, String> {
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
let collection = db.collection::<Event>().expect("can open event collection");
// Try to load all events, but handle deserialization errors gracefully
let events = match collection.get_all() {
Ok(events) => events,
Err(e) => {
eprintln!("Error loading events: {:?}", e);
vec![] // Return an empty vector if there's an error
}
};
Ok(events)
}
/// Fetches a single calendar by its ID from the database.
pub fn get_calendar_by_id(calendar_id: u32) -> Result<Option<Calendar>, String> {
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
let collection = db
.collection::<Calendar>()
.map_err(|e| format!("Collection error: {:?}", e))?;
match collection.get_by_id(calendar_id) {
Ok(calendar) => Ok(calendar),
Err(e) => {
eprintln!("Error fetching calendar by id {}: {:?}", calendar_id, e);
Err(format!("Failed to fetch calendar: {:?}", e))
}
}
}
/// Fetches a single event by its ID from the database.
pub fn get_event_by_id(event_id: u32) -> Result<Option<Event>, String> {
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
let collection = db
.collection::<Event>()
.map_err(|e| format!("Collection error: {:?}", e))?;
match collection.get_by_id(event_id) {
Ok(event) => Ok(event),
Err(e) => {
eprintln!("Error fetching event by id {}: {:?}", event_id, e);
Err(format!("Failed to fetch event: {:?}", e))
}
}
}
/// Creates a new attendee and saves it to the database. Returns the saved attendee and its ID.
pub fn create_new_attendee(
contact_id: u32,
status: AttendanceStatus,
) -> Result<(u32, Attendee), String> {
let db = get_db().expect("Can get DB");
// Create a new attendee (with auto-generated ID)
let attendee = Attendee::new(contact_id).status(status);
// Save the attendee to the database
let collection = db
.collection::<Attendee>()
.expect("can open attendee collection");
let (attendee_id, saved_attendee) = collection.set(&attendee).expect("can save attendee");
Ok((attendee_id, saved_attendee))
}
/// Fetches a single attendee by its ID from the database.
pub fn get_attendee_by_id(attendee_id: u32) -> Result<Option<Attendee>, String> {
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
let collection = db
.collection::<Attendee>()
.map_err(|e| format!("Collection error: {:?}", e))?;
match collection.get_by_id(attendee_id) {
Ok(attendee) => Ok(attendee),
Err(e) => {
eprintln!("Error fetching attendee by id {}: {:?}", attendee_id, e);
Err(format!("Failed to fetch attendee: {:?}", e))
}
}
}
/// Updates attendee status in the database and returns the updated attendee.
pub fn update_attendee_status(
attendee_id: u32,
status: AttendanceStatus,
) -> Result<Attendee, String> {
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
let collection = db
.collection::<Attendee>()
.map_err(|e| format!("Collection error: {:?}", e))?;
if let Some(mut attendee) = collection
.get_by_id(attendee_id)
.map_err(|e| format!("Failed to fetch attendee: {:?}", e))?
{
attendee = attendee.status(status);
let (_, updated_attendee) = collection
.set(&attendee)
.map_err(|e| format!("Failed to update attendee: {:?}", e))?;
Ok(updated_attendee)
} else {
Err("Attendee not found".to_string())
}
}
/// Add attendee to event
pub fn add_attendee_to_event(event_id: u32, attendee_id: u32) -> Result<Event, String> {
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
let collection = db
.collection::<Event>()
.map_err(|e| format!("Collection error: {:?}", e))?;
if let Some(mut event) = collection
.get_by_id(event_id)
.map_err(|e| format!("Failed to fetch event: {:?}", e))?
{
event = event.add_attendee(attendee_id);
let (_, updated_event) = collection
.set(&event)
.map_err(|e| format!("Failed to update event: {:?}", e))?;
Ok(updated_event)
} else {
Err("Event not found".to_string())
}
}
/// Remove attendee from event
pub fn remove_attendee_from_event(event_id: u32, attendee_id: u32) -> Result<Event, String> {
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
let collection = db
.collection::<Event>()
.map_err(|e| format!("Collection error: {:?}", e))?;
if let Some(mut event) = collection
.get_by_id(event_id)
.map_err(|e| format!("Failed to fetch event: {:?}", e))?
{
event = event.remove_attendee(attendee_id);
let (_, updated_event) = collection
.set(&event)
.map_err(|e| format!("Failed to update event: {:?}", e))?;
Ok(updated_event)
} else {
Err("Event not found".to_string())
}
}
/// Add event to calendar
pub fn add_event_to_calendar(calendar_id: u32, event_id: u32) -> Result<Calendar, String> {
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
let collection = db
.collection::<Calendar>()
.map_err(|e| format!("Collection error: {:?}", e))?;
if let Some(mut calendar) = collection
.get_by_id(calendar_id)
.map_err(|e| format!("Failed to fetch calendar: {:?}", e))?
{
calendar = calendar.add_event(event_id as i64);
let (_, updated_calendar) = collection
.set(&calendar)
.map_err(|e| format!("Failed to update calendar: {:?}", e))?;
Ok(updated_calendar)
} else {
Err("Calendar not found".to_string())
}
}
/// Remove event from calendar
pub fn remove_event_from_calendar(calendar_id: u32, event_id: u32) -> Result<Calendar, String> {
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
let collection = db
.collection::<Calendar>()
.map_err(|e| format!("Collection error: {:?}", e))?;
if let Some(mut calendar) = collection
.get_by_id(calendar_id)
.map_err(|e| format!("Failed to fetch calendar: {:?}", e))?
{
calendar = calendar.remove_event(event_id as i64);
let (_, updated_calendar) = collection
.set(&calendar)
.map_err(|e| format!("Failed to update calendar: {:?}", e))?;
Ok(updated_calendar)
} else {
Err("Calendar not found".to_string())
}
}
/// Deletes a calendar from the database.
pub fn delete_calendar(calendar_id: u32) -> Result<(), String> {
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
let collection = db
.collection::<Calendar>()
.map_err(|e| format!("Collection error: {:?}", e))?;
collection
.delete_by_id(calendar_id)
.map_err(|e| format!("Failed to delete calendar: {:?}", e))?;
Ok(())
}
/// Deletes an event from the database.
pub fn delete_event(event_id: u32) -> Result<(), String> {
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
let collection = db
.collection::<Event>()
.map_err(|e| format!("Collection error: {:?}", e))?;
collection
.delete_by_id(event_id)
.map_err(|e| format!("Failed to delete event: {:?}", e))?;
Ok(())
}
/// Gets or creates a calendar for a user. If the user already has a calendar, returns it.
/// If not, creates a new calendar for the user and returns it.
pub fn get_or_create_user_calendar(user_id: u32, user_name: &str) -> Result<Calendar, String> {
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
let collection = db
.collection::<Calendar>()
.map_err(|e| format!("Collection error: {:?}", e))?;
// Try to find existing calendar for this user
let calendars = match collection.get_all() {
Ok(calendars) => calendars,
Err(e) => {
eprintln!("Error loading calendars: {:?}", e);
vec![] // Return an empty vector if there's an error
}
};
// Look for a calendar owned by this user
for calendar in calendars {
if let Some(owner_id) = calendar.owner_id {
if owner_id == user_id {
return Ok(calendar);
}
}
}
// No calendar found for this user, create a new one
let calendar_name = format!("{}'s Calendar", user_name);
let (_, new_calendar) = create_new_calendar(
&calendar_name,
Some("Personal calendar"),
Some(user_id),
false, // Private calendar
Some("#4285F4"), // Default blue color
)?;
Ok(new_calendar)
}

View File

@ -0,0 +1,17 @@
use std::path::PathBuf;
use heromodels::db::hero::OurDB;
/// The path to the database file. Change this as needed for your environment.
pub const DB_PATH: &str = "/tmp/freezone_db";
/// Returns a shared OurDB instance for the given path. You can wrap this in Arc/Mutex for concurrent access if needed.
pub fn get_db() -> Result<OurDB, String> {
let db_path = PathBuf::from(DB_PATH);
if let Some(parent) = db_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
// Temporarily reset the database to fix the serialization issue
let db = heromodels::db::hero::OurDB::new(db_path, false).expect("Can create DB");
Ok(db)
}

View File

@ -0,0 +1,257 @@
use chrono::{Duration, Utc};
use heromodels::{
db::{Collection, Db},
models::governance::{Activity, ActivityType, Proposal, ProposalStatus},
};
use super::db::get_db;
/// Creates a new proposal and saves it to the database. Returns the saved proposal and its ID.
pub fn create_new_proposal(
creator_id: &str,
creator_name: &str,
title: &str,
description: &str,
status: ProposalStatus,
voting_start_date: Option<chrono::DateTime<Utc>>,
voting_end_date: Option<chrono::DateTime<Utc>>,
) -> Result<(u32, Proposal), String> {
let db = get_db().expect("Can get DB");
let created_at = Utc::now();
let updated_at = created_at;
// Create a new proposal (with auto-generated ID)
let proposal = Proposal::new(
None,
creator_id,
creator_name,
title,
description,
status,
created_at,
updated_at,
voting_start_date.unwrap_or_else(Utc::now),
voting_end_date.unwrap_or_else(|| Utc::now() + Duration::days(7)),
);
// Save the proposal to the database
let collection = db
.collection::<Proposal>()
.expect("can open proposal collection");
let (proposal_id, saved_proposal) = collection.set(&proposal).expect("can save proposal");
Ok((proposal_id, saved_proposal))
}
/// Loads all proposals from the database and returns them as a Vec<Proposal>.
pub fn get_proposals() -> Result<Vec<Proposal>, String> {
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
let collection = db
.collection::<Proposal>()
.expect("can open proposal collection");
// Try to load all proposals, but handle deserialization errors gracefully
let proposals = match collection.get_all() {
Ok(props) => props,
Err(e) => {
eprintln!("Error loading proposals: {:?}", e);
vec![] // Return an empty vector if there's an error
}
};
Ok(proposals)
}
/// Fetches a single proposal by its ID from the database.
pub fn get_proposal_by_id(proposal_id: u32) -> Result<Option<Proposal>, String> {
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
let collection = db
.collection::<Proposal>()
.map_err(|e| format!("Collection error: {:?}", e))?;
match collection.get_by_id(proposal_id) {
Ok(proposal) => Ok(Some(proposal.expect("proposal not found"))),
Err(e) => {
eprintln!("Error fetching proposal by id {}: {:?}", proposal_id, e);
Err(format!("Failed to fetch proposal: {:?}", e))
}
}
}
/// Submits a vote on a proposal and returns the updated proposal
pub fn submit_vote_on_proposal(
proposal_id: u32,
user_id: i32,
vote_type: &str,
shares_count: u32, // Default to 1 if not specified
comment: Option<String>,
) -> Result<Proposal, String> {
// Get the proposal from the database
let db = get_db().map_err(|e| format!("DB error: {}", e))?;
let collection = db
.collection::<Proposal>()
.map_err(|e| format!("Collection error: {:?}", e))?;
// Get the proposal
let mut proposal = collection
.get_by_id(proposal_id)
.map_err(|e| format!("Failed to fetch proposal: {:?}", e))?
.ok_or_else(|| format!("Proposal not found with ID: {}", proposal_id))?;
// Ensure the proposal has vote options
// Check if the proposal already has options
if proposal.options.is_empty() {
// Add standard vote options if they don't exist
proposal = proposal.add_option(1, "Approve", Some("Approve the proposal"));
proposal = proposal.add_option(2, "Reject", Some("Reject the proposal"));
proposal = proposal.add_option(3, "Abstain", Some("Abstain from voting"));
}
// Map vote_type to option_id
let option_id = match vote_type {
"Yes" => 1, // Approve
"No" => 2, // Reject
"Abstain" => 3, // Abstain
_ => return Err(format!("Invalid vote type: {}", vote_type)),
};
// Since we're having issues with the cast_vote method, let's implement a workaround
// that directly updates the vote count for the selected option
// Check if the proposal is active
if proposal.status != ProposalStatus::Active {
return Err(format!(
"Cannot vote on a proposal with status: {:?}",
proposal.status
));
}
// Check if voting period is valid
let now = Utc::now();
if now > proposal.vote_end_date {
return Err("Voting period has ended".to_string());
}
if now < proposal.vote_start_date {
return Err("Voting period has not started yet".to_string());
}
// Find the option and increment its count
let mut option_found = false;
for option in &mut proposal.options {
if option.id == option_id {
option.count += shares_count as i64;
option_found = true;
break;
}
}
if !option_found {
return Err(format!("Option with ID {} not found", option_id));
}
// Record the vote in the proposal's ballots
// We'll create a simple ballot with an auto-generated ID
let ballot_id = proposal.ballots.len() as u32 + 1;
// Create a new ballot and add it to the proposal's ballots
use heromodels::models::governance::Ballot;
// Use the Ballot::new constructor which handles the BaseModelData creation
let mut ballot = Ballot::new(
Some(ballot_id),
user_id as u32,
option_id,
shares_count as i64,
);
// Set the comment if provided
ballot.comment = comment;
// Store the local time (EEST = UTC+3) as the vote timestamp
// This ensures the displayed time matches the user's local time
let utc_now = Utc::now();
let local_offset = chrono::FixedOffset::east_opt(3 * 3600).unwrap(); // +3 hours for EEST
let local_time = utc_now.with_timezone(&local_offset);
// Store the local time as a timestamp (this is what will be displayed)
ballot.base_data.created_at = local_time.timestamp();
// Add the ballot to the proposal's ballots
proposal.ballots.push(ballot);
// Update the proposal's updated_at timestamp
proposal.updated_at = Utc::now();
// Save the updated proposal
let (_, updated_proposal) = collection
.set(&proposal)
.map_err(|e| format!("Failed to save vote: {:?}", e))?;
Ok(updated_proposal)
}
#[allow(unused_assignments)]
/// Creates a new governance activity and saves it to the database using OurDB
pub fn create_activity(
proposal_id: u32,
proposal_title: &str,
creator_name: &str,
activity_type: &ActivityType,
) -> Result<(u32, Activity), String> {
let db = get_db().expect("Can get DB");
let mut activity = Activity::default();
match activity_type {
ActivityType::ProposalCreated => {
activity = Activity::proposal_created(proposal_id, proposal_title, creator_name);
}
ActivityType::VoteCast => {
activity = Activity::vote_cast(proposal_id, proposal_title, creator_name);
}
ActivityType::VotingStarted => {
activity = Activity::voting_started(proposal_id, proposal_title);
}
ActivityType::VotingEnded => {
activity = Activity::voting_ended(proposal_id, proposal_title);
}
}
// Save the proposal to the database
let collection = db
.collection::<Activity>()
.expect("can open activity collection");
let (proposal_id, saved_proposal) = collection.set(&activity).expect("can save proposal");
Ok((proposal_id, saved_proposal))
}
pub fn get_recent_activities() -> Result<Vec<Activity>, String> {
let db = get_db().expect("Can get DB");
let collection = db
.collection::<Activity>()
.map_err(|e| format!("Collection error: {:?}", e))?;
let mut db_activities = collection
.get_all()
.map_err(|e| format!("DB fetch error: {:?}", e))?;
// Sort by created_at descending
db_activities.sort_by(|a, b| b.created_at.cmp(&a.created_at));
// Take the top 10 most recent
let recent_activities = db_activities.into_iter().take(10).collect();
Ok(recent_activities)
}
pub fn get_all_activities() -> Result<Vec<Activity>, String> {
let db = get_db().expect("Can get DB");
let collection = db
.collection::<Activity>()
.map_err(|e| format!("Collection error: {:?}", e))?;
let db_activities = collection
.get_all()
.map_err(|e| format!("DB fetch error: {:?}", e))?;
Ok(db_activities)
}

View File

@ -0,0 +1,4 @@
pub mod calendar;
pub mod contracts;
pub mod db;
pub mod governance;

View File

@ -1,22 +1,23 @@
use actix_files as fs; use actix_files as fs;
use actix_web::{App, HttpServer, web};
use actix_web::middleware::Logger; use actix_web::middleware::Logger;
use tera::Tera; use actix_web::{App, HttpServer, web};
use std::io;
use std::env;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use std::env;
use std::io;
use tera::Tera;
mod config; mod config;
mod controllers; mod controllers;
mod db;
mod middleware; mod middleware;
mod models; mod models;
mod routes; mod routes;
mod utils; mod utils;
// Import middleware components // Import middleware components
use middleware::{RequestTimer, SecurityHeaders, JwtAuth}; use middleware::{JwtAuth, RequestTimer, SecurityHeaders};
use utils::redis_service;
use models::initialize_mock_data; use models::initialize_mock_data;
use utils::redis_service;
// Initialize lazy_static for in-memory storage // Initialize lazy_static for in-memory storage
extern crate lazy_static; extern crate lazy_static;
@ -29,13 +30,13 @@ lazy_static! {
// Create a key that's at least 64 bytes long // Create a key that's at least 64 bytes long
"my_secret_session_key_that_is_at_least_64_bytes_long_for_security_reasons_1234567890abcdef".to_string() "my_secret_session_key_that_is_at_least_64_bytes_long_for_security_reasons_1234567890abcdef".to_string()
}); });
// Ensure the key is at least 64 bytes // Ensure the key is at least 64 bytes
let mut key_bytes = secret.as_bytes().to_vec(); let mut key_bytes = secret.as_bytes().to_vec();
while key_bytes.len() < 64 { while key_bytes.len() < 64 {
key_bytes.extend_from_slice(b"0123456789abcdef"); key_bytes.extend_from_slice(b"0123456789abcdef");
} }
actix_web::cookie::Key::from(&key_bytes[0..64]) actix_web::cookie::Key::from(&key_bytes[0..64])
}; };
} }
@ -45,14 +46,14 @@ async fn main() -> io::Result<()> {
// Initialize environment // Initialize environment
dotenv::dotenv().ok(); dotenv::dotenv().ok();
env_logger::init_from_env(env_logger::Env::default().default_filter_or("info")); env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));
// Load configuration // Load configuration
let config = config::get_config(); let config = config::get_config();
// Check for port override from command line arguments // Check for port override from command line arguments
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();
let mut port = config.server.port; let mut port = config.server.port;
for i in 1..args.len() { for i in 1..args.len() {
if args[i] == "--port" && i + 1 < args.len() { if args[i] == "--port" && i + 1 < args.len() {
if let Ok(p) = args[i + 1].parse::<u16>() { if let Ok(p) = args[i + 1].parse::<u16>() {
@ -61,24 +62,28 @@ async fn main() -> io::Result<()> {
} }
} }
} }
let bind_address = format!("{}:{}", config.server.host, port); let bind_address = format!("{}:{}", config.server.host, port);
// Initialize Redis client // Initialize Redis client
let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); let redis_url =
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
if let Err(e) = redis_service::init_redis_client(&redis_url) { if let Err(e) = redis_service::init_redis_client(&redis_url) {
log::error!("Failed to initialize Redis client: {}", e); log::error!("Failed to initialize Redis client: {}", e);
log::warn!("Calendar functionality will not work properly without Redis"); log::warn!("Calendar functionality will not work properly without Redis");
} else { } else {
log::info!("Redis client initialized successfully"); log::info!("Redis client initialized successfully");
} }
// Initialize mock data for DeFi operations // Initialize mock data for DeFi operations
initialize_mock_data(); initialize_mock_data();
log::info!("DeFi mock data initialized successfully"); log::info!("DeFi mock data initialized successfully");
// Governance activity tracker is now ready to record real user activities
log::info!("Governance activity tracker initialized and ready");
log::info!("Starting server at http://{}", bind_address); log::info!("Starting server at http://{}", bind_address);
// Create and configure the HTTP server // Create and configure the HTTP server
HttpServer::new(move || { HttpServer::new(move || {
// Initialize Tera templates // Initialize Tera templates
@ -89,10 +94,10 @@ async fn main() -> io::Result<()> {
::std::process::exit(1); ::std::process::exit(1);
} }
}; };
// Register custom Tera functions // Register custom Tera functions
utils::register_tera_functions(&mut tera); utils::register_tera_functions(&mut tera);
App::new() App::new()
// Enable logger middleware // Enable logger middleware
.wrap(Logger::default()) .wrap(Logger::default())

View File

@ -112,6 +112,7 @@ pub struct Asset {
pub external_url: Option<String>, pub external_url: Option<String>,
} }
#[allow(dead_code)]
impl Asset { impl Asset {
/// Creates a new asset /// Creates a new asset
pub fn new( pub fn new(

View File

@ -1,61 +1,4 @@
use chrono::{DateTime, Utc}; // No imports needed for this module currently
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Represents a calendar event
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalendarEvent {
/// Unique identifier for the event
pub id: String,
/// Title of the event
pub title: String,
/// Description of the event
pub description: String,
/// Start time of the event
pub start_time: DateTime<Utc>,
/// End time of the event
pub end_time: DateTime<Utc>,
/// Color of the event (hex code)
pub color: String,
/// Whether the event is an all-day event
pub all_day: bool,
/// User ID of the event creator
pub user_id: Option<String>,
}
impl CalendarEvent {
/// Creates a new calendar event
pub fn new(
title: String,
description: String,
start_time: DateTime<Utc>,
end_time: DateTime<Utc>,
color: Option<String>,
all_day: bool,
user_id: Option<String>,
) -> Self {
Self {
id: Uuid::new_v4().to_string(),
title,
description,
start_time,
end_time,
color: color.unwrap_or_else(|| "#4285F4".to_string()), // Google Calendar blue
all_day,
user_id,
}
}
/// Converts the event to a JSON string
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
/// Creates an event from a JSON string
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
}
/// Represents a view mode for the calendar /// Represents a view mode for the calendar
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -91,4 +34,4 @@ impl CalendarViewMode {
Self::Day => "day", Self::Day => "day",
} }
} }
} }

View File

@ -85,6 +85,7 @@ pub struct ContractSigner {
pub comments: Option<String>, pub comments: Option<String>,
} }
#[allow(dead_code)]
impl ContractSigner { impl ContractSigner {
/// Creates a new contract signer /// Creates a new contract signer
pub fn new(name: String, email: String) -> Self { pub fn new(name: String, email: String) -> Self {
@ -123,6 +124,7 @@ pub struct ContractRevision {
pub comments: Option<String>, pub comments: Option<String>,
} }
#[allow(dead_code)]
impl ContractRevision { impl ContractRevision {
/// Creates a new contract revision /// Creates a new contract revision
pub fn new(version: u32, content: String, created_by: String, comments: Option<String>) -> Self { pub fn new(version: u32, content: String, created_by: String, comments: Option<String>) -> Self {
@ -166,6 +168,7 @@ pub struct Contract {
pub toc: Option<Vec<TocItem>>, pub toc: Option<Vec<TocItem>>,
} }
#[allow(dead_code)]
impl Contract { impl Contract {
/// Creates a new contract /// Creates a new contract
pub fn new(title: String, description: String, contract_type: ContractType, created_by: String, organization_id: Option<String>) -> Self { pub fn new(title: String, description: String, contract_type: ContractType, created_by: String, organization_id: Option<String>) -> Self {

View File

@ -14,6 +14,7 @@ pub enum DefiPositionStatus {
Cancelled Cancelled
} }
#[allow(dead_code)]
impl DefiPositionStatus { impl DefiPositionStatus {
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
match self { match self {
@ -35,6 +36,7 @@ pub enum DefiPositionType {
Collateral, Collateral,
} }
#[allow(dead_code)]
impl DefiPositionType { impl DefiPositionType {
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
match self { match self {
@ -95,6 +97,7 @@ pub struct DefiDatabase {
receiving_positions: HashMap<String, ReceivingPosition>, receiving_positions: HashMap<String, ReceivingPosition>,
} }
#[allow(dead_code)]
impl DefiDatabase { impl DefiDatabase {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {

View File

@ -110,6 +110,7 @@ pub struct FlowStep {
pub logs: Vec<FlowLog>, pub logs: Vec<FlowLog>,
} }
#[allow(dead_code)]
impl FlowStep { impl FlowStep {
/// Creates a new flow step /// Creates a new flow step
pub fn new(name: String, description: String, order: u32) -> Self { pub fn new(name: String, description: String, order: u32) -> Self {
@ -189,6 +190,7 @@ pub struct FlowLog {
pub timestamp: DateTime<Utc>, pub timestamp: DateTime<Utc>,
} }
#[allow(dead_code)]
impl FlowLog { impl FlowLog {
/// Creates a new flow log /// Creates a new flow log
pub fn new(message: String) -> Self { pub fn new(message: String) -> Self {
@ -231,6 +233,7 @@ pub struct Flow {
pub current_step: Option<FlowStep>, pub current_step: Option<FlowStep>,
} }
#[allow(dead_code)]
impl Flow { impl Flow {
/// Creates a new flow /// Creates a new flow
pub fn new(name: &str, description: &str, flow_type: FlowType, owner_id: &str, owner_name: &str) -> Self { pub fn new(name: &str, description: &str, flow_type: FlowType, owner_id: &str, owner_name: &str) -> Self {

View File

@ -1,248 +0,0 @@
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
use uuid::Uuid;
/// Represents the status of a governance proposal
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ProposalStatus {
/// Proposal is in draft status, not yet open for voting
Draft,
/// Proposal is active and open for voting
Active,
/// Proposal has been approved by the community
Approved,
/// Proposal has been rejected by the community
Rejected,
/// Proposal has been cancelled by the creator
Cancelled,
}
impl std::fmt::Display for ProposalStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProposalStatus::Draft => write!(f, "Draft"),
ProposalStatus::Active => write!(f, "Active"),
ProposalStatus::Approved => write!(f, "Approved"),
ProposalStatus::Rejected => write!(f, "Rejected"),
ProposalStatus::Cancelled => write!(f, "Cancelled"),
}
}
}
/// Represents a vote on a governance proposal
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum VoteType {
/// Vote in favor of the proposal
Yes,
/// Vote against the proposal
No,
/// Abstain from voting on the proposal
Abstain,
}
impl std::fmt::Display for VoteType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VoteType::Yes => write!(f, "Yes"),
VoteType::No => write!(f, "No"),
VoteType::Abstain => write!(f, "Abstain"),
}
}
}
/// Represents a governance proposal in the system
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Proposal {
/// Unique identifier for the proposal
pub id: String,
/// User ID of the proposal creator
pub creator_id: i32,
/// Name of the proposal creator
pub creator_name: String,
/// Title of the proposal
pub title: String,
/// Detailed description of the proposal
pub description: String,
/// Current status of the proposal
pub status: ProposalStatus,
/// Date and time when the proposal was created
pub created_at: DateTime<Utc>,
/// Date and time when the proposal was last updated
pub updated_at: DateTime<Utc>,
/// Date and time when voting starts
pub voting_starts_at: Option<DateTime<Utc>>,
/// Date and time when voting ends
pub voting_ends_at: Option<DateTime<Utc>>,
}
impl Proposal {
/// Creates a new proposal
pub fn new(creator_id: i32, creator_name: String, title: String, description: String) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
creator_id,
creator_name,
title,
description,
status: ProposalStatus::Draft,
created_at: now,
updated_at: now,
voting_starts_at: None,
voting_ends_at: None,
}
}
/// Updates the proposal status
pub fn update_status(&mut self, status: ProposalStatus) {
self.status = status;
self.updated_at = Utc::now();
}
/// Sets the voting period for the proposal
pub fn set_voting_period(&mut self, starts_at: DateTime<Utc>, ends_at: DateTime<Utc>) {
self.voting_starts_at = Some(starts_at);
self.voting_ends_at = Some(ends_at);
self.updated_at = Utc::now();
}
/// Activates the proposal for voting
pub fn activate(&mut self) {
self.status = ProposalStatus::Active;
self.updated_at = Utc::now();
}
/// Cancels the proposal
pub fn cancel(&mut self) {
self.status = ProposalStatus::Cancelled;
self.updated_at = Utc::now();
}
}
/// Represents a vote cast on a proposal
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vote {
/// Unique identifier for the vote
pub id: String,
/// ID of the proposal being voted on
pub proposal_id: String,
/// User ID of the voter
pub voter_id: i32,
/// Name of the voter
pub voter_name: String,
/// Type of vote cast
pub vote_type: VoteType,
/// Optional comment explaining the vote
pub comment: Option<String>,
/// Date and time when the vote was cast
pub created_at: DateTime<Utc>,
/// Date and time when the vote was last updated
pub updated_at: DateTime<Utc>,
}
impl Vote {
/// Creates a new vote
pub fn new(proposal_id: String, voter_id: i32, voter_name: String, vote_type: VoteType, comment: Option<String>) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
proposal_id,
voter_id,
voter_name,
vote_type,
comment,
created_at: now,
updated_at: now,
}
}
/// Updates the vote type
pub fn update_vote(&mut self, vote_type: VoteType, comment: Option<String>) {
self.vote_type = vote_type;
self.comment = comment;
self.updated_at = Utc::now();
}
}
/// Represents a filter for searching proposals
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProposalFilter {
/// Filter by proposal status
pub status: Option<String>,
/// Filter by creator ID
pub creator_id: Option<i32>,
/// Search term for title and description
pub search: Option<String>,
}
impl Default for ProposalFilter {
fn default() -> Self {
Self {
status: None,
creator_id: None,
search: None,
}
}
}
/// Represents the voting results for a proposal
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VotingResults {
/// Proposal ID
pub proposal_id: String,
/// Number of yes votes
pub yes_count: usize,
/// Number of no votes
pub no_count: usize,
/// Number of abstain votes
pub abstain_count: usize,
/// Total number of votes
pub total_votes: usize,
}
impl VotingResults {
/// Creates a new empty voting results object
pub fn new(proposal_id: String) -> Self {
Self {
proposal_id,
yes_count: 0,
no_count: 0,
abstain_count: 0,
total_votes: 0,
}
}
/// Adds a vote to the results
pub fn add_vote(&mut self, vote_type: &VoteType) {
match vote_type {
VoteType::Yes => self.yes_count += 1,
VoteType::No => self.no_count += 1,
VoteType::Abstain => self.abstain_count += 1,
}
self.total_votes += 1;
}
/// Calculates the percentage of yes votes
pub fn yes_percentage(&self) -> f64 {
if self.total_votes == 0 {
return 0.0;
}
(self.yes_count as f64 / self.total_votes as f64) * 100.0
}
/// Calculates the percentage of no votes
pub fn no_percentage(&self) -> f64 {
if self.total_votes == 0 {
return 0.0;
}
(self.no_count as f64 / self.total_votes as f64) * 100.0
}
/// Calculates the percentage of abstain votes
pub fn abstain_percentage(&self) -> f64 {
if self.total_votes == 0 {
return 0.0;
}
(self.abstain_count as f64 / self.total_votes as f64) * 100.0
}
}

View File

@ -1,7 +1,7 @@
use crate::models::asset::AssetType;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::models::asset::{Asset, AssetType};
/// Status of a marketplace listing /// Status of a marketplace listing
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@ -12,6 +12,7 @@ pub enum ListingStatus {
Expired, Expired,
} }
#[allow(dead_code)]
impl ListingStatus { impl ListingStatus {
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
match self { match self {
@ -63,6 +64,7 @@ pub enum BidStatus {
Cancelled, Cancelled,
} }
#[allow(dead_code)]
impl BidStatus { impl BidStatus {
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
match self { match self {
@ -103,6 +105,7 @@ pub struct Listing {
pub image_url: Option<String>, pub image_url: Option<String>,
} }
#[allow(dead_code)]
impl Listing { impl Listing {
/// Creates a new listing /// Creates a new listing
pub fn new( pub fn new(
@ -150,7 +153,13 @@ impl Listing {
} }
/// Adds a bid to the listing /// Adds a bid to the listing
pub fn add_bid(&mut self, bidder_id: String, bidder_name: String, amount: f64, currency: String) -> Result<(), String> { pub fn add_bid(
&mut self,
bidder_id: String,
bidder_name: String,
amount: f64,
currency: String,
) -> Result<(), String> {
if self.status != ListingStatus::Active { if self.status != ListingStatus::Active {
return Err("Listing is not active".to_string()); return Err("Listing is not active".to_string());
} }
@ -160,7 +169,10 @@ impl Listing {
} }
if currency != self.currency { if currency != self.currency {
return Err(format!("Currency mismatch: expected {}, got {}", self.currency, currency)); return Err(format!(
"Currency mismatch: expected {}, got {}",
self.currency, currency
));
} }
// Check if bid amount is higher than current highest bid or starting price // Check if bid amount is higher than current highest bid or starting price
@ -193,13 +205,19 @@ impl Listing {
/// Gets the highest bid on the listing /// Gets the highest bid on the listing
pub fn highest_bid(&self) -> Option<&Bid> { pub fn highest_bid(&self) -> Option<&Bid> {
self.bids.iter() self.bids
.iter()
.filter(|b| b.status == BidStatus::Active) .filter(|b| b.status == BidStatus::Active)
.max_by(|a, b| a.amount.partial_cmp(&b.amount).unwrap()) .max_by(|a, b| a.amount.partial_cmp(&b.amount).unwrap())
} }
/// Marks the listing as sold /// Marks the listing as sold
pub fn mark_as_sold(&mut self, buyer_id: String, buyer_name: String, sale_price: f64) -> Result<(), String> { pub fn mark_as_sold(
&mut self,
buyer_id: String,
buyer_name: String,
sale_price: f64,
) -> Result<(), String> {
if self.status != ListingStatus::Active { if self.status != ListingStatus::Active {
return Err("Listing is not active".to_string()); return Err("Listing is not active".to_string());
} }
@ -257,11 +275,13 @@ impl MarketplaceStatistics {
let mut listings_by_type = std::collections::HashMap::new(); let mut listings_by_type = std::collections::HashMap::new();
let mut sales_by_asset_type = std::collections::HashMap::new(); let mut sales_by_asset_type = std::collections::HashMap::new();
let active_listings = listings.iter() let active_listings = listings
.iter()
.filter(|l| l.status == ListingStatus::Active) .filter(|l| l.status == ListingStatus::Active)
.count(); .count();
let sold_listings = listings.iter() let sold_listings = listings
.iter()
.filter(|l| l.status == ListingStatus::Sold) .filter(|l| l.status == ListingStatus::Sold)
.count(); .count();

View File

@ -1,17 +1,16 @@
// Export models // Export models
pub mod user;
pub mod ticket;
pub mod calendar;
pub mod governance;
pub mod flow;
pub mod contract;
pub mod asset; pub mod asset;
pub mod marketplace; pub mod calendar;
pub mod contract;
pub mod defi; pub mod defi;
pub mod flow;
pub mod marketplace;
pub mod ticket;
pub mod user;
// Re-export models for easier imports // Re-export models for easier imports
pub use calendar::CalendarViewMode;
pub use defi::initialize_mock_data;
pub use ticket::{Ticket, TicketComment, TicketPriority, TicketStatus};
pub use user::User; pub use user::User;
pub use ticket::{Ticket, TicketComment, TicketStatus, TicketPriority};
pub use calendar::{CalendarEvent, CalendarViewMode};
pub use marketplace::{Listing, ListingStatus, ListingType, Bid, BidStatus, MarketplaceStatistics};
pub use defi::{DefiPosition, DefiPositionType, DefiPositionStatus, ProvidingPosition, ReceivingPosition, DEFI_DB, initialize_mock_data};

View File

@ -76,6 +76,7 @@ pub struct Ticket {
pub assigned_to: Option<i32>, pub assigned_to: Option<i32>,
} }
#[allow(dead_code)]
impl Ticket { impl Ticket {
/// Creates a new ticket /// Creates a new ticket
pub fn new(user_id: i32, title: String, description: String, priority: TicketPriority) -> Self { pub fn new(user_id: i32, title: String, description: String, priority: TicketPriority) -> Self {

View File

@ -4,6 +4,7 @@ use bcrypt::{hash, verify, DEFAULT_COST};
/// Represents a user in the system /// Represents a user in the system
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct User { pub struct User {
/// Unique identifier for the user /// Unique identifier for the user
pub id: Option<i32>, pub id: Option<i32>,
@ -31,6 +32,7 @@ pub enum UserRole {
Admin, Admin,
} }
#[allow(dead_code)]
impl User { impl User {
/// Creates a new user with default values /// Creates a new user with default values
pub fn new(name: String, email: String) -> Self { pub fn new(name: String, email: String) -> Self {
@ -125,6 +127,7 @@ impl User {
/// Represents user login credentials /// Represents user login credentials
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct LoginCredentials { pub struct LoginCredentials {
pub email: String, pub email: String,
pub password: String, pub password: String,
@ -132,6 +135,7 @@ pub struct LoginCredentials {
/// Represents user registration data /// Represents user registration data
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct RegistrationData { pub struct RegistrationData {
pub name: String, pub name: String,
pub email: String, pub email: String,

View File

@ -1,28 +1,26 @@
use actix_web::web;
use actix_session::{SessionMiddleware, storage::CookieSessionStore};
use crate::controllers::home::HomeController;
use crate::controllers::auth::AuthController;
use crate::controllers::ticket::TicketController;
use crate::controllers::calendar::CalendarController;
use crate::controllers::governance::GovernanceController;
use crate::controllers::flow::FlowController;
use crate::controllers::contract::ContractController;
use crate::controllers::asset::AssetController;
use crate::controllers::marketplace::MarketplaceController;
use crate::controllers::defi::DefiController;
use crate::controllers::company::CompanyController;
use crate::middleware::JwtAuth;
use crate::SESSION_KEY; use crate::SESSION_KEY;
use crate::controllers::asset::AssetController;
use crate::controllers::auth::AuthController;
use crate::controllers::calendar::CalendarController;
use crate::controllers::company::CompanyController;
use crate::controllers::contract::ContractController;
use crate::controllers::defi::DefiController;
use crate::controllers::flow::FlowController;
use crate::controllers::governance::GovernanceController;
use crate::controllers::home::HomeController;
use crate::controllers::marketplace::MarketplaceController;
use crate::controllers::ticket::TicketController;
use crate::middleware::JwtAuth;
use actix_session::{SessionMiddleware, storage::CookieSessionStore};
use actix_web::web;
/// Configures all application routes /// Configures all application routes
pub fn configure_routes(cfg: &mut web::ServiceConfig) { pub fn configure_routes(cfg: &mut web::ServiceConfig) {
// Configure session middleware with the consistent key // Configure session middleware with the consistent key
let session_middleware = SessionMiddleware::builder( let session_middleware =
CookieSessionStore::default(), SessionMiddleware::builder(CookieSessionStore::default(), SESSION_KEY.clone())
SESSION_KEY.clone() .cookie_secure(false) // Set to true in production with HTTPS
) .build();
.cookie_secure(false) // Set to true in production with HTTPS
.build();
// Public routes that don't require authentication // Public routes that don't require authentication
cfg.service( cfg.service(
@ -33,56 +31,98 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) {
.route("/about", web::get().to(HomeController::about)) .route("/about", web::get().to(HomeController::about))
.route("/contact", web::get().to(HomeController::contact)) .route("/contact", web::get().to(HomeController::contact))
.route("/contact", web::post().to(HomeController::submit_contact)) .route("/contact", web::post().to(HomeController::submit_contact))
// Auth routes // Auth routes
.route("/login", web::get().to(AuthController::login_page)) .route("/login", web::get().to(AuthController::login_page))
.route("/login", web::post().to(AuthController::login)) .route("/login", web::post().to(AuthController::login))
.route("/register", web::get().to(AuthController::register_page)) .route("/register", web::get().to(AuthController::register_page))
.route("/register", web::post().to(AuthController::register)) .route("/register", web::post().to(AuthController::register))
.route("/logout", web::get().to(AuthController::logout)) .route("/logout", web::get().to(AuthController::logout))
// Protected routes that require authentication // Protected routes that require authentication
// These routes will be protected by the JwtAuth middleware in the main.rs file // These routes will be protected by the JwtAuth middleware in the main.rs file
.route("/editor", web::get().to(HomeController::editor)) .route("/editor", web::get().to(HomeController::editor))
// Ticket routes // Ticket routes
.route("/tickets", web::get().to(TicketController::list_tickets)) .route("/tickets", web::get().to(TicketController::list_tickets))
.route("/tickets/new", web::get().to(TicketController::new_ticket)) .route("/tickets/new", web::get().to(TicketController::new_ticket))
.route("/tickets", web::post().to(TicketController::create_ticket)) .route("/tickets", web::post().to(TicketController::create_ticket))
.route("/tickets/{id}", web::get().to(TicketController::show_ticket)) .route(
.route("/tickets/{id}/comment", web::post().to(TicketController::add_comment)) "/tickets/{id}",
.route("/tickets/{id}/status/{status}", web::post().to(TicketController::update_status)) web::get().to(TicketController::show_ticket),
)
.route(
"/tickets/{id}/comment",
web::post().to(TicketController::add_comment),
)
.route(
"/tickets/{id}/status/{status}",
web::post().to(TicketController::update_status),
)
.route("/my-tickets", web::get().to(TicketController::my_tickets)) .route("/my-tickets", web::get().to(TicketController::my_tickets))
// Calendar routes // Calendar routes
.route("/calendar", web::get().to(CalendarController::calendar)) .route("/calendar", web::get().to(CalendarController::calendar))
.route("/calendar/events/new", web::get().to(CalendarController::new_event)) .route(
.route("/calendar/events", web::post().to(CalendarController::create_event)) "/calendar/events/new",
.route("/calendar/events/{id}/delete", web::post().to(CalendarController::delete_event)) web::get().to(CalendarController::new_event),
)
.route(
"/calendar/events",
web::post().to(CalendarController::create_event),
)
.route(
"/calendar/events/{id}/delete",
web::post().to(CalendarController::delete_event),
)
// Governance routes // Governance routes
.route("/governance", web::get().to(GovernanceController::index)) .route("/governance", web::get().to(GovernanceController::index))
.route("/governance/proposals", web::get().to(GovernanceController::proposals)) .route(
.route("/governance/proposals/{id}", web::get().to(GovernanceController::proposal_detail)) "/governance/proposals",
.route("/governance/proposals/{id}/vote", web::post().to(GovernanceController::submit_vote)) web::get().to(GovernanceController::proposals),
.route("/governance/create", web::get().to(GovernanceController::create_proposal_form)) )
.route("/governance/create", web::post().to(GovernanceController::submit_proposal)) .route(
.route("/governance/my-votes", web::get().to(GovernanceController::my_votes)) "/governance/proposals/{id}",
web::get().to(GovernanceController::proposal_detail),
)
.route(
"/governance/proposals/{id}/vote",
web::post().to(GovernanceController::submit_vote),
)
.route(
"/governance/create",
web::get().to(GovernanceController::create_proposal_form),
)
.route(
"/governance/create",
web::post().to(GovernanceController::submit_proposal),
)
.route(
"/governance/my-votes",
web::get().to(GovernanceController::my_votes),
)
.route(
"/governance/activities",
web::get().to(GovernanceController::all_activities),
)
// Flow routes // Flow routes
.service( .service(
web::scope("/flows") web::scope("/flows")
.route("", web::get().to(FlowController::index)) .route("", web::get().to(FlowController::index))
.route("/list", web::get().to(FlowController::list_flows)) .route("/list", web::get().to(FlowController::list_flows))
.route("/{id}", web::get().to(FlowController::flow_detail)) .route("/{id}", web::get().to(FlowController::flow_detail))
.route("/{id}/advance", web::post().to(FlowController::advance_flow_step)) .route(
.route("/{id}/stuck", web::post().to(FlowController::mark_flow_step_stuck)) "/{id}/advance",
.route("/{id}/step/{step_id}/log", web::post().to(FlowController::add_log_to_flow_step)) web::post().to(FlowController::advance_flow_step),
)
.route(
"/{id}/stuck",
web::post().to(FlowController::mark_flow_step_stuck),
)
.route(
"/{id}/step/{step_id}/log",
web::post().to(FlowController::add_log_to_flow_step),
)
.route("/create", web::get().to(FlowController::create_flow_form)) .route("/create", web::get().to(FlowController::create_flow_form))
.route("/create", web::post().to(FlowController::create_flow)) .route("/create", web::post().to(FlowController::create_flow))
.route("/my-flows", web::get().to(FlowController::my_flows)) .route("/my-flows", web::get().to(FlowController::my_flows)),
) )
// Contract routes // Contract routes
.service( .service(
web::scope("/contracts") web::scope("/contracts")
@ -91,9 +131,8 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) {
.route("/my", web::get().to(ContractController::my_contracts)) .route("/my", web::get().to(ContractController::my_contracts))
.route("/{id}", web::get().to(ContractController::detail)) .route("/{id}", web::get().to(ContractController::detail))
.route("/create", web::get().to(ContractController::create_form)) .route("/create", web::get().to(ContractController::create_form))
.route("/create", web::post().to(ContractController::create)) .route("/create", web::post().to(ContractController::create)),
) )
// Asset routes // Asset routes
.service( .service(
web::scope("/assets") web::scope("/assets")
@ -104,35 +143,72 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) {
.route("/create", web::post().to(AssetController::create)) .route("/create", web::post().to(AssetController::create))
.route("/test", web::get().to(AssetController::test)) .route("/test", web::get().to(AssetController::test))
.route("/{id}", web::get().to(AssetController::detail)) .route("/{id}", web::get().to(AssetController::detail))
.route("/{id}/valuation", web::post().to(AssetController::add_valuation)) .route(
.route("/{id}/transaction", web::post().to(AssetController::add_transaction)) "/{id}/valuation",
.route("/{id}/status/{status}", web::post().to(AssetController::update_status)) web::post().to(AssetController::add_valuation),
)
.route(
"/{id}/transaction",
web::post().to(AssetController::add_transaction),
)
.route(
"/{id}/status/{status}",
web::post().to(AssetController::update_status),
),
) )
// Marketplace routes // Marketplace routes
.service( .service(
web::scope("/marketplace") web::scope("/marketplace")
.route("", web::get().to(MarketplaceController::index)) .route("", web::get().to(MarketplaceController::index))
.route("/listings", web::get().to(MarketplaceController::list_listings)) .route(
"/listings",
web::get().to(MarketplaceController::list_listings),
)
.route("/my", web::get().to(MarketplaceController::my_listings)) .route("/my", web::get().to(MarketplaceController::my_listings))
.route("/create", web::get().to(MarketplaceController::create_listing_form)) .route(
.route("/create", web::post().to(MarketplaceController::create_listing)) "/create",
.route("/{id}", web::get().to(MarketplaceController::listing_detail)) web::get().to(MarketplaceController::create_listing_form),
.route("/{id}/bid", web::post().to(MarketplaceController::submit_bid)) )
.route("/{id}/purchase", web::post().to(MarketplaceController::purchase_listing)) .route(
.route("/{id}/cancel", web::post().to(MarketplaceController::cancel_listing)) "/create",
web::post().to(MarketplaceController::create_listing),
)
.route(
"/{id}",
web::get().to(MarketplaceController::listing_detail),
)
.route(
"/{id}/bid",
web::post().to(MarketplaceController::submit_bid),
)
.route(
"/{id}/purchase",
web::post().to(MarketplaceController::purchase_listing),
)
.route(
"/{id}/cancel",
web::post().to(MarketplaceController::cancel_listing),
),
) )
// DeFi routes // DeFi routes
.service( .service(
web::scope("/defi") web::scope("/defi")
.route("", web::get().to(DefiController::index)) .route("", web::get().to(DefiController::index))
.route("/providing", web::post().to(DefiController::create_providing)) .route(
.route("/receiving", web::post().to(DefiController::create_receiving)) "/providing",
web::post().to(DefiController::create_providing),
)
.route(
"/receiving",
web::post().to(DefiController::create_receiving),
)
.route("/liquidity", web::post().to(DefiController::add_liquidity)) .route("/liquidity", web::post().to(DefiController::add_liquidity))
.route("/staking", web::post().to(DefiController::create_staking)) .route("/staking", web::post().to(DefiController::create_staking))
.route("/swap", web::post().to(DefiController::swap_tokens)) .route("/swap", web::post().to(DefiController::swap_tokens))
.route("/collateral", web::post().to(DefiController::create_collateral)) .route(
"/collateral",
web::post().to(DefiController::create_collateral),
),
) )
// Company routes // Company routes
.service( .service(
@ -140,13 +216,15 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) {
.route("", web::get().to(CompanyController::index)) .route("", web::get().to(CompanyController::index))
.route("/register", web::post().to(CompanyController::register)) .route("/register", web::post().to(CompanyController::register))
.route("/view/{id}", web::get().to(CompanyController::view_company)) .route("/view/{id}", web::get().to(CompanyController::view_company))
.route("/switch/{id}", web::get().to(CompanyController::switch_entity)) .route(
) "/switch/{id}",
web::get().to(CompanyController::switch_entity),
),
),
); );
// Keep the /protected scope for any future routes that should be under that path // Keep the /protected scope for any future routes that should be under that path
cfg.service( cfg.service(
web::scope("/protected") web::scope("/protected").wrap(JwtAuth), // Apply JWT authentication middleware
.wrap(JwtAuth) // Apply JWT authentication middleware
); );
} }

View File

@ -1,16 +1,17 @@
use actix_web::{error, Error, HttpResponse}; use actix_web::{Error, HttpResponse};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use tera::{self, Context, Function, Tera, Value};
use std::error::Error as StdError; use std::error::Error as StdError;
use tera::{self, Context, Function, Tera, Value};
// Export modules // Export modules
pub mod redis_service; pub mod redis_service;
// Re-export for easier imports // Re-export for easier imports
pub use redis_service::RedisCalendarService; // pub use redis_service::RedisCalendarService; // Currently unused
/// Error type for template rendering /// Error type for template rendering
#[derive(Debug)] #[derive(Debug)]
#[allow(dead_code)]
pub struct TemplateError { pub struct TemplateError {
pub message: String, pub message: String,
pub details: String, pub details: String,
@ -25,10 +26,16 @@ impl std::fmt::Display for TemplateError {
impl std::error::Error for TemplateError {} impl std::error::Error for TemplateError {}
/// Registers custom Tera functions /// Registers custom Tera functions and filters
pub fn register_tera_functions(tera: &mut tera::Tera) { pub fn register_tera_functions(tera: &mut tera::Tera) {
tera.register_function("now", NowFunction); tera.register_function("now", NowFunction);
tera.register_function("format_date", FormatDateFunction); tera.register_function("format_date", FormatDateFunction);
tera.register_function("local_time", LocalTimeFunction);
// Register custom filters
tera.register_filter("format_hour", format_hour_filter);
tera.register_filter("extract_hour", extract_hour_filter);
tera.register_filter("format_time", format_time_filter);
} }
/// Tera function to get the current date/time /// Tera function to get the current date/time
@ -46,7 +53,7 @@ impl Function for NowFunction {
}; };
let now = Utc::now(); let now = Utc::now();
// Special case for just getting the year // Special case for just getting the year
if args.get("year").and_then(|v| v.as_bool()).unwrap_or(false) { if args.get("year").and_then(|v| v.as_bool()).unwrap_or(false) {
return Ok(Value::String(now.format("%Y").to_string())); return Ok(Value::String(now.format("%Y").to_string()));
@ -68,14 +75,10 @@ impl Function for FormatDateFunction {
None => { None => {
return Err(tera::Error::msg( return Err(tera::Error::msg(
"The 'timestamp' argument must be a valid timestamp", "The 'timestamp' argument must be a valid timestamp",
)) ));
} }
}, },
None => { None => return Err(tera::Error::msg("The 'timestamp' argument is required")),
return Err(tera::Error::msg(
"The 'timestamp' argument is required",
))
}
}; };
let format = match args.get("format") { let format = match args.get("format") {
@ -89,23 +92,130 @@ impl Function for FormatDateFunction {
// Convert timestamp to DateTime using the non-deprecated method // Convert timestamp to DateTime using the non-deprecated method
let datetime = match DateTime::from_timestamp(timestamp, 0) { let datetime = match DateTime::from_timestamp(timestamp, 0) {
Some(dt) => dt, Some(dt) => dt,
None => { None => return Err(tera::Error::msg("Failed to convert timestamp to datetime")),
return Err(tera::Error::msg(
"Failed to convert timestamp to datetime",
))
}
}; };
Ok(Value::String(datetime.format(format).to_string())) Ok(Value::String(datetime.format(format).to_string()))
} }
} }
/// Tera function to convert UTC datetime to local time
#[derive(Clone)]
pub struct LocalTimeFunction;
impl Function for LocalTimeFunction {
fn call(&self, args: &std::collections::HashMap<String, Value>) -> tera::Result<Value> {
let datetime_value = match args.get("datetime") {
Some(val) => val,
None => return Err(tera::Error::msg("The 'datetime' argument is required")),
};
let format = match args.get("format") {
Some(val) => match val.as_str() {
Some(s) => s,
None => "%Y-%m-%d %H:%M",
},
None => "%Y-%m-%d %H:%M",
};
// The datetime comes from Rust as a serialized DateTime<Utc>
// We need to handle it properly
let utc_datetime = if let Some(dt_str) = datetime_value.as_str() {
// Try to parse as RFC3339 first
match DateTime::parse_from_rfc3339(dt_str) {
Ok(dt) => dt.with_timezone(&Utc),
Err(_) => {
// Try to parse as our standard format
match DateTime::parse_from_str(dt_str, "%Y-%m-%d %H:%M:%S%.f UTC") {
Ok(dt) => dt.with_timezone(&Utc),
Err(_) => return Err(tera::Error::msg("Invalid datetime string format")),
}
}
}
} else {
return Err(tera::Error::msg("Datetime must be a string"));
};
// Convert UTC to local time (EEST = UTC+3)
// In a real application, you'd want to get the user's timezone from their profile
let local_offset = chrono::FixedOffset::east_opt(3 * 3600).unwrap(); // +3 hours for EEST
let local_datetime = utc_datetime.with_timezone(&local_offset);
Ok(Value::String(local_datetime.format(format).to_string()))
}
}
/// Tera filter to format hour with zero padding
pub fn format_hour_filter(
value: &Value,
_args: &std::collections::HashMap<String, Value>,
) -> tera::Result<Value> {
match value.as_i64() {
Some(hour) => Ok(Value::String(format!("{:02}", hour))),
None => Err(tera::Error::msg("Value must be a number")),
}
}
/// Tera filter to extract hour from datetime string
pub fn extract_hour_filter(
value: &Value,
_args: &std::collections::HashMap<String, Value>,
) -> tera::Result<Value> {
match value.as_str() {
Some(datetime_str) => {
// Try to parse as RFC3339 first
if let Ok(dt) = DateTime::parse_from_rfc3339(datetime_str) {
Ok(Value::String(dt.format("%H").to_string()))
} else {
// Try to parse as our standard format
match DateTime::parse_from_str(datetime_str, "%Y-%m-%d %H:%M:%S%.f UTC") {
Ok(dt) => Ok(Value::String(dt.format("%H").to_string())),
Err(_) => Err(tera::Error::msg("Invalid datetime string format")),
}
}
}
None => Err(tera::Error::msg("Value must be a string")),
}
}
/// Tera filter to format time from datetime string
pub fn format_time_filter(
value: &Value,
args: &std::collections::HashMap<String, Value>,
) -> tera::Result<Value> {
let format = match args.get("format") {
Some(val) => match val.as_str() {
Some(s) => s,
None => "%H:%M",
},
None => "%H:%M",
};
match value.as_str() {
Some(datetime_str) => {
// Try to parse as RFC3339 first
if let Ok(dt) = DateTime::parse_from_rfc3339(datetime_str) {
Ok(Value::String(dt.format(format).to_string()))
} else {
// Try to parse as our standard format
match DateTime::parse_from_str(datetime_str, "%Y-%m-%d %H:%M:%S%.f UTC") {
Ok(dt) => Ok(Value::String(dt.format(format).to_string())),
Err(_) => Err(tera::Error::msg("Invalid datetime string format")),
}
}
}
None => Err(tera::Error::msg("Value must be a string")),
}
}
/// Formats a date for display /// Formats a date for display
#[allow(dead_code)]
pub fn format_date(date: &DateTime<Utc>, format: &str) -> String { pub fn format_date(date: &DateTime<Utc>, format: &str) -> String {
date.format(format).to_string() date.format(format).to_string()
} }
/// Truncates a string to a maximum length and adds an ellipsis if truncated /// Truncates a string to a maximum length and adds an ellipsis if truncated
#[allow(dead_code)]
pub fn truncate_string(s: &str, max_length: usize) -> String { pub fn truncate_string(s: &str, max_length: usize) -> String {
if s.len() <= max_length { if s.len() <= max_length {
s.to_string() s.to_string()
@ -124,38 +234,41 @@ pub fn render_template(
ctx: &Context, ctx: &Context,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
println!("DEBUG: Attempting to render template: {}", template_name); println!("DEBUG: Attempting to render template: {}", template_name);
// Print all context keys for debugging // Print all context keys for debugging
let mut keys = Vec::new(); let mut keys = Vec::new();
for (key, _) in ctx.clone().into_json().as_object().unwrap().iter() { for (key, _) in ctx.clone().into_json().as_object().unwrap().iter() {
keys.push(key.clone()); keys.push(key.clone());
} }
println!("DEBUG: Context keys: {:?}", keys); println!("DEBUG: Context keys: {:?}", keys);
match tmpl.render(template_name, ctx) { match tmpl.render(template_name, ctx) {
Ok(content) => { Ok(content) => {
println!("DEBUG: Successfully rendered template: {}", template_name); println!("DEBUG: Successfully rendered template: {}", template_name);
Ok(HttpResponse::Ok().content_type("text/html").body(content)) Ok(HttpResponse::Ok().content_type("text/html").body(content))
}, }
Err(e) => { Err(e) => {
// Log the error with more details // Log the error with more details
println!("DEBUG: Template rendering error for {}: {}", template_name, e); println!(
"DEBUG: Template rendering error for {}: {}",
template_name, e
);
println!("DEBUG: Error details: {:?}", e); println!("DEBUG: Error details: {:?}", e);
// Print the error cause chain for better debugging // Print the error cause chain for better debugging
let mut current_error: Option<&dyn StdError> = Some(&e); let mut current_error: Option<&dyn StdError> = Some(&e);
let mut error_chain = Vec::new(); let mut error_chain = Vec::new();
while let Some(error) = current_error { while let Some(error) = current_error {
error_chain.push(format!("{}", error)); error_chain.push(format!("{}", error));
current_error = error.source(); current_error = error.source();
} }
println!("DEBUG: Error chain: {:?}", error_chain); println!("DEBUG: Error chain: {:?}", error_chain);
// Log the error // Log the error
log::error!("Template rendering error: {}", e); log::error!("Template rendering error: {}", e);
// Create a simple error response with more detailed information // Create a simple error response with more detailed information
let error_html = format!( let error_html = format!(
r#"<!DOCTYPE html> r#"<!DOCTYPE html>
@ -187,9 +300,9 @@ pub fn render_template(
e, e,
error_chain.join("\n") error_chain.join("\n")
); );
println!("DEBUG: Returning simple error page"); println!("DEBUG: Returning simple error page");
Ok(HttpResponse::InternalServerError() Ok(HttpResponse::InternalServerError()
.content_type("text/html") .content_type("text/html")
.body(error_html)) .body(error_html))
@ -207,4 +320,4 @@ mod tests {
assert_eq!(truncate_string("Hello, world!", 5), "Hello..."); assert_eq!(truncate_string("Hello, world!", 5), "Hello...");
assert_eq!(truncate_string("", 5), ""); assert_eq!(truncate_string("", 5), "");
} }
} }

View File

@ -1,7 +1,7 @@
use heromodels::models::Event as CalendarEvent;
use lazy_static::lazy_static;
use redis::{Client, Commands, Connection, RedisError}; use redis::{Client, Commands, Connection, RedisError};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use lazy_static::lazy_static;
use crate::models::CalendarEvent;
// Create a lazy static Redis client that can be used throughout the application // Create a lazy static Redis client that can be used throughout the application
lazy_static! { lazy_static! {
@ -11,21 +11,21 @@ lazy_static! {
/// Initialize the Redis client /// Initialize the Redis client
pub fn init_redis_client(redis_url: &str) -> Result<(), RedisError> { pub fn init_redis_client(redis_url: &str) -> Result<(), RedisError> {
let client = redis::Client::open(redis_url)?; let client = redis::Client::open(redis_url)?;
// Test the connection // Test the connection
let _: Connection = client.get_connection()?; let _: Connection = client.get_connection()?;
// Store the client in the lazy static // Store the client in the lazy static
let mut client_guard = REDIS_CLIENT.lock().unwrap(); let mut client_guard = REDIS_CLIENT.lock().unwrap();
*client_guard = Some(client); *client_guard = Some(client);
Ok(()) Ok(())
} }
/// Get a Redis connection /// Get a Redis connection
pub fn get_connection() -> Result<Connection, RedisError> { pub fn get_connection() -> Result<Connection, RedisError> {
let client_guard = REDIS_CLIENT.lock().unwrap(); let client_guard = REDIS_CLIENT.lock().unwrap();
if let Some(client) = &*client_guard { if let Some(client) = &*client_guard {
client.get_connection() client.get_connection()
} else { } else {
@ -42,14 +42,14 @@ pub struct RedisCalendarService;
impl RedisCalendarService { impl RedisCalendarService {
/// Key prefix for calendar events /// Key prefix for calendar events
const EVENT_KEY_PREFIX: &'static str = "calendar:event:"; const EVENT_KEY_PREFIX: &'static str = "calendar:event:";
/// Key for the set of all event IDs /// Key for the set of all event IDs
const ALL_EVENTS_KEY: &'static str = "calendar:all_events"; const ALL_EVENTS_KEY: &'static str = "calendar:all_events";
/// Save a calendar event to Redis /// Save a calendar event to Redis
pub fn save_event(event: &CalendarEvent) -> Result<(), RedisError> { pub fn save_event(event: &CalendarEvent) -> Result<(), RedisError> {
let mut conn = get_connection()?; let mut conn = get_connection()?;
// Convert the event to JSON // Convert the event to JSON
let json = event.to_json().map_err(|e| { let json = event.to_json().map_err(|e| {
RedisError::from(std::io::Error::new( RedisError::from(std::io::Error::new(
@ -57,25 +57,25 @@ impl RedisCalendarService {
format!("Failed to serialize event: {}", e), format!("Failed to serialize event: {}", e),
)) ))
})?; })?;
// Save the event // Save the event
let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, event.id); let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, event.base_data.id);
let _: () = conn.set(event_key, json)?; let _: () = conn.set(event_key, json)?;
// Add the event ID to the set of all events // Add the event ID to the set of all events
let _: () = conn.sadd(Self::ALL_EVENTS_KEY, &event.id)?; let _: () = conn.sadd(Self::ALL_EVENTS_KEY, &event.base_data.id)?;
Ok(()) Ok(())
} }
/// Get a calendar event from Redis by ID /// Get a calendar event from Redis by ID
pub fn get_event(id: &str) -> Result<Option<CalendarEvent>, RedisError> { pub fn get_event(id: &str) -> Result<Option<CalendarEvent>, RedisError> {
let mut conn = get_connection()?; let mut conn = get_connection()?;
// Get the event JSON // Get the event JSON
let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, id); let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, id);
let json: Option<String> = conn.get(event_key)?; let json: Option<String> = conn.get(event_key)?;
// Parse the JSON // Parse the JSON
if let Some(json) = json { if let Some(json) = json {
let event = CalendarEvent::from_json(&json).map_err(|e| { let event = CalendarEvent::from_json(&json).map_err(|e| {
@ -84,34 +84,34 @@ impl RedisCalendarService {
format!("Failed to deserialize event: {}", e), format!("Failed to deserialize event: {}", e),
)) ))
})?; })?;
Ok(Some(event)) Ok(Some(event))
} else { } else {
Ok(None) Ok(None)
} }
} }
/// Delete a calendar event from Redis /// Delete a calendar event from Redis
pub fn delete_event(id: &str) -> Result<bool, RedisError> { pub fn delete_event(id: &str) -> Result<bool, RedisError> {
let mut conn = get_connection()?; let mut conn = get_connection()?;
// Delete the event // Delete the event
let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, id); let event_key = format!("{}{}", Self::EVENT_KEY_PREFIX, id);
let deleted: i32 = conn.del(event_key)?; let deleted: i32 = conn.del(event_key)?;
// Remove the event ID from the set of all events // Remove the event ID from the set of all events
let _: () = conn.srem(Self::ALL_EVENTS_KEY, id)?; let _: () = conn.srem(Self::ALL_EVENTS_KEY, id)?;
Ok(deleted > 0) Ok(deleted > 0)
} }
/// Get all calendar events from Redis /// Get all calendar events from Redis
pub fn get_all_events() -> Result<Vec<CalendarEvent>, RedisError> { pub fn get_all_events() -> Result<Vec<CalendarEvent>, RedisError> {
let mut conn = get_connection()?; let mut conn = get_connection()?;
// Get all event IDs // Get all event IDs
let event_ids: Vec<String> = conn.smembers(Self::ALL_EVENTS_KEY)?; let event_ids: Vec<String> = conn.smembers(Self::ALL_EVENTS_KEY)?;
// Get all events // Get all events
let mut events = Vec::new(); let mut events = Vec::new();
for id in event_ids { for id in event_ids {
@ -119,23 +119,23 @@ impl RedisCalendarService {
events.push(event); events.push(event);
} }
} }
Ok(events) Ok(events)
} }
/// Get events for a specific date range /// Get events for a specific date range
pub fn get_events_in_range( pub fn get_events_in_range(
start: chrono::DateTime<chrono::Utc>, start: chrono::DateTime<chrono::Utc>,
end: chrono::DateTime<chrono::Utc>, end: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<CalendarEvent>, RedisError> { ) -> Result<Vec<CalendarEvent>, RedisError> {
let all_events = Self::get_all_events()?; let all_events = Self::get_all_events()?;
// Filter events that fall within the date range // Filter events that fall within the date range
let filtered_events = all_events let filtered_events = all_events
.into_iter() .into_iter()
.filter(|event| event.start_time <= end && event.end_time >= start) .filter(|event| event.start_time <= end && event.end_time >= start)
.collect(); .collect();
Ok(filtered_events) Ok(filtered_events)
} }
} }

View File

@ -1,644 +1,50 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Register for Digital Freezone Residence{% endblock %} {% block title %}Register{% endblock %}
{% block content %} {% block content %}
<div class="card mb-4"> <div class="row justify-content-center">
<div class="card-header bg-success text-white"> <div class="col-md-6">
<h4 class="mb-0"><i class="bi bi-person-plus me-1"></i> Register for Digital Freezone Residence</h4> <div class="card shadow">
</div> <div class="card-header bg-primary text-white">
<div class="card-body"> <h4 class="mb-0">Register</h4>
{% if errors %}
<div class="alert alert-danger" role="alert">
<ul class="mb-0">
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<form method="post" action="/register" id="userRegistrationForm" enctype="multipart/form-data">
<!-- Progress bar -->
<div class="progress mb-4">
<div class="progress-bar bg-success" role="progressbar" style="width: 50%" aria-valuenow="50" aria-valuemin="0" aria-valuemax="100" id="progress-bar">Step 1 of 2</div>
</div> </div>
<div class="card-body">
<!-- Step indicators --> {% if errors %}
<div class="d-flex justify-content-between mb-4"> <div class="alert alert-danger" role="alert">
<div class="step-indicator active" id="step-indicator-1"> <ul class="mb-0">
<span class="badge rounded-pill bg-success">1</span> Personal Info {% for error in errors %}
</div> <li>{{ error }}</li>
<div class="step-indicator" id="step-indicator-2"> {% endfor %}
<span class="badge rounded-pill bg-secondary">2</span> Contracts & KYC
</div>
</div>
<!-- Step 1: Personal Information -->
<div class="form-step" id="step-1">
<h4 class="mb-3">Personal Information</h4>
<div class="row mb-3">
<div class="col-md-6">
<label for="name" class="form-label">Full Legal Name</label>
<input type="text" class="form-control" id="name" name="name" value="{{ name | default(value='') }}" required>
</div>
<div class="col-md-6">
<label for="email" class="form-label">Email Address</label>
<input type="email" class="form-control" id="email" name="email" value="{{ email | default(value='') }}" required>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="digital_id_key" class="form-label">Digital ID Public Key <a href="#" data-bs-toggle="modal" data-bs-target="#digitalIdModal"><i class="bi bi-question-circle text-muted"></i></a></label>
<input type="text" class="form-control" id="digital_id_key" name="digital_id_key" value="{{ digital_id_key | default(value='') }}" placeholder="Enter your public key or connect wallet">
<div class="form-text">Your digital identity for secure signing and blockchain transactions.</div>
</div>
<div class="col-md-6 d-flex align-items-end">
<button type="button" class="btn btn-outline-primary mb-2" onclick="connectWallet()">
<i class="bi bi-wallet2 me-1"></i> Connect Wallet
</button>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="nationality" class="form-label">Nationality</label>
<select class="form-select" id="nationality" name="nationality" required>
<option value="" selected disabled>Select your country</option>
<option value="Afghanistan">Afghanistan</option>
<option value="Albania">Albania</option>
<option value="Algeria">Algeria</option>
<option value="Andorra">Andorra</option>
<option value="Angola">Angola</option>
<option value="Antigua and Barbuda">Antigua and Barbuda</option>
<option value="Argentina">Argentina</option>
<option value="Armenia">Armenia</option>
<option value="Australia">Australia</option>
<option value="Austria">Austria</option>
<option value="Azerbaijan">Azerbaijan</option>
<option value="Bahamas">Bahamas</option>
<option value="Bahrain">Bahrain</option>
<option value="Bangladesh">Bangladesh</option>
<option value="Barbados">Barbados</option>
<option value="Belarus">Belarus</option>
<option value="Belgium">Belgium</option>
<option value="Belize">Belize</option>
<option value="Benin">Benin</option>
<option value="Bhutan">Bhutan</option>
<option value="Bolivia">Bolivia</option>
<option value="Bosnia and Herzegovina">Bosnia and Herzegovina</option>
<option value="Botswana">Botswana</option>
<option value="Brazil">Brazil</option>
<option value="Brunei">Brunei</option>
<option value="Bulgaria">Bulgaria</option>
<option value="Burkina Faso">Burkina Faso</option>
<option value="Burundi">Burundi</option>
<option value="Cabo Verde">Cabo Verde</option>
<option value="Cambodia">Cambodia</option>
<option value="Cameroon">Cameroon</option>
<option value="Canada">Canada</option>
<option value="Central African Republic">Central African Republic</option>
<option value="Chad">Chad</option>
<option value="Chile">Chile</option>
<option value="China">China</option>
<option value="Colombia">Colombia</option>
<option value="Comoros">Comoros</option>
<option value="Congo">Congo</option>
<option value="Costa Rica">Costa Rica</option>
<option value="Croatia">Croatia</option>
<option value="Cuba">Cuba</option>
<option value="Cyprus">Cyprus</option>
<option value="Czech Republic">Czech Republic</option>
<option value="Denmark">Denmark</option>
<option value="Djibouti">Djibouti</option>
<option value="Dominica">Dominica</option>
<option value="Dominican Republic">Dominican Republic</option>
<option value="Ecuador">Ecuador</option>
<option value="Egypt">Egypt</option>
<option value="El Salvador">El Salvador</option>
<option value="Equatorial Guinea">Equatorial Guinea</option>
<option value="Eritrea">Eritrea</option>
<option value="Estonia">Estonia</option>
<option value="Eswatini">Eswatini</option>
<option value="Ethiopia">Ethiopia</option>
<option value="Fiji">Fiji</option>
<option value="Finland">Finland</option>
<option value="France">France</option>
<option value="Gabon">Gabon</option>
<option value="Gambia">Gambia</option>
<option value="Georgia">Georgia</option>
<option value="Germany">Germany</option>
<option value="Ghana">Ghana</option>
<option value="Greece">Greece</option>
<option value="Grenada">Grenada</option>
<option value="Guatemala">Guatemala</option>
<option value="Guinea">Guinea</option>
<option value="Guinea-Bissau">Guinea-Bissau</option>
<option value="Guyana">Guyana</option>
<option value="Haiti">Haiti</option>
<option value="Honduras">Honduras</option>
<option value="Hungary">Hungary</option>
<option value="Iceland">Iceland</option>
<option value="India">India</option>
<option value="Indonesia">Indonesia</option>
<option value="Iran">Iran</option>
<option value="Iraq">Iraq</option>
<option value="Ireland">Ireland</option>
<option value="Israel">Israel</option>
<option value="Italy">Italy</option>
<option value="Jamaica">Jamaica</option>
<option value="Japan">Japan</option>
<option value="Jordan">Jordan</option>
<option value="Kazakhstan">Kazakhstan</option>
<option value="Kenya">Kenya</option>
<option value="Kiribati">Kiribati</option>
<option value="Korea, North">Korea, North</option>
<option value="Korea, South">Korea, South</option>
<option value="Kosovo">Kosovo</option>
<option value="Kuwait">Kuwait</option>
<option value="Kyrgyzstan">Kyrgyzstan</option>
<option value="Laos">Laos</option>
<option value="Latvia">Latvia</option>
<option value="Lebanon">Lebanon</option>
<option value="Lesotho">Lesotho</option>
<option value="Liberia">Liberia</option>
<option value="Libya">Libya</option>
<option value="Liechtenstein">Liechtenstein</option>
<option value="Lithuania">Lithuania</option>
<option value="Luxembourg">Luxembourg</option>
<option value="Madagascar">Madagascar</option>
<option value="Malawi">Malawi</option>
<option value="Malaysia">Malaysia</option>
<option value="Maldives">Maldives</option>
<option value="Mali">Mali</option>
<option value="Malta">Malta</option>
<option value="Marshall Islands">Marshall Islands</option>
<option value="Mauritania">Mauritania</option>
<option value="Mauritius">Mauritius</option>
<option value="Mexico">Mexico</option>
<option value="Micronesia">Micronesia</option>
<option value="Moldova">Moldova</option>
<option value="Monaco">Monaco</option>
<option value="Mongolia">Mongolia</option>
<option value="Montenegro">Montenegro</option>
<option value="Morocco">Morocco</option>
<option value="Mozambique">Mozambique</option>
<option value="Myanmar">Myanmar</option>
<option value="Namibia">Namibia</option>
<option value="Nauru">Nauru</option>
<option value="Nepal">Nepal</option>
<option value="Netherlands">Netherlands</option>
<option value="New Zealand">New Zealand</option>
<option value="Nicaragua">Nicaragua</option>
<option value="Niger">Niger</option>
<option value="Nigeria">Nigeria</option>
<option value="North Macedonia">North Macedonia</option>
<option value="Norway">Norway</option>
<option value="Oman">Oman</option>
<option value="Pakistan">Pakistan</option>
<option value="Palau">Palau</option>
<option value="Palestine">Palestine</option>
<option value="Panama">Panama</option>
<option value="Papua New Guinea">Papua New Guinea</option>
<option value="Paraguay">Paraguay</option>
<option value="Peru">Peru</option>
<option value="Philippines">Philippines</option>
<option value="Poland">Poland</option>
<option value="Portugal">Portugal</option>
<option value="Qatar">Qatar</option>
<option value="Romania">Romania</option>
<option value="Russia">Russia</option>
<option value="Rwanda">Rwanda</option>
<option value="Saint Kitts and Nevis">Saint Kitts and Nevis</option>
<option value="Saint Lucia">Saint Lucia</option>
<option value="Saint Vincent and the Grenadines">Saint Vincent and the Grenadines</option>
<option value="Samoa">Samoa</option>
<option value="San Marino">San Marino</option>
<option value="Sao Tome and Principe">Sao Tome and Principe</option>
<option value="Saudi Arabia">Saudi Arabia</option>
<option value="Senegal">Senegal</option>
<option value="Serbia">Serbia</option>
<option value="Seychelles">Seychelles</option>
<option value="Sierra Leone">Sierra Leone</option>
<option value="Singapore">Singapore</option>
<option value="Slovakia">Slovakia</option>
<option value="Slovenia">Slovenia</option>
<option value="Solomon Islands">Solomon Islands</option>
<option value="Somalia">Somalia</option>
<option value="South Africa">South Africa</option>
<option value="South Sudan">South Sudan</option>
<option value="Spain">Spain</option>
<option value="Sri Lanka">Sri Lanka</option>
<option value="Sudan">Sudan</option>
<option value="Suriname">Suriname</option>
<option value="Sweden">Sweden</option>
<option value="Switzerland">Switzerland</option>
<option value="Syria">Syria</option>
<option value="Taiwan">Taiwan</option>
<option value="Tajikistan">Tajikistan</option>
<option value="Tanzania">Tanzania</option>
<option value="Thailand">Thailand</option>
<option value="Timor-Leste">Timor-Leste</option>
<option value="Togo">Togo</option>
<option value="Tonga">Tonga</option>
<option value="Trinidad and Tobago">Trinidad and Tobago</option>
<option value="Tunisia">Tunisia</option>
<option value="Turkey">Turkey</option>
<option value="Turkmenistan">Turkmenistan</option>
<option value="Tuvalu">Tuvalu</option>
<option value="Uganda">Uganda</option>
<option value="Ukraine">Ukraine</option>
<option value="United Arab Emirates">United Arab Emirates</option>
<option value="United Kingdom">United Kingdom</option>
<option value="United States">United States</option>
<option value="Uruguay">Uruguay</option>
<option value="Uzbekistan">Uzbekistan</option>
<option value="Vanuatu">Vanuatu</option>
<option value="Vatican City">Vatican City</option>
<option value="Venezuela">Venezuela</option>
<option value="Vietnam">Vietnam</option>
<option value="Yemen">Yemen</option>
<option value="Zambia">Zambia</option>
<option value="Zimbabwe">Zimbabwe</option>
</select>
</div>
<div class="col-md-6">
<label for="phone" class="form-label">Phone Number</label>
<input type="tel" class="form-control" id="phone" name="phone" value="{{ phone | default(value='') }}">
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="address" class="form-label">Current Address</label>
<input type="text" class="form-control" id="address" name="address" value="{{ address | default(value='') }}">
</div>
<div class="col-md-6">
<label for="date_of_birth" class="form-label">Date of Birth</label>
<input type="date" class="form-control" id="date_of_birth" name="date_of_birth" value="{{ date_of_birth | default(value='') }}">
</div>
</div>
<div class="d-flex justify-content-end mt-4">
<button type="button" class="btn btn-success" onclick="nextStep(1)">Next <i class="bi bi-arrow-right"></i></button>
</div>
</div>
<!-- Step 2: Contracts & KYC -->
<div class="form-step" id="step-2" style="display: none;">
<h4 class="mb-3">Contracts & KYC Verification</h4>
<!-- Required Contracts Section -->
<div class="card mb-4">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="bi bi-file-earmark-text me-2"></i>Required Contracts</h5>
</div>
<div class="card-body">
<p class="card-text">The following contracts must be signed:</p>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 40%">Contract</th>
<th style="width: 40%">Description</th>
<th style="width: 20%">Actions</th>
</tr>
</thead>
<tbody>
<!-- Common contracts for all users -->
<tr>
<td>Freezone Residence Terms & Conditions</td>
<td>General terms and conditions for digital freezone residence</td>
<td>
<div class="d-flex align-items-center">
<button type="button" class="btn btn-sm btn-outline-primary me-1" onclick="viewContract('residence-terms')">View</button>
<div class="form-check ms-1">
<input class="form-check-input" type="checkbox" id="contract-terms" name="contracts[]" value="terms" required>
<label class="form-check-label" for="contract-terms">Sign</label>
</div>
</div>
</td>
</tr>
<tr>
<td>Data Protection Agreement</td>
<td>Agreement on how your personal data will be processed</td>
<td>
<div class="d-flex align-items-center">
<button type="button" class="btn btn-sm btn-outline-primary me-1" onclick="viewContract('data-protection')">View</button>
<div class="form-check ms-1">
<input class="form-check-input" type="checkbox" id="contract-data" name="contracts[]" value="data" required>
<label class="form-check-label" for="contract-data">Sign</label>
</div>
</div>
</td>
</tr>
<tr>
<td>Digital Asset Compliance</td>
<td>Compliance requirements for digital asset ownership</td>
<td>
<div class="d-flex align-items-center">
<button type="button" class="btn btn-sm btn-outline-primary me-1" onclick="viewContract('compliance')">View</button>
<div class="form-check ms-1">
<input class="form-check-input" type="checkbox" id="contract-compliance" name="contracts[]" value="compliance" required>
<label class="form-check-label" for="contract-compliance">Sign</label>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="contract-agreement" name="contract_agreement" required>
<label class="form-check-label" for="contract-agreement">
<strong>I have read and agree to all the required contracts</strong>
</label>
</div>
</div>
</div>
<!-- KYC Verification Section -->
<div class="card mb-4">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="bi bi-shield-check me-2"></i>KYC Verification</h5>
</div>
<div class="card-body">
<p>To complete your registration, you'll need to verify your identity through our KYC process.</p>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i> You can complete the KYC verification after registration, but some features will be limited until verification is complete.
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="kyc-agreement" name="kyc_agreement">
<label class="form-check-label" for="kyc-agreement">
I understand that I need to complete KYC verification to access all features
</label>
</div>
<button type="button" class="btn btn-outline-success" onclick="startKycProcess()">
<i class="bi bi-shield-check me-1"></i> Start KYC Process Now
</button>
</div>
</div>
<div class="d-flex justify-content-between mt-4">
<button type="button" class="btn btn-outline-secondary" onclick="prevStep(2)"><i class="bi bi-arrow-left"></i> Previous</button>
<button type="submit" class="btn btn-success btn-lg">
<i class="bi bi-person-check me-1"></i> Complete Registration
</button>
</div>
</div>
</form>
</div>
<div class="card-footer text-center">
<p class="mb-0">Already have an account? <a href="/login">Login</a></p>
</div>
</div>
<!-- JavaScript for contract viewing, KYC process, and multi-step form -->
<script>
// Multi-step form navigation
function nextStep(currentStep) {
// Validate current step
if (validateStep(currentStep)) {
// Hide current step
document.getElementById(`step-${currentStep}`).style.display = 'none';
// Show next step
document.getElementById(`step-${currentStep + 1}`).style.display = 'block';
// Update progress bar
updateProgressBar(currentStep + 1);
// Update step indicators
updateStepIndicators(currentStep + 1);
}
}
function prevStep(currentStep) {
// Hide current step
document.getElementById(`step-${currentStep}`).style.display = 'none';
// Show previous step
document.getElementById(`step-${currentStep - 1}`).style.display = 'block';
// Update progress bar
updateProgressBar(currentStep - 1);
// Update step indicators
updateStepIndicators(currentStep - 1);
}
function updateProgressBar(step) {
const progressBar = document.getElementById('progress-bar');
const percentage = (step / 2) * 100;
progressBar.style.width = `${percentage}%`;
progressBar.setAttribute('aria-valuenow', percentage);
progressBar.textContent = `Step ${step} of 2`;
}
function updateStepIndicators(activeStep) {
// Reset all indicators
document.querySelectorAll('.step-indicator').forEach((indicator, index) => {
const stepNum = index + 1;
indicator.classList.remove('active');
const badge = indicator.querySelector('.badge');
badge.classList.remove('bg-success');
badge.classList.add('bg-secondary');
});
// Set active indicator
const activeIndicator = document.getElementById(`step-indicator-${activeStep}`);
activeIndicator.classList.add('active');
const activeBadge = activeIndicator.querySelector('.badge');
activeBadge.classList.remove('bg-secondary');
activeBadge.classList.add('bg-success');
}
function validateStep(step) {
if (step === 1) {
// Validate personal information fields
const name = document.getElementById('name').value;
const email = document.getElementById('email').value;
const nationality = document.getElementById('nationality').value;
if (!name || !email) {
alert('Please fill in all required fields.');
return false;
}
if (!nationality) {
alert('Please select your nationality.');
return false;
}
return true;
}
return true; // No validation for other steps
}
// Contract viewing function
function viewContract(contractId) {
// In a real application, this would open the contract document
// For now, we'll just show an alert
alert(`Viewing contract: ${contractId.replace(/-/g, ' ')}`);
// This would typically involve:
// 1. Fetching the contract document from the server
// 2. Opening it in a viewer or new tab
// window.open(`/contracts/view/${contractId}`, '_blank');
}
// KYC process function
function startKycProcess() {
alert('Starting KYC verification process. In a production environment, this would redirect to a secure KYC provider.');
// This would typically redirect to a KYC provider or open a modal with KYC steps
// window.location.href = '/kyc/start';
}
// Wallet connection function
function connectWallet() {
// In a real implementation, this would connect to various wallet providers
// For demonstration purposes, we'll simulate a successful connection
// Simulate wallet selection dialog
const walletType = prompt('Select wallet type (MetaMask, Polkadot.js, TFConnect, or Other):', 'MetaMask');
if (!walletType) {
return; // User cancelled
}
// Simulate connection process
setTimeout(() => {
// Generate a sample public key (in a real app, this would come from the wallet)
const samplePublicKey = generateSamplePublicKey(walletType);
// Update the digital ID field with the public key
document.getElementById('digital_id_key').value = samplePublicKey;
// Show success message
alert(`Successfully connected to ${walletType}! Your public key has been added to the form.`);
}, 1000);
}
// Helper function to generate a sample public key for demonstration
function generateSamplePublicKey(walletType) {
const prefixes = {
'MetaMask': '0x',
'Polkadot.js': '5',
'TFConnect': 'twin',
'Other': 'key'
};
const prefix = prefixes[walletType] || prefixes['Other'];
const randomChars = '0123456789abcdef';
let key = prefix;
// Generate random characters
for (let i = 0; i < 40; i++) {
key += randomChars.charAt(Math.floor(Math.random() * randomChars.length));
}
return key;
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
// Add event listener to ensure all contracts are checked when the agreement checkbox is checked
const agreementCheckbox = document.getElementById('contract-agreement');
const contractCheckboxes = document.querySelectorAll('input[name="contracts[]"]');
if (agreementCheckbox) {
agreementCheckbox.addEventListener('change', function() {
if (this.checked) {
// Verify all contracts are checked
let allChecked = true;
contractCheckboxes.forEach(checkbox => {
if (!checkbox.checked) {
allChecked = false;
}
});
if (!allChecked) {
alert('Please read and sign all required contracts first.');
this.checked = false;
}
}
});
}
});
</script>
<style>
/* Step indicator styling */
.step-indicator {
text-align: center;
position: relative;
flex: 1;
}
.step-indicator.active {
font-weight: bold;
}
/* Form step styling */
.form-step {
transition: all 0.3s ease;
}
</style>
<!-- Digital ID Explanation Modal -->
<div class="modal fade" id="digitalIdModal" tabindex="-1" aria-labelledby="digitalIdModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-light">
<h5 class="modal-title" id="digitalIdModalLabel"><i class="bi bi-key me-2"></i> Digital ID Public Key</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<h5>What is a Digital ID?</h5>
<p>A Digital ID is a secure, blockchain-based identity that allows you to:</p>
<ul>
<li>Digitally sign documents and contracts</li>
<li>Securely access digital services in the freezone</li>
<li>Manage your digital assets and transactions</li>
<li>Participate in governance and voting</li>
</ul>
<div class="alert alert-info">
<h6><i class="bi bi-info-circle me-2"></i>How it works:</h6>
<p>Your Digital ID consists of a pair of cryptographic keys:</p>
<ul>
<li><strong>Public Key</strong>: Shared with others and used to verify your identity</li>
<li><strong>Private Key</strong>: Kept secret and used to sign documents and transactions</li>
</ul> </ul>
</div> </div>
{% endif %}
<h5>How to Create Your Digital ID</h5> <form method="post" action="/register">
<p>You have two options to create your Digital ID:</p> <div class="mb-3">
<label for="name" class="form-label">Full Name</label>
<div class="card mb-3"> <input type="text" class="form-control" id="name" name="name" value="{{ name | default(value='') }}" required>
<div class="card-header">Option 1: Connect an Existing Wallet</div>
<div class="card-body">
<p>If you already have a blockchain wallet (like MetaMask, Polkadot.js, or TFConnect), you can connect it to use as your Digital ID.</p>
<button type="button" class="btn btn-primary" onclick="connectWallet()" data-bs-dismiss="modal">
<i class="bi bi-wallet2 me-1"></i> Connect Existing Wallet
</button>
</div> </div>
</div> <div class="mb-3">
<label for="email" class="form-label">Email address</label>
<div class="card"> <input type="email" class="form-control" id="email" name="email" value="{{ email | default(value='') }}" required>
<div class="card-header">Option 2: Create a New Digital ID</div>
<div class="card-body">
<p>If you don't have a wallet, we can help you create a new Digital ID:</p>
<ol>
<li>Click the button below to launch our secure Digital ID creator</li>
<li>Follow the instructions to generate your keys</li>
<li>Store your private key securely - it will never be stored on our servers</li>
<li>Your public key will be automatically added to your registration form</li>
</ol>
<a href="/digital-id/create" class="btn btn-success" target="_blank">
<i class="bi bi-plus-circle me-1"></i> Create New Digital ID
</a>
</div> </div>
</div> <div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
<div class="form-text">Password must be at least 8 characters long.</div>
</div>
<div class="mb-3">
<label for="password_confirmation" class="form-label">Confirm Password</label>
<input type="password" class="form-control" id="password_confirmation" name="password_confirmation" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Register</button>
</div>
</form>
</div> </div>
<div class="modal-footer"> <div class="card-footer text-center">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> <p class="mb-0">Already have an account? <a href="/login">Login</a></p>
</div> </div>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -5,29 +5,29 @@
{% block content %} {% block content %}
<div class="container-fluid"> <div class="container-fluid">
<h1>Create New Event</h1> <h1>Create New Event</h1>
{% if error %} {% if error %}
<div class="alert alert-danger" role="alert"> <div class="alert alert-danger" role="alert">
{{ error }} {{ error }}
</div> </div>
{% endif %} {% endif %}
<form action="/calendar/new" method="post"> <form action="/calendar/events" method="post">
<div class="mb-3"> <div class="mb-3">
<label for="title" class="form-label">Event Title</label> <label for="title" class="form-label">Event Title</label>
<input type="text" class="form-control" id="title" name="title" required> <input type="text" class="form-control" id="title" name="title" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="description" class="form-label">Description</label> <label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="3"></textarea> <textarea class="form-control" id="description" name="description" rows="3"></textarea>
</div> </div>
<div class="mb-3 form-check"> <div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="all_day" name="all_day"> <input type="checkbox" class="form-check-input" id="all_day" name="all_day">
<label class="form-check-label" for="all_day">All Day Event</label> <label class="form-check-label" for="all_day">All Day Event</label>
</div> </div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col"> <div class="col">
<label for="start_time" class="form-label">Start Time</label> <label for="start_time" class="form-label">Start Time</label>
@ -38,7 +38,14 @@
<input type="datetime-local" class="form-control" id="end_time" name="end_time" required> <input type="datetime-local" class="form-control" id="end_time" name="end_time" required>
</div> </div>
</div> </div>
<!-- Show selected date info when coming from calendar date click -->
<div id="selected-date-info" class="alert alert-info" style="display: none;">
<strong>Selected Date:</strong> <span id="selected-date-display"></span>
<br>
<small>The date is pre-selected. You can only modify the time portion.</small>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="color" class="form-label">Event Color</label> <label for="color" class="form-label">Event Color</label>
<select class="form-control" id="color" name="color"> <select class="form-control" id="color" name="color">
@ -50,7 +57,7 @@
<option value="#24C1E0">Cyan</option> <option value="#24C1E0">Cyan</option>
</select> </select>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<button type="submit" class="btn btn-primary">Create Event</button> <button type="submit" class="btn btn-primary">Create Event</button>
<a href="/calendar" class="btn btn-secondary">Cancel</a> <a href="/calendar" class="btn btn-secondary">Cancel</a>
@ -59,37 +66,106 @@
</div> </div>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
// Check if we came from a date click (URL parameter)
const urlParams = new URLSearchParams(window.location.search);
const selectedDate = urlParams.get('date');
if (selectedDate) {
// Show the selected date info
document.getElementById('selected-date-info').style.display = 'block';
document.getElementById('selected-date-display').textContent = new Date(selectedDate).toLocaleDateString();
// Pre-fill the date portion and restrict date changes
const startTimeInput = document.getElementById('start_time');
const endTimeInput = document.getElementById('end_time');
// Set default times (9 AM to 10 AM on the selected date)
const startDateTime = new Date(selectedDate + 'T09:00');
const endDateTime = new Date(selectedDate + 'T10:00');
// Format for datetime-local input (YYYY-MM-DDTHH:MM)
startTimeInput.value = startDateTime.toISOString().slice(0, 16);
endTimeInput.value = endDateTime.toISOString().slice(0, 16);
// Set minimum and maximum date to the selected date to prevent changing the date
const minDate = selectedDate + 'T00:00';
const maxDate = selectedDate + 'T23:59';
startTimeInput.min = minDate;
startTimeInput.max = maxDate;
endTimeInput.min = minDate;
endTimeInput.max = maxDate;
// Add event listeners to ensure end time is after start time
startTimeInput.addEventListener('change', function () {
const startTime = new Date(this.value);
const endTime = new Date(endTimeInput.value);
if (endTime <= startTime) {
// Set end time to 1 hour after start time
const newEndTime = new Date(startTime.getTime() + 60 * 60 * 1000);
endTimeInput.value = newEndTime.toISOString().slice(0, 16);
}
// Update end time minimum to be after start time
endTimeInput.min = this.value;
});
endTimeInput.addEventListener('change', function () {
const startTime = new Date(startTimeInput.value);
const endTime = new Date(this.value);
if (endTime <= startTime) {
// Reset to 1 hour after start time
const newEndTime = new Date(startTime.getTime() + 60 * 60 * 1000);
this.value = newEndTime.toISOString().slice(0, 16);
}
});
} else {
// No date selected, set default to current time
const now = new Date();
const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000);
document.getElementById('start_time').value = now.toISOString().slice(0, 16);
document.getElementById('end_time').value = oneHourLater.toISOString().slice(0, 16);
}
// Convert datetime-local inputs to RFC3339 format on form submission // Convert datetime-local inputs to RFC3339 format on form submission
document.querySelector('form').addEventListener('submit', function(e) { document.querySelector('form').addEventListener('submit', function (e) {
e.preventDefault(); e.preventDefault();
const startTime = document.getElementById('start_time').value; const startTime = document.getElementById('start_time').value;
const endTime = document.getElementById('end_time').value; const endTime = document.getElementById('end_time').value;
// Validate that end time is after start time
if (new Date(endTime) <= new Date(startTime)) {
alert('End time must be after start time');
return;
}
// Convert to RFC3339 format // Convert to RFC3339 format
const startRFC = new Date(startTime).toISOString(); const startRFC = new Date(startTime).toISOString();
const endRFC = new Date(endTime).toISOString(); const endRFC = new Date(endTime).toISOString();
// Create hidden inputs for the RFC3339 values // Create hidden inputs for the RFC3339 values
const startInput = document.createElement('input'); const startInput = document.createElement('input');
startInput.type = 'hidden'; startInput.type = 'hidden';
startInput.name = 'start_time'; startInput.name = 'start_time';
startInput.value = startRFC; startInput.value = startRFC;
const endInput = document.createElement('input'); const endInput = document.createElement('input');
endInput.type = 'hidden'; endInput.type = 'hidden';
endInput.name = 'end_time'; endInput.name = 'end_time';
endInput.value = endRFC; endInput.value = endRFC;
// Remove the original inputs // Remove the original inputs
document.getElementById('start_time').removeAttribute('name'); document.getElementById('start_time').removeAttribute('name');
document.getElementById('end_time').removeAttribute('name'); document.getElementById('end_time').removeAttribute('name');
// Add the hidden inputs to the form // Add the hidden inputs to the form
this.appendChild(startInput); this.appendChild(startInput);
this.appendChild(endInput); this.appendChild(endInput);
// Submit the form // Submit the form
this.submit(); this.submit();
}); });

View File

@ -0,0 +1,18 @@
<!-- Governance Page Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="h3 mb-1">{{ page_title }}</h1>
<p class="text-muted mb-0">{{ page_description }}</p>
</div>
{% if show_create_button %}
<div>
<a href="/governance/create" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create Proposal
</a>
</div>
{% endif %}
</div>
</div>
</div>

View File

@ -0,0 +1,32 @@
<!-- Governance Navigation Tabs -->
<div class="row mb-4">
<div class="col-12">
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link {% if active_tab == 'dashboard' %}active{% endif %}" href="/governance">
<i class="bi bi-house"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_tab == 'proposals' %}active{% endif %}" href="/governance/proposals">
<i class="bi bi-file-text"></i> All Proposals
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_tab == 'create' %}active{% endif %}" href="/governance/create">
<i class="bi bi-plus-circle"></i> Create Proposal
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_tab == 'my-votes' %}active{% endif %}" href="/governance/my-votes">
<i class="bi bi-check-circle"></i> My Votes
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if active_tab == 'activities' %}active{% endif %}" href="/governance/activities">
<i class="bi bi-activity"></i> All Activities
</a>
</li>
</ul>
</div>
</div>

View File

@ -0,0 +1,118 @@
{% extends "base.html" %}
{% block title %}All Governance Activities{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<!-- Header -->
{% include "governance/_header.html" %}
<!-- Navigation Tabs -->
{% include "governance/_tabs.html" %}
<!-- Activities List -->
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="bi bi-activity"></i> Governance Activity History
</h5>
</div>
<div class="card-body">
{% if activities %}
<div class="row">
<div class="col-12">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th width="50">Type</th>
<th>User</th>
<th>Action</th>
<th>Proposal</th>
<th width="150">Date</th>
</tr>
</thead>
<tbody>
{% for activity in activities %}
<tr>
<td>
<i class="{{ activity.icon }}"></i>
</td>
<td>
<strong>{{ activity.user }}</strong>
</td>
<td>
{{ activity.action }}
</td>
<td>
<a href="/governance/proposals/{{ activity.proposal_id }}"
class="text-decoration-none">
{{ activity.proposal_title }}
</a>
</td>
<td>
<small class="text-muted">
{{ activity.created_at | date(format="%Y-%m-%d %H:%M") }}
</small>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-activity display-1 text-muted"></i>
<h4 class="mt-3">No Activities Yet</h4>
<p class="text-muted">
Governance activities will appear here as users create proposals and cast votes.
</p>
<a href="/governance/create" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create First Proposal
</a>
</div>
{% endif %}
</div>
</div>
<!-- Activity Statistics -->
{% if activities %}
<div class="row mt-4">
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">{{ activities | length }}</h5>
<p class="card-text text-muted">Total Activities</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-activity text-primary"></i>
</h5>
<p class="card-text text-muted">Activity Timeline</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">
<i class="bi bi-people text-success"></i>
</h5>
<p class="card-text text-muted">Community Engagement</p>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -4,69 +4,74 @@
{% block content %} {% block content %}
<div class="container-fluid"> <div class="container-fluid">
<div class="row mb-4"> <!-- Header -->
<div class="col-12"> {% include "governance/_header.html" %}
<h1 class="display-5 mb-4">Create Governance Proposal</h1>
<p class="lead">Submit a new proposal for the community to vote on.</p>
</div>
</div>
<!-- Navigation Tabs --> <!-- Navigation Tabs -->
<div class="row mb-4"> {% include "governance/_tabs.html" %}
<!-- Info Alert -->
<div class="row">
<div class="col-12"> <div class="col-12">
<ul class="nav nav-tabs"> <div class="alert alert-info alert-dismissible fade show">
<li class="nav-item"> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<a class="nav-link" href="/governance">Dashboard</a> <h5><i class="bi bi-info-circle"></i> About Creating Proposals</h5>
</li> <p>Creating a proposal is an important step in our community governance process. Well-crafted proposals
<li class="nav-item"> clearly state the problem, solution, and implementation details. The community will review and vote
<a class="nav-link" href="/governance/proposals">All Proposals</a> on your proposal, so be thorough and thoughtful in your submission.</p>
</li> <div class="mt-2">
<li class="nav-item"> <a href="/governance/proposal-templates" class="btn btn-sm btn-outline-primary"><i
<a class="nav-link" href="/governance/my-votes">My Votes</a> class="bi bi-file-earmark-text"></i> Proposal Templates</a>
</li> </div>
<li class="nav-item"> </div>
<a class="nav-link active" href="/governance/create">Create Proposal</a>
</li>
</ul>
</div> </div>
</div> </div>
<!-- Proposal Form --> <!-- Proposal Form and Guidelines in Flex Layout -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-md-8 mx-auto"> <!-- Proposal Form Column -->
<div class="card"> <div class="col-lg-8">
<div class="card h-100">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">New Proposal</h5> <h5 class="mb-0">New Proposal</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<form action="/governance/create" method="post"> <form action="/governance/create" method="post" id="proposalForm" novalidate>
<div class="mb-3"> <div class="mb-3">
<label for="title" class="form-label">Title</label> <label for="title" class="form-label">Title</label>
<input type="text" class="form-control" id="title" name="title" required <input type="text" class="form-control" id="title" name="title" required minlength="5"
placeholder="Enter a clear, concise title for your proposal"> maxlength="100" placeholder="Enter a clear, concise title for your proposal">
<div class="invalid-feedback">Please provide a title (5-100 characters).</div>
<div class="form-text">Make it descriptive and specific</div> <div class="form-text">Make it descriptive and specific</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="description" class="form-label">Description</label> <label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="6" required <textarea class="form-control" id="description" name="description" rows="8" required
placeholder="Provide a detailed description of your proposal..."></textarea> minlength="50" maxlength="5000"
placeholder="Provide a detailed description of your proposal..."></textarea>
<div class="invalid-feedback">Please provide a detailed description (at least 50
characters).</div>
<div class="form-text">Explain the purpose, benefits, and implementation details</div> <div class="form-text">Explain the purpose, benefits, and implementation details</div>
</div> </div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-6"> <div class="col-md-6">
<label for="voting_start_date" class="form-label">Voting Start Date</label> <label for="voting_start_date" class="form-label">Voting Start Date</label>
<input type="date" class="form-control" id="voting_start_date" name="voting_start_date"> <input type="date" class="form-control" id="voting_start_date" name="voting_start_date">
<div class="invalid-feedback" id="start_date_feedback">Please select a valid start date.
</div>
<div class="form-text">When should voting begin?</div> <div class="form-text">When should voting begin?</div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label for="voting_end_date" class="form-label">Voting End Date</label> <label for="voting_end_date" class="form-label">Voting End Date</label>
<input type="date" class="form-control" id="voting_end_date" name="voting_end_date"> <input type="date" class="form-control" id="voting_end_date" name="voting_end_date">
<div class="invalid-feedback" id="end_date_feedback">End date must be after start date.
</div>
<div class="form-text">When should voting end?</div> <div class="form-text">When should voting end?</div>
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="draft" name="draft" value="true"> <input class="form-check-input" type="checkbox" id="draft" name="draft" value="true">
@ -75,7 +80,7 @@
</label> </label>
</div> </div>
</div> </div>
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">Submit Proposal</button> <button type="submit" class="btn btn-primary">Submit Proposal</button>
<a href="/governance" class="btn btn-outline-secondary">Cancel</a> <a href="/governance" class="btn btn-outline-secondary">Cancel</a>
@ -84,12 +89,10 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Guidelines Column -->
<!-- Guidelines Card --> <div class="col-lg-4">
<div class="row mb-4"> <div class="card bg-light h-100">
<div class="col-md-8 mx-auto">
<div class="card bg-light">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">Proposal Guidelines</h5> <h5 class="mb-0">Proposal Guidelines</h5>
</div> </div>
@ -116,4 +119,111 @@
</div> </div>
</div> </div>
</div> </div>
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('proposalForm');
const startDateInput = document.getElementById('voting_start_date');
const endDateInput = document.getElementById('voting_end_date');
const startDateFeedback = document.getElementById('start_date_feedback');
const endDateFeedback = document.getElementById('end_date_feedback');
// Set default dates
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const nextWeek = new Date(today);
nextWeek.setDate(nextWeek.getDate() + 7);
// Format dates for input fields
const formatDate = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// Set default values
startDateInput.value = formatDate(tomorrow);
endDateInput.value = formatDate(nextWeek);
// Validate dates when they change
function validateDates() {
const startDate = new Date(startDateInput.value);
const endDate = new Date(endDateInput.value);
const currentDate = new Date();
currentDate.setHours(0, 0, 0, 0); // Reset time to start of day
let startValid = true;
let endValid = true;
// Validate start date is not in the past
if (startDate < currentDate) {
startDateInput.classList.add('is-invalid');
startDateFeedback.textContent = 'Start date cannot be in the past.';
startValid = false;
} else {
startDateInput.classList.remove('is-invalid');
}
// Validate end date is after start date
if (endDate < startDate) {
endDateInput.classList.add('is-invalid');
endDateFeedback.textContent = 'End date must be after start date.';
endValid = false;
} else {
endDateInput.classList.remove('is-invalid');
}
return startValid && endValid;
}
// Validate on input
startDateInput.addEventListener('change', validateDates);
endDateInput.addEventListener('change', validateDates);
// Form submission validation
form.addEventListener('submit', function (event) {
let formValid = true;
// Validate required fields
const requiredFields = form.querySelectorAll('[required]');
requiredFields.forEach(field => {
if (!field.value.trim()) {
field.classList.add('is-invalid');
formValid = false;
} else {
field.classList.remove('is-invalid');
}
// Check minlength if specified
if (field.minLength && field.value.length < field.minLength) {
field.classList.add('is-invalid');
formValid = false;
}
});
// Validate dates
const datesValid = validateDates();
formValid = formValid && datesValid;
// If form is not valid, prevent submission
if (!formValid) {
event.preventDefault();
// Scroll to the first invalid element
const firstInvalid = form.querySelector('.is-invalid');
if (firstInvalid) {
firstInvalid.scrollIntoView({ behavior: 'smooth', block: 'center' });
firstInvalid.focus();
}
}
});
// Initial validation
validateDates();
});
</script>
{% endblock %} {% endblock %}
{% endblock %}

View File

@ -3,170 +3,192 @@
{% block title %}Governance Dashboard{% endblock %} {% block title %}Governance Dashboard{% endblock %}
{% block content %} {% block content %}
<!-- Navigation Tabs --> <!-- Header -->
<div class="row mb-3"> {% include "governance/_header.html" %}
<div class="col-12">
<ul class="nav nav-tabs"> <!-- Navigation Tabs -->
<li class="nav-item"> {% include "governance/_tabs.html" %}
<a class="nav-link active" href="/governance">Dashboard</a>
</li> <!-- Info Alert -->
<li class="nav-item"> <div class="row mb-2">
<a class="nav-link" href="/governance/proposals">All Proposals</a> <div class="col-12">
</li> <div class="alert alert-info alert-dismissible fade show">
<li class="nav-item"> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<a class="nav-link" href="/governance/my-votes">My Votes</a> <h5><i class="bi bi-info-circle"></i> About Governance</h5>
</li> <p>The governance system allows token holders to participate in decision-making processes by voting on
<li class="nav-item"> proposals that affect the platform's future. Create proposals, cast votes, and help shape the direction
<a class="nav-link" href="/governance/create">Create Proposal</a> of our decentralized ecosystem.</p>
</li> <div class="mt-2">
</ul> <a href="/governance/documentation" class="btn btn-sm btn-outline-primary"><i class="bi bi-book"></i>
Read Documentation</a>
</div>
</div> </div>
</div> </div>
</div>
<!-- Info Alert --> <!-- Dashboard Main Content -->
<div class="row mb-2"> <div class="row mb-3">
<div class="col-12"> <!-- Voting Pane for Nearest Deadline Proposal -->
<div class="alert alert-info alert-dismissible fade show"> <div class="col-lg-8 mb-4 mb-lg-0">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> {% if nearest_proposal is defined %}
<h5><i class="bi bi-info-circle"></i> About Governance</h5> <div class="card h-100">
<p>The governance system allows token holders to participate in decision-making processes by voting on proposals that affect the platform's future. Create proposals, cast votes, and help shape the direction of our decentralized ecosystem.</p> <div class="card-header d-flex justify-content-between align-items-center">
<div class="mt-2"> <h5 class="mb-0">Urgent: Voting Closes Soon</h5>
<a href="/governance/documentation" class="btn btn-sm btn-outline-primary"><i class="bi bi-book"></i> Read Documentation</a> <div>
<span class="badge bg-warning text-dark me-2">Ends: {{ nearest_proposal.vote_end_date |
date(format="%Y-%m-%d") }}</span>
<a href="/governance/proposals/{{ nearest_proposal.base_data.id }}"
class="btn btn-sm btn-outline-primary">View Full Proposal</a>
</div>
</div>
<div class="card-body">
<h4 class="card-title">{{ nearest_proposal.title }}</h4>
<h6 class="card-subtitle mb-3 text-muted">Proposed by {{ nearest_proposal.creator_name }}</h6>
<div class="mb-4">
<p>{{ nearest_proposal.description }}</p>
</div>
{% set yes_percent = 0 %}
{% set no_percent = 0 %}
{% set abstain_percent = 0 %}
{% set total_votes = 0 %}
{% if nearest_proposal_results is defined %}
{% if nearest_proposal_results.total_votes > 0 %}
{% set yes_percent = (nearest_proposal_results.yes_count * 100 / nearest_proposal_results.total_votes) |
int %}
{% set no_percent = (nearest_proposal_results.no_count * 100 / nearest_proposal_results.total_votes) |
int %}
{% set abstain_percent = (nearest_proposal_results.abstain_count * 100 /
nearest_proposal_results.total_votes) |
int %}
{% endif %}
{% set total_votes = nearest_proposal_results.total_votes %}
{% endif %}
<div class="progress mb-3" style="height: 25px;">
<div class="progress-bar bg-success" role="progressbar" style="width: {{ yes_percent }}%"
aria-valuenow="{{ yes_percent }}" aria-valuemin="0" aria-valuemax="100">{{ yes_percent }}% Yes
</div> </div>
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ no_percent }}%"
aria-valuenow="{{ no_percent }}" aria-valuemin="0" aria-valuemax="100">{{ no_percent }}% No
</div>
<div class="progress-bar bg-secondary" role="progressbar" style="width: {{ abstain_percent }}%"
aria-valuenow="{{ abstain_percent }}" aria-valuemin="0" aria-valuemax="100">{{ abstain_percent
}}% Abstain
</div>
</div>
<div class="d-flex justify-content-between text-muted small mb-4">
<span>{{ total_votes }} votes cast</span>
<span>Quorum: {% if total_votes >= 20 %}75% reached{% else %}Not reached{% endif %}</span>
</div>
<div class="mb-4">
<h5 class="mb-3">Cast Your Vote</h5>
<form action="/governance/proposals/{{ nearest_proposal.base_data.id }}/vote" method="post">
<div class="mb-3">
<input type="text" class="form-control" name="comment"
placeholder="Optional comment on your vote" aria-label="Vote comment">
</div>
<div class="d-flex justify-content-between">
<button type="submit" name="vote_type" value="Yes" class="btn btn-success">Vote Yes</button>
<button type="submit" name="vote_type" value="No" class="btn btn-danger">Vote No</button>
<button type="submit" name="vote_type" value="Abstain"
class="btn btn-secondary">Abstain</button>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>
{% else %}
<!-- Dashboard Main Content --> <div class="card h-100">
<div class="row mb-3"> <div class="card-body text-center py-5">
<!-- Voting Pane for Nearest Deadline Proposal --> <i class="bi bi-clipboard-check fs-1 text-muted mb-3"></i>
<div class="col-lg-8 mb-4 mb-lg-0"> <h5>No active proposals requiring votes</h5>
{% if nearest_proposal is defined %} <p class="text-muted">When new proposals are created, they will appear here for voting.</p>
<div class="card h-100"> <a href="/governance/create" class="btn btn-primary mt-3">Create Proposal</a>
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Urgent: Voting Closes Soon</h5>
<div>
<span class="badge bg-warning text-dark me-2">Ends: {{ nearest_proposal.voting_ends_at | date(format="%Y-%m-%d") }}</span>
<a href="/governance/proposals/{{ nearest_proposal.id }}" class="btn btn-sm btn-outline-primary">View Full Proposal</a>
</div>
</div>
<div class="card-body">
<h4 class="card-title">{{ nearest_proposal.title }}</h4>
<h6 class="card-subtitle mb-3 text-muted">Proposed by {{ nearest_proposal.creator_name }}</h6>
<div class="mb-4">
<p>{{ nearest_proposal.description }}</p>
</div>
<div class="progress mb-3" style="height: 25px;">
<div class="progress-bar bg-success" role="progressbar" style="width: 65%" aria-valuenow="65" aria-valuemin="0" aria-valuemax="100">65% Yes</div>
<div class="progress-bar bg-danger" role="progressbar" style="width: 35%" aria-valuenow="35" aria-valuemin="0" aria-valuemax="100">35% No</div>
</div>
<div class="d-flex justify-content-between text-muted small mb-4">
<span>26 votes cast</span>
<span>Quorum: 75% reached</span>
</div>
<div class="mb-4">
<h5 class="mb-3">Cast Your Vote</h5>
<form>
<div class="mb-3">
<input type="text" class="form-control" placeholder="Optional comment on your vote" aria-label="Vote comment">
</div>
<div class="d-flex justify-content-between">
<button type="submit" name="vote" value="yes" class="btn btn-success">Vote Yes</button>
<button type="submit" name="vote" value="no" class="btn btn-danger">Vote No</button>
<button type="submit" name="vote" value="abstain" class="btn btn-secondary">Abstain</button>
</div>
</form>
</div>
</div>
</div> </div>
{% else %}
<div class="card h-100">
<div class="card-body text-center py-5">
<i class="bi bi-clipboard-check fs-1 text-muted mb-3"></i>
<h5>No active proposals requiring votes</h5>
<p class="text-muted">When new proposals are created, they will appear here for voting.</p>
<a href="/governance/create" class="btn btn-primary mt-3">Create Proposal</a>
</div>
</div>
{% endif %}
</div> </div>
{% endif %}
<!-- Recent Activity Timeline --> </div>
<div class="col-lg-4">
<div class="card h-100"> <!-- Recent Activity Timeline -->
<div class="card-header"> <div class="col-lg-4">
<h5 class="mb-0">Recent Activity</h5> <div class="card h-100">
</div> <div class="card-header">
<div class="card-body p-0"> <h5 class="mb-0">Recent Activity</h5>
<div class="list-group list-group-flush"> </div>
{% for activity in recent_activity %} <div class="card-body p-0">
<div class="list-group-item border-start-0 border-end-0 py-3"> <div class="list-group list-group-flush">
<div class="d-flex"> {% for activity in recent_activity %}
<div class="me-3"> <div class="list-group-item border-start-0 border-end-0 py-3">
<i class="bi {{ activity.icon }} fs-4"></i> <div class="d-flex">
</div> <div class="me-3">
<div> <i class="bi {{ activity.icon }} fs-4"></i>
<div class="d-flex justify-content-between align-items-center"> </div>
<strong>{{ activity.user }}</strong> <div>
<small class="text-muted">{{ activity.timestamp | date(format="%H:%M") }}</small> <div class="d-flex justify-content-between align-items-center">
</div> <strong>{{ activity.user }}</strong>
<p class="mb-1">{{ activity.action }} on <a href="/governance/proposals/{{ activity.proposal_id }}">{{ activity.proposal_title }}</a></p> <small class="text-muted">{{ activity.created_at | date(format="%H:%M") }}</small>
{% if activity.type == "comment" and activity.comment is defined %}
<p class="mb-0 small text-muted">"{{ activity.comment }}"</p>
{% endif %}
</div> </div>
<p class="mb-1">{{ activity.action }} on <a
href="/governance/proposals/{{ activity.proposal_id }}">{{
activity.proposal_title }}</a></p>
{% if activity.type == "comment" and activity.comment is defined %}
<p class="mb-0 small text-muted">"{{ activity.comment }}"</p>
{% endif %}
</div> </div>
</div> </div>
{% endfor %}
</div> </div>
{% endfor %}
</div> </div>
<div class="card-footer text-center"> </div>
<a href="/governance/proposals" class="btn btn-sm btn-outline-info">View All Activity</a> <div class="card-footer text-center">
</div> <a href="/governance/activities" class="btn btn-sm btn-outline-info">View All Activities</a>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Recent Proposals Section --> <!-- Recent Proposals Section -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">Active Proposals (Ending Soon)</h5> <h5 class="mb-0">Active Proposals (Ending Soon)</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
{% set count = 0 %} {% set count = 0 %}
{% for proposal in proposals %} {% for proposal in proposals %}
{% if count < 3 %} {% if count < 3 %} <div class="col-md-4 mb-3">
<div class="col-md-4 mb-3"> <div class="card h-100">
<div class="card h-100"> <div class="card-body">
<div class="card-body"> <h5 class="card-title">{{ proposal.title }}</h5>
<h5 class="card-title">{{ proposal.title }}</h5> <h6 class="card-subtitle mb-2 text-muted">By {{ proposal.creator_name }}</h6>
<h6 class="card-subtitle mb-2 text-muted">By {{ proposal.creator_name }}</h6> <p class="card-text">{{ proposal.description | truncate(length=100) }}</p>
<p class="card-text">{{ proposal.description | truncate(length=100) }}</p> <div class="d-flex justify-content-between align-items-center">
<div class="d-flex justify-content-between align-items-center"> <span
<span class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% else %}bg-secondary{% endif %}"> class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% else %}bg-secondary{% endif %}">
{{ proposal.status }} {{ proposal.status }}
</span> </span>
<a href="/governance/proposals/{{ proposal.id }}" class="btn btn-sm btn-outline-primary">View Details</a> <a href="/governance/proposals/{{ proposal.base_data.id }}"
</div> class="btn btn-sm btn-outline-primary">View Details</a>
</div>
<div class="card-footer text-muted text-center">
<span>Voting ends: {{ proposal.voting_ends_at | date(format="%Y-%m-%d") }}</span>
</div>
</div>
</div> </div>
{% set count = count + 1 %} </div>
{% endif %} <div class="card-footer text-muted text-center">
{% endfor %} <span>Voting ends: {{ proposal.vote_end_date | date(format="%Y-%m-%d") }}</span>
</div> </div>
</div>
</div> </div>
{% set count = count + 1 %}
{% endif %}
{% endfor %}
</div> </div>
</div> </div>
</div> </div>
{% endblock %} </div>
</div>
{% endblock %}

View File

@ -3,133 +3,121 @@
{% block title %}My Votes - Governance Dashboard{% endblock %} {% block title %}My Votes - Governance Dashboard{% endblock %}
{% block content %} {% block content %}
<!-- Navigation Tabs --> <!-- Header -->
<div class="row mb-4"> {% include "governance/_header.html" %}
<div class="col-12">
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link" href="/governance">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/governance/proposals">All Proposals</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/governance/my-votes">My Votes</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/governance/create">Create Proposal</a>
</li>
</ul>
</div>
</div>
<!-- My Votes List --> <!-- Navigation Tabs -->
<div class="row mb-4"> {% include "governance/_tabs.html" %}
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">My Voting History</h5>
</div>
<div class="card-body">
{% if votes | length > 0 %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Proposal</th>
<th>My Vote</th>
<th>Status</th>
<th>Voted On</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for item in votes %}{% set vote = item.0 %}{% set proposal = item.1 %}
<tr>
<td>{{ proposal.title }}</td>
<td>
<span class="badge {% if vote.vote_type == 'Yes' %}bg-success{% elif vote.vote_type == 'No' %}bg-danger{% else %}bg-secondary{% endif %}">
{{ vote.vote_type }}
</span>
</td>
<td>
<span class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% elif proposal.status == 'Draft' %}bg-secondary{% else %}bg-warning{% endif %}">
{{ proposal.status }}
</span>
</td>
<td>{{ vote.created_at | date(format="%Y-%m-%d") }}</td>
<td>
<a href="/governance/proposals/{{ proposal.id }}" class="btn btn-sm btn-primary">View Proposal</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-clipboard-check fs-1 text-muted mb-3"></i>
<h5>You haven't voted on any proposals yet</h5>
<p class="text-muted">When you vote on proposals, they will appear here.</p>
<a href="/governance/proposals" class="btn btn-primary mt-3">Browse Proposals</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Voting Stats --> <!-- Info Alert -->
{% if votes | length > 0 %} <div class="row">
<div class="row mb-4"> <div class="col-12">
<div class="col-md-4 mb-3"> <div class="alert alert-info alert-dismissible fade show">
<div class="card text-white bg-success h-100"> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<div class="card-body text-center"> <h5><i class="bi bi-info-circle"></i> About Votes</h5>
<h5 class="card-title">Yes Votes</h5> <p>Voting is a fundamental right of all token holders in our governance system. Each vote carries weight
<p class="display-4"> proportional to your token holdings, ensuring fair representation. The voting statistics below show the
{% set yes_count = 0 %} community's collective decision-making across all proposals.</p>
{% for item in votes %}{% set vote = item.0 %}{% set proposal = item.1 %} <div class="mt-2">
{% if vote.vote_type == 'Yes' %} <a href="/governance/voting-guide" class="btn btn-sm btn-outline-primary"><i
{% set yes_count = yes_count + 1 %} class="bi bi-check2-square"></i> Voting Guide</a>
{% endif %}
{% endfor %}
{{ yes_count }}
</p>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card text-white bg-danger h-100">
<div class="card-body text-center">
<h5 class="card-title">No Votes</h5>
<p class="display-4">
{% set no_count = 0 %}
{% for item in votes %}{% set vote = item.0 %}{% set proposal = item.1 %}
{% if vote.vote_type == 'No' %}
{% set no_count = no_count + 1 %}
{% endif %}
{% endfor %}
{{ no_count }}
</p>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card text-white bg-secondary h-100">
<div class="card-body text-center">
<h5 class="card-title">Abstain Votes</h5>
<p class="display-4">
{% set abstain_count = 0 %}
{% for item in votes %}{% set vote = item.0 %}{% set proposal = item.1 %}
{% if vote.vote_type == 'Abstain' %}
{% set abstain_count = abstain_count + 1 %}
{% endif %}
{% endfor %}
{{ abstain_count }}
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
{% endif %} </div>
{% endblock %}
<!-- Voting Stats -->
<div class="row mb-4">
<div class="col-md-4 mb-3">
<div class="card text-white bg-success h-100">
<div class="card-body text-center">
<h5 class="card-title">Yes Votes</h5>
<p class="display-4">
{{ total_yes_votes }}
</p>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card text-white bg-danger h-100">
<div class="card-body text-center">
<h5 class="card-title">No Votes</h5>
<p class="display-4">
{{ total_no_votes }}
</p>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card text-white bg-secondary h-100">
<div class="card-body text-center">
<h5 class="card-title">Abstain Votes</h5>
<p class="display-4">
{{ total_abstain_votes }}
</p>
</div>
</div>
</div>
</div>
<!-- My Votes List -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">My Voting History</h5>
</div>
<div class="card-body">
{% if votes | length > 0 %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Proposal</th>
<th>My Vote</th>
<th>Status</th>
<th>Voted On</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for item in votes %}{% set vote = item.0 %}{% set proposal = item.1 %}
<tr>
<td>{{ proposal.title }}</td>
<td>
<span
class="badge {% if vote.vote_type == 'Yes' %}bg-success{% elif vote.vote_type == 'No' %}bg-danger{% else %}bg-secondary{% endif %}">
{{ vote.vote_type }}
</span>
</td>
<td>
<span
class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% elif proposal.status == 'Draft' %}bg-secondary{% else %}bg-warning{% endif %}">
{{ proposal.status }}
</span>
</td>
<td>{{ vote.created_at | date(format="%Y-%m-%d") }}</td>
<td>
<a href="/governance/proposals/{{ proposal.base_data.id }}"
class="btn btn-sm btn-primary">View Proposal</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-clipboard-check fs-1 text-muted mb-3"></i>
<h5>You haven't voted on any proposals yet</h5>
<p class="text-muted">When you vote on proposals, they will appear here.</p>
<a href="/governance/proposals" class="btn btn-primary mt-3">Browse Proposals</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -2,8 +2,45 @@
{% block title %}{{ proposal.title }} - Governance Proposal{% endblock %} {% block title %}{{ proposal.title }} - Governance Proposal{% endblock %}
{% block styles %}
<style>
.avatar-circle {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
.comment-text {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.comment-text:hover {
white-space: normal;
overflow: visible;
}
.progress {
border-radius: 10px;
overflow: hidden;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="container-fluid">
<!-- Header -->
{% include "governance/_header.html" %}
<!-- Navigation Tabs -->
{% include "governance/_tabs.html" %}
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
@ -30,160 +67,549 @@
<!-- Proposal Details --> <!-- Proposal Details -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-md-8"> <div class="col-lg-8">
<div class="card"> <div class="card h-100 shadow-sm">
<div class="card-header"> <div class="card-header bg-light">
<h4 class="mb-0">{{ proposal.title }}</h4> <h4 class="mb-0">{{ proposal.title }}</h4>
</div> </div>
<div class="card-body"> <div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<span class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% elif proposal.status == 'Draft' %}bg-secondary{% else %}bg-warning{% endif %} p-2"> <span
class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% elif proposal.status == 'Draft' %}bg-secondary{% else %}bg-warning{% endif %} p-2">
<i
class="bi {% if proposal.status == 'Active' %}bi-check-circle{% elif proposal.status == 'Approved' %}bi-trophy{% elif proposal.status == 'Rejected' %}bi-x-circle{% elif proposal.status == 'Draft' %}bi-pencil{% else %}bi-exclamation-circle{% endif %} me-1"></i>
{{ proposal.status }} {{ proposal.status }}
</span> </span>
<small class="text-muted">Created by {{ proposal.creator_name }} on {{ proposal.created_at | date(format="%Y-%m-%d") }}</small> <span class="text-muted"><i class="bi bi-person me-1"></i>Created by {{ proposal.creator_name
}}</span>
</div>
<div class="flex-grow-1">
<h5><i class="bi bi-file-text me-2"></i>Description</h5>
<div class="p-3 bg-light rounded mb-4">{{ proposal.description }}</div>
</div>
<div class="mt-auto">
<h5><i class="bi bi-calendar-event me-2"></i>Voting Period</h5>
<div class="d-flex justify-content-between align-items-center p-3 bg-light rounded">
{% if proposal.vote_start_date and proposal.vote_end_date %}
<div>
<div class="text-muted mb-1">Start Date</div>
<div class="fw-bold">{{ proposal.vote_start_date | date(format="%Y-%m-%d") }}</div>
</div>
<div class="text-center">
<i class="bi bi-arrow-right fs-4 text-muted"></i>
</div>
<div>
<div class="text-muted mb-1">End Date</div>
<div class="fw-bold">{{ proposal.vote_end_date | date(format="%Y-%m-%d") }}</div>
</div>
{% else %}
<div class="text-center w-100">Not set</div>
{% endif %}
</div>
</div> </div>
<h5>Description</h5>
<p class="mb-4">{{ proposal.description }}</p>
<h5>Voting Period</h5>
<p>
{% if proposal.voting_starts_at and proposal.voting_ends_at %}
<strong>Start:</strong> {{ proposal.voting_starts_at | date(format="%Y-%m-%d") }} <br>
<strong>End:</strong> {{ proposal.voting_ends_at | date(format="%Y-%m-%d") }}
{% else %}
Not set
{% endif %}
</p>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-4"> <div class="col-lg-4">
<div class="card mb-4"> <div class="card mb-4 shadow-sm h-100">
<div class="card-header"> <div class="card-header bg-primary text-white">
<h5 class="mb-0">Voting Results</h5> <h5 class="mb-0"><i class="bi bi-bar-chart-fill me-2"></i>Voting Dashboard</h5>
</div> </div>
<div class="card-body"> <div class="card-body d-flex flex-column">
<div class="mb-3"> <!-- Voting Results Section -->
<div class="mb-4">
<h6 class="border-bottom pb-2 mb-3">Results</h6>
{% set yes_percent = 0 %} {% set yes_percent = 0 %}
{% set no_percent = 0 %} {% set no_percent = 0 %}
{% set abstain_percent = 0 %} {% set abstain_percent = 0 %}
{% if results.total_votes > 0 %}
{% set yes_percent = (results.yes_count * 100 / results.total_votes) | int %}
{% set no_percent = (results.no_count * 100 / results.total_votes) | int %}
{% set abstain_percent = (results.abstain_count * 100 / results.total_votes) | int %}
{% endif %}
<p class="mb-1">Yes: {{ results.yes_count }} ({{ yes_percent }}%)</p>
<div class="progress mb-3">
<div class="progress-bar bg-success" role="progressbar" style="width: {{ yes_percent }}%"></div>
</div>
<p class="mb-1">No: {{ results.no_count }} ({{ no_percent }}%)</p>
<div class="progress mb-3">
<div class="progress-bar bg-danger" role="progressbar" style="width: {{ no_percent }}%"></div>
</div>
<p class="mb-1">Abstain: {{ results.abstain_count }} ({{ abstain_percent }}%)</p>
<div class="progress mb-3">
<div class="progress-bar bg-secondary" role="progressbar" style="width: {{ abstain_percent }}%"></div>
</div>
</div>
<p class="text-center"><strong>Total Votes:</strong> {{ results.total_votes }}</p>
</div>
</div>
<!-- Vote Form -->
{% if proposal.status == "Active" and user and user.id %}
<div class="card">
<div class="card-header">
<h5 class="mb-0">Cast Your Vote</h5>
</div>
<div class="card-body">
<form action="/governance/proposals/{{ proposal.id }}/vote" method="post">
<div class="mb-3">
<label class="form-label">Vote Type</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="vote_type" id="voteYes" value="Yes" checked>
<label class="form-check-label" for="voteYes">
Yes - I support this proposal
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="vote_type" id="voteNo" value="No">
<label class="form-check-label" for="voteNo">
No - I oppose this proposal
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="vote_type" id="voteAbstain" value="Abstain">
<label class="form-check-label" for="voteAbstain">
Abstain - I choose not to vote
</label>
</div>
</div>
<div class="mb-3">
<label for="comment" class="form-label">Comment (Optional)</label>
<textarea class="form-control" id="comment" name="comment" rows="3" placeholder="Explain your vote..."></textarea>
</div>
<button type="submit" class="btn btn-primary w-100">Submit Vote</button>
</form>
</div>
</div>
{% elif not user or not user.id %}
<div class="card">
<div class="card-body text-center">
<p>You must be logged in to vote.</p>
<a href="/login" class="btn btn-primary">Login to Vote</a>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Votes List --> {% if results.total_votes > 0 %}
<div class="row mb-4"> {% set yes_percent = (results.yes_count * 100 / results.total_votes) | int %}
<div class="col-12"> {% set no_percent = (results.no_count * 100 / results.total_votes) | int %}
<div class="card"> {% set abstain_percent = (results.abstain_count * 100 / results.total_votes) | int %}
<div class="card-header"> {% endif %}
<h5 class="mb-0">Votes ({{ votes | length }})</h5>
</div> <!-- Yes votes -->
<div class="card-body"> <div class="d-flex justify-content-between align-items-center mb-1">
{% if votes | length > 0 %} <span class="fw-bold text-success"><i class="bi bi-check-circle-fill me-1"></i> Yes</span>
<div class="table-responsive"> <span class="badge bg-success rounded-pill">{{ results.yes_count }}</span>
<table class="table"> </div>
<thead> <div class="progress mb-3" style="height: 12px;">
<tr> <div class="progress-bar bg-success" role="progressbar" style="width: {{ yes_percent }}%"
<th>Voter</th> aria-valuenow="{{ yes_percent }}" aria-valuemin="0" aria-valuemax="100"
<th>Vote</th> title="{{ yes_percent }}% of votes"></div>
<th>Comment</th> </div>
<th>Date</th>
</tr> <!-- No votes -->
</thead> <div class="d-flex justify-content-between align-items-center mb-1">
<tbody> <span class="fw-bold text-danger"><i class="bi bi-x-circle-fill me-1"></i> No</span>
{% for vote in votes %} <span class="badge bg-danger rounded-pill">{{ results.no_count }}</span>
<tr> </div>
<td>{{ vote.voter_name }}</td> <div class="progress mb-3" style="height: 12px;">
<td> <div class="progress-bar bg-danger" role="progressbar" style="width: {{ no_percent }}%"
<span class="badge {% if vote.vote_type == 'Yes' %}bg-success{% elif vote.vote_type == 'No' %}bg-danger{% else %}bg-secondary{% endif %}"> aria-valuenow="{{ no_percent }}" aria-valuemin="0" aria-valuemax="100"
{{ vote.vote_type }} title="{{ no_percent }}% of votes"></div>
</span> </div>
</td>
<td>{% if vote.comment %}{{ vote.comment }}{% else %}No comment{% endif %}</td> <!-- Abstain votes -->
<td>{{ vote.created_at | date(format="%Y-%m-%d %H:%M") }}</td> <div class="d-flex justify-content-between align-items-center mb-1">
</tr> <span class="fw-bold text-secondary"><i class="bi bi-dash-circle-fill me-1"></i>
{% endfor %} Abstain</span>
</tbody> <span class="badge bg-secondary rounded-pill">{{ results.abstain_count }}</span>
</table> </div>
<div class="progress mb-3" style="height: 12px;">
<div class="progress-bar bg-secondary" role="progressbar"
style="width: {{ abstain_percent }}%" aria-valuenow="{{ abstain_percent }}"
aria-valuemin="0" aria-valuemax="100" title="{{ abstain_percent }}% of votes"></div>
</div>
</div>
<div class="mt-auto">
<div class="d-flex justify-content-between align-items-center p-3 bg-light rounded">
<div class="text-center">
<h4 class="mb-0">{{ results.total_votes }}</h4>
<small class="text-muted">Total Votes</small>
</div>
{% if proposal.status == "Active" %}
<div class="text-center">
<div class="position-relative d-inline-block" style="width: 60px; height: 60px;">
<svg width="60" height="60">
<circle cx="30" cy="30" r="25" fill="none" stroke="#e9ecef" stroke-width="5">
</circle>
<circle cx="30" cy="30" r="25" fill="none" stroke="#0d6efd" stroke-width="5"
stroke-dasharray="157"
stroke-dashoffset="{{ 157 - (157 * yes_percent / 100) }}"
transform="rotate(-90 30 30)"></circle>
</svg>
<div
class="position-absolute top-50 start-50 translate-middle text-primary fw-bold">
{{ yes_percent }}%</div>
</div>
<small class="text-muted">Approval Rate</small>
</div>
{% endif %}
</div>
</div>
<!-- Vote Form Section -->
{% if proposal.status == "Active" and user and user.id %}
<div class="mt-auto">
<h6 class="border-bottom pb-2 mb-3"><i class="bi bi-check2-square me-2"></i>Cast Your Vote</h6>
<form action="/governance/proposals/{{ proposal.base_data.id }}/vote" method="post"
id="voteForm">
<div class="mb-3">
<div class="d-flex gap-2 mb-2">
<div class="form-check">
<input class="form-check-input" type="radio" name="vote_type" id="voteYes"
value="Yes" required>
<label class="form-check-label text-success" for="voteYes"><i
class="bi bi-check-circle-fill me-1"></i>Yes</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="vote_type" id="voteNo"
value="No">
<label class="form-check-label text-danger" for="voteNo"><i
class="bi bi-x-circle-fill me-1"></i>No</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="vote_type" id="voteAbstain"
value="Abstain">
<label class="form-check-label text-secondary" for="voteAbstain"><i
class="bi bi-dash-circle-fill me-1"></i>Abstain</label>
</div>
</div>
<textarea class="form-control" id="comment" name="comment" rows="2"
placeholder="Add your thoughts about this proposal (optional)..."></textarea>
</div>
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-send me-2"></i>Submit
Vote</button>
</form>
</div>
{% elif proposal.status != "Active" %}
<div class="mt-auto text-center p-3 bg-light rounded">
<i class="bi bi-info-circle fs-4 text-muted"></i>
<p class="mb-0 mt-2">Voting is {{ proposal.status | lower }} for this proposal</p>
</div>
{% elif not user or not user.id %}
<div class="mt-auto text-center p-3 bg-light rounded">
<i class="bi bi-person-lock fs-4 text-muted"></i>
<p class="mb-0 mt-2">You must be logged in to vote</p>
<a href="/login" class="btn btn-primary btn-sm mt-2">Login to Vote</a>
</div> </div>
{% else %}
<p class="text-center">No votes have been cast yet.</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
<!-- Votes List -->
<div class="row mt-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="bi bi-list-check me-2"></i>Votes</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th class="ps-3">Voter</th>
<th>Vote</th>
<th>Comment</th>
<th class="text-end pe-3">Date</th>
</tr>
</thead>
<tbody id="votesTableBody">
{% if votes | length == 0 %}
<tr>
<td colspan="4" class="text-center py-4">
<div class="py-3">
<i class="bi bi-inbox fs-1 text-muted"></i>
<p class="mt-2 mb-0">No votes have been cast yet</p>
</div>
</td>
</tr>
{% else %}
{% for vote in votes %}
<tr class="vote-row" data-vote-type="{{ vote.vote_type | lower }}">
<td class="ps-3">
<div class="d-flex align-items-center">
<div class="avatar-circle me-2 bg-primary text-white">
U
</div>
<span>{{ vote.voter_name }}</span>
</div>
</td>
<td>
<span
class="badge {% if vote.vote_type == 'Yes' %}bg-success{% elif vote.vote_type == 'No' %}bg-danger{% else %}bg-secondary{% endif %} rounded-pill px-3 py-2">
{% if vote.vote_type == 'Yes' %}
<i class="bi bi-check-circle-fill me-1"></i>
{% elif vote.vote_type == 'No' %}
<i class="bi bi-x-circle-fill me-1"></i>
{% else %}
<i class="bi bi-dash-circle-fill me-1"></i>
{% endif %}
{{ vote.vote_type }}
</span>
</td>
<td>
{% if vote.comment %}
<div class="comment-text">{{ vote.comment }}</div>
{% else %}
<span class="text-muted fst-italic">No comment provided</span>
{% endif %}
</td>
<td class="text-end pe-3">
<div class="d-flex flex-column align-items-end">
<span>{{ vote.created_at | date(format="%Y-%m-%d") }}</span>
<small class="text-muted">{{ vote.created_at | date(format="%H:%M")
}}</small>
</div>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
<!-- Pagination Controls -->
{% if votes | length > 10 %}
<div class="d-flex justify-content-between align-items-center p-3 border-top">
<div class="d-flex align-items-center">
<label class="me-2 text-muted small">Rows per page:</label>
<select id="rowsPerPage" class="form-select form-select-sm" style="width: auto;">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div>
<nav aria-label="Votes pagination">
<ul class="pagination pagination-sm mb-0" id="paginationControls">
<li class="page-item disabled" id="prevPage">
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
<li class="page-item active"><a class="page-link" href="#">1</a></li>
<li class="page-item"><a class="page-link" href="#">2</a></li>
<li class="page-item"><a class="page-link" href="#">3</a></li>
<li class="page-item" id="nextPage">
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
</div>
<div class="text-muted small" id="paginationInfo">
Showing <span id="startRow">1</span>-<span id="endRow">10</span> of <span
id="totalRows">{{ votes | length }}</span>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function () {
// Remove query parameters from URL without refreshing the page
if (window.location.search.includes('vote_success=true')) {
const newUrl = window.location.pathname;
window.history.replaceState({}, document.title, newUrl);
// Auto-hide the success alert after 5 seconds
const successAlert = document.querySelector('.alert-success');
if (successAlert) {
setTimeout(function () {
successAlert.classList.remove('show');
setTimeout(function () {
successAlert.remove();
}, 500);
}, 5000);
}
}
// Pagination functionality
const rowsPerPageSelect = document.getElementById('rowsPerPage');
const paginationControls = document.getElementById('paginationControls');
const votesTableBody = document.getElementById('votesTableBody');
const startRowElement = document.getElementById('startRow');
const endRowElement = document.getElementById('endRow');
const totalRowsElement = document.getElementById('totalRows');
const prevPageBtn = document.getElementById('prevPage');
const nextPageBtn = document.getElementById('nextPage');
let currentPage = 1;
let rowsPerPage = rowsPerPageSelect ? parseInt(rowsPerPageSelect.value) : 10;
// Function to update pagination display
function updatePagination() {
if (!paginationControls) return;
// Get all rows that match the current filter
const currentFilter = document.querySelector('[data-filter].active');
const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all';
// Get rows that match the current filter and search term
let filteredRows = Array.from(voteRows);
if (filterType !== 'all') {
filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType);
}
// Apply search filter if there's a search term
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
if (searchTerm) {
filteredRows = filteredRows.filter(row => {
const voterName = row.querySelector('td:first-child').textContent.toLowerCase();
const comment = row.querySelector('td:nth-child(3)').textContent.toLowerCase();
return voterName.includes(searchTerm) || comment.includes(searchTerm);
});
}
const totalRows = filteredRows.length;
// Calculate total pages
const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage));
// Ensure current page is valid
if (currentPage > totalPages) {
currentPage = totalPages;
}
// Update pagination controls
if (paginationControls) {
// Clear existing page links (except prev/next)
const pageLinks = paginationControls.querySelectorAll('li:not(#prevPage):not(#nextPage)');
pageLinks.forEach(link => link.remove());
// Add new page links
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
// Adjust if we're near the end
if (endPage - startPage + 1 < maxVisiblePages && startPage > 1) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
// Insert page links before the next button
const nextPageElement = document.getElementById('nextPage');
for (let i = startPage; i <= endPage; i++) {
const li = document.createElement('li');
li.className = `page-item ${i === currentPage ? 'active' : ''}`;
const a = document.createElement('a');
a.className = 'page-link';
a.href = '#';
a.textContent = i;
a.addEventListener('click', function (e) {
e.preventDefault();
currentPage = i;
updatePagination();
});
li.appendChild(a);
paginationControls.insertBefore(li, nextPageElement);
}
// Update prev/next buttons
prevPageBtn.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
nextPageBtn.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
}
// Show current page
showCurrentPage();
}
// Function to show current page
function showCurrentPage() {
if (!votesTableBody) return;
// Get all rows that match the current filter
const currentFilter = document.querySelector('[data-filter].active');
const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all';
// Get rows that match the current filter and search term
let filteredRows = Array.from(voteRows);
if (filterType !== 'all') {
filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType);
}
// Apply search filter if there's a search term
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
if (searchTerm) {
filteredRows = filteredRows.filter(row => {
const voterName = row.querySelector('td:first-child').textContent.toLowerCase();
const comment = row.querySelector('td:nth-child(3)').textContent.toLowerCase();
return voterName.includes(searchTerm) || comment.includes(searchTerm);
});
}
// Hide all rows first
voteRows.forEach(row => row.style.display = 'none');
// Calculate pagination
const totalRows = filteredRows.length;
const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage));
// Ensure current page is valid
if (currentPage > totalPages) {
currentPage = totalPages;
}
// Show only rows for current page
const start = (currentPage - 1) * rowsPerPage;
const end = start + rowsPerPage;
filteredRows.slice(start, end).forEach(row => row.style.display = '');
// Update pagination info
if (startRowElement && endRowElement && totalRowsElement) {
startRowElement.textContent = totalRows > 0 ? start + 1 : 0;
endRowElement.textContent = Math.min(end, totalRows);
totalRowsElement.textContent = totalRows;
}
}
// Event listeners for pagination
if (prevPageBtn) {
prevPageBtn.addEventListener('click', function (e) {
e.preventDefault();
if (currentPage > 1) {
currentPage--;
updatePagination();
}
});
}
if (nextPageBtn) {
nextPageBtn.addEventListener('click', function (e) {
e.preventDefault();
// Get all rows that match the current filter
const currentFilter = document.querySelector('[data-filter].active');
const filterType = currentFilter ? currentFilter.getAttribute('data-filter') : 'all';
// Get rows that match the current filter and search term
let filteredRows = Array.from(voteRows);
if (filterType !== 'all') {
filteredRows = filteredRows.filter(row => row.getAttribute('data-vote-type') === filterType);
}
// Apply search filter if there's a search term
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
if (searchTerm) {
filteredRows = filteredRows.filter(row => {
const voterName = row.querySelector('td:first-child').textContent.toLowerCase();
const comment = row.querySelector('td:nth-child(3)').textContent.toLowerCase();
return voterName.includes(searchTerm) || comment.includes(searchTerm);
});
}
const totalRows = filteredRows.length;
const totalPages = Math.max(1, Math.ceil(totalRows / rowsPerPage));
if (currentPage < totalPages) {
currentPage++;
updatePagination();
}
});
}
if (rowsPerPageSelect) {
rowsPerPageSelect.addEventListener('change', function () {
rowsPerPage = parseInt(this.value);
currentPage = 1; // Reset to first page
updatePagination();
});
}
// Initialize pagination (but don't interfere with filtering)
if (paginationControls) {
// Only initialize pagination if there are many votes
// The filtering will handle showing/hiding rows
console.log('Pagination controls available but not interfering with filtering');
}
// Initialize tooltips for all elements with title attributes
const tooltipElements = document.querySelectorAll('[title]');
if (tooltipElements.length > 0) {
[].slice.call(tooltipElements).map(function (el) {
return new bootstrap.Tooltip(el);
});
}
// Add debugging for vote form
const voteForm = document.getElementById('voteForm');
if (voteForm) {
console.log('Vote form found:', voteForm);
voteForm.addEventListener('submit', function (e) {
console.log('Vote form submitted');
const formData = new FormData(voteForm);
console.log('Form data:', Object.fromEntries(formData));
});
} else {
console.log('Vote form not found');
}
// Debug logging
console.log('Filter buttons found:', filterButtons.length);
console.log('Vote rows found:', voteRows.length);
console.log('Search input found:', searchInput ? 'Yes' : 'No');
});
</script>
{% endblock %}

View File

@ -3,128 +3,140 @@
{% block title %}Proposals - Governance Dashboard{% endblock %} {% block title %}Proposals - Governance Dashboard{% endblock %}
{% block content %} {% block content %}
<!-- Success message if present --> <!-- Header -->
{% if success %} {% include "governance/_header.html" %}
<div class="row mb-4">
<div class="col-12">
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ success }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
</div>
{% endif %}
<!-- Navigation Tabs --> <!-- Navigation Tabs -->
<div class="row mb-3"> {% include "governance/_tabs.html" %}
<div class="col-12">
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link" href="/governance">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/governance/proposals">All Proposals</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/governance/my-votes">My Votes</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/governance/create">Create Proposal</a>
</li>
</ul>
</div>
</div>
<!-- Success message if present -->
{% if success %}
<div class="row mb-4">
<div class="col-12"> <div class="col-12">
<div class="alert alert-info alert-dismissible fade show"> <div class="alert alert-success alert-dismissible fade show" role="alert">
{{ success }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<h5><i class="bi bi-info-circle"></i> About Proposals</h5>
<p>Proposals are formal requests for changes to the platform that require community approval. Each proposal includes a detailed description, implementation plan, and voting period. Browse the list below to see all active and past proposals.</p>
<div class="mt-2">
<a href="/governance/proposal-guidelines" class="btn btn-sm btn-outline-primary"><i class="bi bi-file-text"></i> Proposal Guidelines</a>
</div>
</div> </div>
</div> </div>
</div>
{% endif %}
<!-- Filter Controls --> <div class="col-12">
<div class="row mb-4"> <div class="alert alert-info alert-dismissible fade show">
<div class="col-12"> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<div class="card"> <h5><i class="bi bi-info-circle"></i> About Proposals</h5>
<div class="card-body"> <p>Proposals are formal requests for changes to the platform that require community approval. Each proposal
<form action="/governance/proposals" method="get" class="row g-3"> includes a detailed description, implementation plan, and voting period. Browse the list below to see all
<div class="col-md-4"> active and past proposals.</p>
<label for="status" class="form-label">Status</label> <div class="mt-2">
<select class="form-select" id="status" name="status"> <a href="/governance/proposal-guidelines" class="btn btn-sm btn-outline-primary"><i
<option value="">All Statuses</option> class="bi bi-file-text"></i> Proposal Guidelines</a>
<option value="Draft">Draft</option>
<option value="Active">Active</option>
<option value="Approved">Approved</option>
<option value="Rejected">Rejected</option>
<option value="Cancelled">Cancelled</option>
</select>
</div>
<div class="col-md-6">
<label for="search" class="form-label">Search</label>
<input type="text" class="form-control" id="search" name="search" placeholder="Search by title or description">
</div>
<div class="col-md-2 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">Filter</button>
</div>
</form>
</div>
</div>
</div> </div>
</div> </div>
</div>
<!-- Proposals List --> <!-- Filter Controls -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<div class="card"> <div class="card">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-body">
<h5 class="mb-0">All Proposals</h5> <form action="/governance/proposals" method="get" class="row g-3">
<a href="/governance/create" class="btn btn-sm btn-primary">Create New Proposal</a> <div class="col-md-4">
</div> <label for="status" class="form-label">Status</label>
<div class="card-body"> <select class="form-select" id="status" name="status">
<div class="table-responsive"> <option value="" {% if not status_filter or status_filter=="" %}selected{% endif %}>All
<table class="table table-hover"> Statuses</option>
<thead> <option value="Draft" {% if status_filter=="Draft" %}selected{% endif %}>Draft</option>
<tr> <option value="Active" {% if status_filter=="Active" %}selected{% endif %}>Active</option>
<th>Title</th> <option value="Approved" {% if status_filter=="Approved" %}selected{% endif %}>Approved
<th>Creator</th> </option>
<th>Status</th> <option value="Rejected" {% if status_filter=="Rejected" %}selected{% endif %}>Rejected
<th>Created</th> </option>
<th>Voting Period</th> <option value="Cancelled" {% if status_filter=="Cancelled" %}selected{% endif %}>Cancelled
<th>Actions</th> </option>
</tr> </select>
</thead>
<tbody>
{% for proposal in proposals %}
<tr>
<td>{{ proposal.title }}</td>
<td>{{ proposal.creator_name }}</td>
<td>
<span class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% elif proposal.status == 'Draft' %}bg-secondary{% else %}bg-warning{% endif %}">
{{ proposal.status }}
</span>
</td>
<td>{{ proposal.created_at | date(format="%Y-%m-%d") }}</td>
<td>
{% if proposal.voting_starts_at and proposal.voting_ends_at %}
{{ proposal.voting_starts_at | date(format="%Y-%m-%d") }} to {{ proposal.voting_ends_at | date(format="%Y-%m-%d") }}
{% else %}
Not set
{% endif %}
</td>
<td>
<a href="/governance/proposals/{{ proposal.id }}" class="btn btn-sm btn-primary">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
<div class="col-md-6">
<label for="search" class="form-label">Search</label>
<input type="text" class="form-control" id="search" name="search"
placeholder="Search by title or description"
value="{% if search_filter %}{{ search_filter }}{% endif %}">
</div>
<div class="col-md-2 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">Filter</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Proposals List -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">All Proposals</h5>
<a href="/governance/create" class="btn btn-sm btn-primary">Create New Proposal</a>
</div>
<div class="card-body">
{% if proposals and proposals|length > 0 %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Title</th>
<th>Creator</th>
<th>Status</th>
<th>Created</th>
<th>Voting Period</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for proposal in proposals %}
<tr>
<td>{{ proposal.title }}</td>
<td>{{ proposal.creator_name }}</td>
<td>
<span
class="badge {% if proposal.status == 'Active' %}bg-success{% elif proposal.status == 'Approved' %}bg-primary{% elif proposal.status == 'Rejected' %}bg-danger{% elif proposal.status == 'Draft' %}bg-secondary{% else %}bg-warning{% endif %}">
{{ proposal.status }}
</span>
</td>
<td>{{ proposal.created_at | date(format="%Y-%m-%d") }}</td>
<td>
{% if proposal.vote_start_date and proposal.vote_end_date %}
{{ proposal.vote_start_date | date(format="%Y-%m-%d") }} to {{
proposal.vote_end_date | date(format="%Y-%m-%d") }}
{% else %}
Not set
{% endif %}
</td>
<td>
<a href="/governance/proposals/{{ proposal.base_data.id }}"
class="btn btn-sm btn-primary">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="alert alert-info text-center py-5">
<i class="bi bi-info-circle fs-1 mb-3"></i>
<h5>No proposals found</h5>
{% if status_filter or search_filter %}
<p>No proposals match your current filter criteria. Try adjusting your filters or <a
href="/governance/proposals" class="alert-link">view all proposals</a>.</p>
{% else %}
<p>There are no proposals in the system yet.</p>
{% endif %}
<a href="/governance/create" class="btn btn-primary mt-3">Create New Proposal</a>
</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} </div>
{% endblock %}

2782
flowbroker/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +0,0 @@
[package]
name = "flowbroker"
version = "0.1.0"
edition = "2024"
[dependencies]
sigsocket = { path = "../sigsocket" } # Path relative to flowbroker directory
actix-web = "4.3.1"
actix-rt = "2.8.0"
actix-files = "0.6.2"
actix-web-actors = "4.2.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
env_logger = "0.10.0"
log = "0.4.0"
tera = "1.19.0"
tokio = { version = "1.28.0", features = ["full"] }
dotenv = "0.15.0"
hex = "0.4.3"
uuid = { version = "1.4", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } # For timestamps
rhai = "1.18.0"
serde_urlencoded = "0.7"
# Database models and ORM-like functionality
heromodels = { path = "../../db/heromodels" }
# Note: heromodels pulls in 'ourdb', 'heromodels_core', 'heromodels_derive'

Binary file not shown.

Binary file not shown.

View File

@ -1 +0,0 @@
148

View File

@ -1,690 +0,0 @@
use actix_files as fs;
use actix_web::{web, App, HttpResponse, HttpServer, Responder, Result as ActixResult};
use std::fs as std_fs;
use std::path::PathBuf;
use actix_web_actors::ws;
use serde::{Deserialize, Serialize};
use serde_urlencoded; // Added for from_str
use tera::{Tera, Context};
use std::sync::{Arc, Mutex, RwLock};
use sigsocket::service::SigSocketService;
use sigsocket::registry::ConnectionRegistry;
use log::{info, error};
use uuid::Uuid;
use rhai::{Engine, EvalAltResult, Position};
use serde_json::Value as JsonValue;
// use std::collections::HashMap; // Removed as no longer used
use heromodels; // Added for database models
use heromodels::db::hero::OurDB;
use heromodels::db::{Db, Collection}; // Import Db trait for .collection() and Collection trait for .set()/.get_all()
use heromodels::models::flowbroker_models::{Flow, FlowStep, SignatureRequirement}; // Import the models
use dotenv::dotenv;
use std::env;
// --- Flowbroker Specific Enums (to be used by application logic) ---
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub enum FlowStepStatus {
Pending, // Step created, not yet processed
InProgress, // Step is actively being processed (e.g., waiting for signatures)
Completed, // All requirements for this step are met
Failed, // Step failed (e.g., a signature requirement failed or timed out)
Skipped, // Step was skipped (e.g., due to conditional logic not yet implemented)
}
impl FlowStepStatus {
pub fn to_db_string(&self) -> String {
format!("{:?}", self)
}
pub fn from_db_string(s: &str) -> Result<Self, String> {
match s {
"Pending" => Ok(FlowStepStatus::Pending),
"InProgress" => Ok(FlowStepStatus::InProgress),
"Completed" => Ok(FlowStepStatus::Completed),
"Failed" => Ok(FlowStepStatus::Failed),
"Skipped" => Ok(FlowStepStatus::Skipped),
_ => Err(format!("Invalid FlowStepStatus string: {}", s)),
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub enum SignatureRequirementStatus {
Pending, // Not yet processed or sent for signing
SentToClient, // Sent to client via SigSocket, awaiting signature
Signed, // Successfully signed
Failed, // Signing failed (e.g., client rejected, timeout, error)
Error, // An internal error occurred processing this requirement
}
impl SignatureRequirementStatus {
pub fn to_db_string(&self) -> String {
format!("{:?}", self)
}
pub fn from_db_string(s: &str) -> Result<Self, String> {
match s {
"Pending" => Ok(SignatureRequirementStatus::Pending),
"SentToClient" => Ok(SignatureRequirementStatus::SentToClient),
"Signed" => Ok(SignatureRequirementStatus::Signed),
"Failed" => Ok(SignatureRequirementStatus::Failed),
"Error" => Ok(SignatureRequirementStatus::Error),
_ => Err(format!("Invalid SignatureRequirementStatus string: {}", s)),
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub enum FlowStatus {
Pending, // Flow created, no steps initiated
InProgress, // Flow started, steps are being processed
Completed, // All steps successfully signed
Failed, // A step failed or timed out
}
impl FlowStatus {
pub fn to_db_string(&self) -> String {
format!("{:?}", self)
}
pub fn from_db_string(s: &str) -> Result<Self, String> {
match s {
"Pending" => Ok(FlowStatus::Pending),
"InProgress" => Ok(FlowStatus::InProgress),
"Completed" => Ok(FlowStatus::Completed),
"Failed" => Ok(FlowStatus::Failed),
_ => Err(format!("Invalid FlowStatus string: {}", s)),
}
}
}
// NOTE: The old Flow, FlowStep, and SignatureRequirement structs previously here
// have been removed. Their definitions are now in the heromodels crate.
// --- AppState ---
pub struct AppState {
templates: Tera,
sigsocket_service: Arc<SigSocketService>,
db: Arc<OurDB>, // Using OurDB from heromodels
next_id_counter: Arc<Mutex<u32>>, // For generating temporary primary keys
}
// --- Form Deserialization (for new dynamic form) ---
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct RequirementRealFormData {
// The name attributes in HTML are like: steps[0][requirements][0][message]
pub message: String, // Made fields public for external construction in tests
pub public_key: String, // Made fields public
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct FlowStepFormData {
description: Option<String>, // If description field is optional and might not be present
requirements: Vec<RequirementRealFormData>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct CreateFlowRealFormData { // Renamed to avoid confusion with heromodels::Flow
flow_name: String,
steps: Vec<FlowStepFormData>,
}
#[derive(serde::Deserialize, Debug)]
pub struct RhaiScriptFormData {
rhai_script: String,
}
// --- Handlers ---
// Display list of flows
async fn list_flows(data: web::Data<AppState>) -> ActixResult<HttpResponse> {
let mut context = Context::new();
match data.db.collection::<Flow>() {
Ok(flow_collection) => {
match flow_collection.get_all() {
Ok(mut flows_vec) => {
// Sort by creation date, newest first
flows_vec.sort_by(|a, b| b.base_data.created_at.cmp(&a.base_data.created_at));
context.insert("flows", &flows_vec);
},
Err(e) => {
error!("Failed to retrieve flows from database: {:?}", e);
// Optionally, insert an empty vec or an error message for the template
context.insert("flows", &Vec::<Flow>::new());
context.insert("db_error", "Failed to load flows.");
}
}
},
Err(e) => {
error!("Failed to get flow collection from database: {}", e);
context.insert("flows", &Vec::<Flow>::new());
context.insert("db_error", "Database collection error.");
}
}
let rendered = data.templates.render("index.html", &context)
.map_err(|e| {
error!("Template error (index.html): {}", e);
actix_web::error::ErrorInternalServerError("Template error rendering index.html")
})?;
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
}
// Show form to create a new flow
#[derive(Serialize, Clone)] // Clone is for the context, Serialize for Tera
struct RhaiExampleScript {
name: String,
content: String,
}
async fn new_flow_form(data: web::Data<AppState>) -> impl Responder {
let mut context = Context::new();
let mut example_scripts = Vec::new();
let examples_path = PathBuf::from("templates/rhai_examples");
if examples_path.is_dir() {
match std_fs::read_dir(examples_path) {
Ok(entries) => {
for entry in entries {
if let Ok(entry) = entry {
let path = entry.path();
if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("rhai") {
match std_fs::read_to_string(&path) {
Ok(content) => {
let file_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("Unknown Script");
// Convert filename (e.g., simple_two_step) to a nicer name (e.g., Simple Two Step)
let name = file_stem.replace("_", " ")
.split_whitespace()
.map(|word| {
let mut c = word.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
})
.collect::<Vec<String>>().join(" ");
example_scripts.push(RhaiExampleScript { name, content });
}
Err(e) => {
error!("Failed to read Rhai example script {}: {}", path.display(), e);
}
}
}
}
}
}
Err(e) => {
error!("Failed to read rhai_examples directory: {}", e);
}
}
}
context.insert("example_scripts", &example_scripts);
info!("Rendering new flow form with {} examples from files.", example_scripts.len());
match data.templates.render("new_flow_form.html", &context) {
Ok(rendered) => HttpResponse::Ok().body(rendered),
Err(e) => {
error!("Template error in new_flow_form: {}", e);
HttpResponse::InternalServerError().body(format!("Template error: {}", e))
}
}
}
// Handle creation of a new flow
async fn create_flow(
data: web::Data<AppState>,
raw_form_data: String, // Changed to accept raw String
) -> impl Responder {
info!("Received raw form data for create_flow: {}", raw_form_data);
// Attempt to parse the raw form data
let form_parse_result: Result<CreateFlowRealFormData, serde_urlencoded::de::Error> = serde_urlencoded::from_str(&raw_form_data);
let form = match form_parse_result {
Ok(parsed_form_data) => {
info!("Successfully parsed form data: {:?}", parsed_form_data);
parsed_form_data // Use the successfully parsed data
}
Err(e) => {
error!("Failed to parse form data from string: {}. Raw data: {}", e, raw_form_data);
return HttpResponse::BadRequest().body(format!("Form parsing error: {}. Please check input and logs.", e));
}
};
// --- Logic starts here, using `form` which is now CreateFlowRealFormData ---
info!("Processing create_flow request for: {}", form.flow_name);
let db = &data.db;
let mut id_counter = match data.next_id_counter.lock() {
Ok(guard) => guard,
Err(poisoned) => {
error!("Mutex for next_id_counter was poisoned: {}. Recovering.", poisoned);
poisoned.into_inner() // Attempt to recover
}
};
// 1. Create and save the main Flow object
*id_counter += 1;
let flow_db_id = *id_counter;
let flow_uuid = Uuid::new_v4().to_string();
let flow_instance = Flow::new(
flow_db_id,
&flow_uuid,
&form.flow_name,
FlowStatus::Pending.to_db_string() // Use local enum's string representation
);
match db.collection::<Flow>() {
Ok(flow_collection) => {
if let Err(e) = flow_collection.set(&flow_instance) {
error!("Failed to save Flow (name: {}): {:?}. Aborting flow creation.", form.flow_name, e);
return HttpResponse::InternalServerError().body(format!("Failed to save main flow data: {:?}", e));
}
info!("Saved Flow object for '{}', UUID: {}, DB_ID: {}", flow_instance.name, flow_instance.flow_uuid, flow_instance.base_data.id);
}
Err(e) => {
error!("Failed to get Flow collection: {:?}. Aborting flow creation.", e);
return HttpResponse::InternalServerError().body(format!("Database error getting flow collection: {:?}", e));
}
}
// 2. Create and save FlowStep and SignatureRequirement objects
for (step_idx, step_form_data) in form.steps.into_iter().enumerate() {
*id_counter += 1;
let flow_step_db_id = *id_counter;
let mut flow_step_instance = FlowStep::new(
flow_step_db_id,
flow_instance.base_data.id, // Use ID from the saved Flow instance
step_idx as u32, // step_order
FlowStepStatus::Pending.to_db_string() // Use local enum's string representation
);
if let Some(desc) = step_form_data.description {
if !desc.is_empty() { // Only set if description is not empty
flow_step_instance = flow_step_instance.description(desc);
}
}
match db.collection::<FlowStep>() {
Ok(step_collection) => {
if let Err(e) = step_collection.set(&flow_step_instance) {
error!("Failed to save FlowStep (flow: {}, step_idx: {}): {:?}", flow_instance.name, step_idx, e);
return HttpResponse::InternalServerError().body(format!("Failed to save flow step: {:?}", e));
}
info!("Saved FlowStep {} for flow '{}', DB_ID: {}", step_idx + 1, flow_instance.name, flow_step_instance.base_data.id);
}
Err(e) => {
error!("Failed to get FlowStep collection: {:?}. Aborting.", e);
return HttpResponse::InternalServerError().body(format!("Database error getting step collection: {:?}", e));
}
}
for (req_idx, req_form_data) in step_form_data.requirements.into_iter().enumerate() {
*id_counter += 1;
let sig_req_db_id = *id_counter;
let sig_req_instance = SignatureRequirement::new(
sig_req_db_id,
flow_step_instance.base_data.id, // Use ID from the saved FlowStep instance
&req_form_data.public_key,
&req_form_data.message,
SignatureRequirementStatus::Pending.to_db_string() // Use local enum's string representation
);
match db.collection::<SignatureRequirement>() {
Ok(req_collection) => {
if let Err(e) = req_collection.set(&sig_req_instance) {
error!("Failed to save SignatureRequirement (flow: {}, step: {}, req_idx: {}): {:?}", flow_instance.name, step_idx, req_idx, e);
return HttpResponse::InternalServerError().body(format!("Failed to save signature requirement: {:?}", e));
}
info!(
"Saved SignatureRequirement {} for step {} of flow '{}', DB_ID: {}",
req_idx + 1, step_idx + 1, flow_instance.name, sig_req_instance.base_data.id
);
}
Err(e) => {
error!("Failed to get SignatureRequirement collection: {:?}. Aborting.", e);
return HttpResponse::InternalServerError().body(format!("Database error getting requirement collection: {:?}", e));
}
}
}
}
info!("Finished processing all steps for flow '{}', UUID: {}", flow_instance.name, flow_instance.flow_uuid);
HttpResponse::SeeOther()
.append_header((actix_web::http::header::LOCATION, "/"))
.finish()
}
// --- Rhai-Callable Helper Functions ---
fn rhai_create_flow_entry(
db_arc: Arc<OurDB>,
id_counter_arc: Arc<Mutex<u32>>,
name: String,
) -> Result<u32, Box<rhai::EvalAltResult>> {
info!("Rhai: Attempting to create flow entry with name: {}", name);
let mut id_counter = match id_counter_arc.lock() {
Ok(guard) => guard,
Err(poisoned) => {
let err_msg = format!("Rhai: Mutex for next_id_counter was poisoned: {}", poisoned);
error!("{}", err_msg);
return Err(Box::new(rhai::EvalAltResult::ErrorRuntime(err_msg.into(), Position::NONE)));
}
};
*id_counter += 1;
let flow_db_id = *id_counter;
let flow_uuid = Uuid::new_v4().to_string();
let flow_instance = Flow::new(
flow_db_id,
&flow_uuid,
&name,
FlowStatus::Pending.to_db_string(),
);
match db_arc.collection::<Flow>() {
Ok(flow_collection) => {
if let Err(e) = flow_collection.set(&flow_instance) {
let err_msg = format!("Rhai: Failed to save Flow (name: {}): {:?}", name, e);
error!("{}", err_msg);
return Err(Box::new(rhai::EvalAltResult::ErrorRuntime(err_msg.into(), Position::NONE)));
}
info!("Rhai: Saved Flow object for '{}', UUID: {}, DB_ID: {}", flow_instance.name, flow_instance.flow_uuid, flow_instance.base_data.id);
Ok(flow_instance.base_data.id)
}
Err(e) => {
let err_msg = format!("Rhai: Failed to get Flow collection: {:?}", e);
error!("{}", err_msg);
return Err(Box::new(rhai::EvalAltResult::ErrorRuntime(err_msg.into(), Position::NONE)));
}
}
}
fn rhai_add_step_entry(
db_arc: Arc<OurDB>,
id_counter_arc: Arc<Mutex<u32>>,
flow_db_id: u32, // ID of the parent flow
description: String,
order: u32,
) -> Result<u32, Box<rhai::EvalAltResult>> {
info!(
"Rhai: Adding step to flow ID {}, order {}, description: '{}'",
flow_db_id, order, description
);
let mut id_counter = match id_counter_arc.lock() {
Ok(guard) => guard,
Err(poisoned) => {
let err_msg = format!("Rhai: Mutex for next_id_counter was poisoned: {}", poisoned);
error!("{}", err_msg);
return Err(Box::new(rhai::EvalAltResult::ErrorRuntime(err_msg.into(), Position::NONE)));
}
};
*id_counter += 1;
let flow_step_db_id = *id_counter;
let mut flow_step_instance = FlowStep::new(
flow_step_db_id,
flow_db_id,
order,
FlowStepStatus::Pending.to_db_string(),
);
if !description.is_empty() {
flow_step_instance = flow_step_instance.description(description);
}
match db_arc.collection::<FlowStep>() {
Ok(step_collection) => {
if let Err(e) = step_collection.set(&flow_step_instance) {
let err_msg = format!(
"Rhai: Failed to save FlowStep (flow_id: {}, order: {}): {:?}",
flow_db_id, order, e
);
error!("{}", err_msg);
return Err(Box::new(rhai::EvalAltResult::ErrorRuntime(err_msg.into(), Position::NONE)));
}
info!(
"Rhai: Saved FlowStep for flow_id {}, order {}, DB_ID: {}",
flow_db_id, order, flow_step_instance.base_data.id
);
Ok(flow_step_instance.base_data.id)
}
Err(e) => {
let err_msg = format!("Rhai: Failed to get FlowStep collection: {:?}", e);
error!("{}", err_msg);
return Err(Box::new(rhai::EvalAltResult::ErrorRuntime(err_msg.into(), Position::NONE)));
}
}
}
fn rhai_add_requirement_entry(
db_arc: Arc<OurDB>,
id_counter_arc: Arc<Mutex<u32>>,
step_db_id: u32, // ID of the parent step
public_key: String,
message: String,
) -> Result<u32, Box<rhai::EvalAltResult>> {
info!(
"Rhai: Adding requirement to step ID {}, pk: '{}', msg: '{}'",
step_db_id, public_key, message
);
let mut id_counter = match id_counter_arc.lock() {
Ok(guard) => guard,
Err(poisoned) => {
let err_msg = format!("Rhai: Mutex for next_id_counter was poisoned: {}", poisoned);
error!("{}", err_msg);
return Err(Box::new(rhai::EvalAltResult::ErrorRuntime(err_msg.into(), Position::NONE)));
}
};
*id_counter += 1;
let sig_req_db_id = *id_counter;
let sig_req_instance = SignatureRequirement::new(
sig_req_db_id,
step_db_id,
&public_key,
&message,
SignatureRequirementStatus::Pending.to_db_string(),
);
match db_arc.collection::<SignatureRequirement>() {
Ok(req_collection) => {
if let Err(e) = req_collection.set(&sig_req_instance) {
let err_msg = format!(
"Rhai: Failed to save SigRequirement (step_id: {}): {:?}",
step_db_id, e
);
error!("{}", err_msg);
return Err(Box::new(rhai::EvalAltResult::ErrorRuntime(err_msg.into(), Position::NONE)));
}
info!(
"Rhai: Saved SigRequirement for step_id {}, DB_ID: {}",
step_db_id, sig_req_instance.base_data.id
);
Ok(sig_req_instance.base_data.id)
}
Err(e) => {
let err_msg = format!("Rhai: Failed to get SigRequirement collection: {:?}", e);
error!("{}", err_msg);
return Err(Box::new(rhai::EvalAltResult::ErrorRuntime(err_msg.into(), Position::NONE)));
}
}
}
// Handle creation of a new flow from a Rhai script
async fn create_flow_from_script(
data: web::Data<AppState>,
form: web::Form<RhaiScriptFormData>,
) -> impl Responder {
info!("Received Rhai script for flow creation:\n{}", form.rhai_script);
let mut engine = Engine::new();
// Clone Arcs for capturing in closures
let db_clone_for_flow = data.db.clone();
let id_clone_for_flow = data.next_id_counter.clone();
let db_clone_for_step = data.db.clone();
let id_clone_for_step = data.next_id_counter.clone();
let db_clone_for_req = data.db.clone();
let id_clone_for_req = data.next_id_counter.clone();
engine
.register_fn("create_flow", move |name: String| {
crate::rhai_create_flow_entry(db_clone_for_flow.clone(), id_clone_for_flow.clone(), name)
})
.register_fn("add_step", move |flow_id: u32, desc: String, order: i64| {
if order < 0 || order > u32::MAX as i64 {
return Err(Box::new(EvalAltResult::ErrorRuntime(format!("Order {} is out of range for u32", order).into(), Position::NONE)));
}
crate::rhai_add_step_entry(db_clone_for_step.clone(), id_clone_for_step.clone(), flow_id, desc, order as u32)
})
.register_fn("add_requirement", move |step_id: u32, pk: String, msg: String| {
crate::rhai_add_requirement_entry(db_clone_for_req.clone(), id_clone_for_req.clone(), step_id, pk, msg)
});
match engine.eval::<()>(&form.rhai_script) { // Expecting () as successful script execution doesn't need to return a value to Rust here.
Ok(_) => {
info!("Rhai script executed successfully.");
HttpResponse::SeeOther()
.append_header((actix_web::http::header::LOCATION, "/"))
.finish()
}
Err(e) => {
error!("Rhai script execution failed: {}", e.to_string());
HttpResponse::BadRequest().body(format!("Rhai script error: {}\n\nYour script was:\n{}", e.to_string(), form.rhai_script))
}
}
}
// Placeholder for SigSocket WebSocket handler
async fn websocket_handler(
req: actix_web::HttpRequest,
stream: actix_web::web::Payload,
service: web::Data<Arc<SigSocketService>>,
) -> ActixResult<HttpResponse> {
info!("WebSocket connection attempt");
let handler = service.create_websocket_handler();
ws::start(handler, &req, stream)
}
// --- Extracted Helper Functions for App Setup and Configuration ---
/// Sets up the shared application data (AppState).
/// Allows overriding the database path for testing purposes.
pub async fn setup_app_data(db_path_override: Option<String>) -> Result<web::Data<AppState>, std::io::Error> {
// Initialize templates
let tera = match Tera::new("templates/**/*") {
Ok(t) => t,
Err(e) => {
error!("Critical: Tera template parsing error(s): {}", e);
// Convert tera::Error to std::io::Error
return Err(std::io::Error::new(std::io::ErrorKind::Other, format!("Tera init error: {}", e)));
}
};
// Initialize SigSocket registry and service
let registry = Arc::new(RwLock::new(ConnectionRegistry::new()));
let sigsocket_service = Arc::new(SigSocketService::new(registry.clone()));
// Load environment variables from .env file
dotenv().ok();
// Initialize Database
let database_path = db_path_override.unwrap_or_else(||
env::var("DATABASE_PATH").unwrap_or_else(|_|
{
info!("DATABASE_PATH not set, defaulting to ./flowbroker_db");
"./flowbroker_db".to_string()
})
);
let db = match OurDB::new(&database_path, true) { // true for create_if_missing
Ok(db_instance) => Arc::new(db_instance),
Err(e) => {
error!("Failed to initialize database at '{}': {}. Please ensure the path is writable.", database_path, e);
// Convert heromodels::Error to std::io::Error (assuming Error impls std::error::Error)
return Err(std::io::Error::new(std::io::ErrorKind::Other, format!("DB init error: {}", e)));
}
};
info!("Database initialized at: {}", database_path);
// Initialize ID counter for temporary primary keys
let next_id_counter = Arc::new(Mutex::new(0_u32));
// TODO: Replace this with a robust primary key generation strategy from the database itself if possible.
// Create shared application state
Ok(web::Data::new(AppState {
templates: tera,
sigsocket_service: sigsocket_service.clone(), // Clone for AppState
db,
next_id_counter,
}))
}
/// Configures the application routes.
pub fn configure_app_routes(cfg: &mut web::ServiceConfig) {
// Note: AppState should be added via .app_data() before calling this configure function.
// The websocket_handler specifically needs web::Data<Arc<SigSocketService>>.
// The main HttpServer setup will add AppState (which includes an Arc<SigSocketService>)
// and also the specific web::Data<Arc<SigSocketService>> for handlers like websocket_handler that expect it directly.
cfg.route("/", web::get().to(list_flows))
.service(
web::scope("/flows") // Group flow-related routes under /flows
// .route("", web::get().to(list_flows)) // If you want /flows to also list flows
.route("/new", web::get().to(new_flow_form))
.route("/create", web::post().to(create_flow))
.route("/create_script", web::post().to(create_flow_from_script)) // Moved inside /flows scope
)
.service(web::resource("/ws/").route(web::get().to(websocket_handler)))
.service(fs::Files::new("/static", "./static").show_files_listing()); // Static files
}
// --- Main Function ---
#[actix_web::main]
async fn main() -> std::io::Result<()> {
env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));
let app_data = match setup_app_data(None).await {
Ok(data) => data,
Err(e) => {
error!("Failed to setup application data: {}", e);
std::process::exit(1);
}
};
// The AppState (app_data) already contains an Arc<SigSocketService>.
// Handlers like websocket_handler that take web::Data<Arc<SigSocketService>> directly
// will be able to access it if AppState is correctly registered and the handler signature matches.
// Alternatively, if a handler needs *only* the SigSocketService, it can be added separately.
// For the websocket_handler as defined (taking web::Data<Arc<SigSocketService>>),
// it needs this specific type registered with app_data.
let sigsocket_service_for_ws_handler_data = web::Data::new(app_data.sigsocket_service.clone());
info!("Flowbroker server starting on http://127.0.0.1:8081");
info!("SigSocket WebSocket endpoint available at ws://127.0.0.1:8081/ws");
HttpServer::new(move || {
App::new()
.app_data(app_data.clone()) // Main app state (includes SigSocketService)
.app_data(sigsocket_service_for_ws_handler_data.clone()) // Specifically for handlers expecting web::Data<Arc<SigSocketService>>
.configure(configure_app_routes)
})
.bind("127.0.0.1:8081")? // Using a different port for now
.run()
.await
}

View File

@ -1,34 +0,0 @@
#!/bin/zsh
FORCE_KILL=false
# Parse command line options
while getopts ":f" opt; do
case ${opt} in
f )
FORCE_KILL=true
;;
\? )
echo "Usage: cmd [-f]"
exit 1
;;
esac
done
if [ "$FORCE_KILL" = true ] ; then
echo "Attempting to kill process on port 8081..."
# Get PID of process using port 8081 and kill it
# -t option for lsof outputs only the PID
# xargs -r ensures kill is only run if lsof finds a PID
lsof -t -i:8081 | xargs -r kill -9
if [ $? -eq 0 ]; then
echo "Process(es) on port 8081 killed."
else
echo "No process found on port 8081 or failed to kill."
fi
# Give a moment for the port to be released
sleep 1
fi
echo "Starting Flowbroker server..."
cargo run

View File

@ -1,127 +0,0 @@
body {
font-family: sans-serif;
margin: 20px;
line-height: 1.6;
}
h1, h2 {
color: #333;
}
a {
color: #007bff;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
form div {
margin-bottom: 10px;
}
label {
display: block;
margin-bottom: 5px;
}
input[type="text"], textarea {
width: 100%;
padding: 8px;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
background-color: #007bff;
color: white;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #0056b3;
}
hr {
margin: 20px 0;
}
#flows-list ul {
list-style-type: none;
padding: 0;
}
#flows-list li {
border: 1px solid #eee;
padding: 10px;
margin-bottom: 10px;
border-radius: 4px;
}
/* Styles for dynamic form elements from create_flow.html */
.step, .requirement {
border: 1px solid #ddd;
padding: 15px; /* Increased padding */
margin-bottom: 15px;
border-radius: 4px;
background-color: #f9f9f9;
}
.step h3, .step h4, .requirement h5 {
margin-top: 0;
color: #555; /* Slightly softer color */
}
.step .requirementsContainer {
margin-left: 20px;
border-left: 3px solid #007bff; /* Thicker border */
padding-left: 20px; /* Increased padding */
margin-top: 10px;
margin-bottom: 10px;
}
button.removeStepBtn, button.removeRequirementBtn {
background-color: #dc3545;
color: white;
padding: 5px 10px; /* Adjusted padding */
border: none;
border-radius: 4px;
cursor: pointer;
margin-top: 10px; /* Increased margin */
float: right; /* Align to the right */
}
button.removeStepBtn:hover, button.removeRequirementBtn:hover {
background-color: #c82333;
}
/* Clearfix for floated remove buttons */
.step::after, .requirement::after {
content: "";
clear: both;
display: table;
}
.addBtn { /* Style for Add Step / Add Requirement buttons */
background-color: #28a745;
color: white;
padding: 8px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
margin-top: 10px;
margin-bottom: 10px;
}
.addBtn:hover {
background-color: #218838;
}
/* General styling for form elements within steps/requirements for consistency */
.step input[type="text"], .step textarea,
.requirement input[type="text"], .requirement textarea {
margin-bottom: 8px; /* Add some space below inputs */
}

View File

@ -1,187 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Flowbroker - Create Flow</title>
<link rel="stylesheet" href="/static/style.css">
<style>
.step, .requirement {
border: 1px solid #ddd;
padding: 10px;
margin-bottom: 15px;
border-radius: 4px;
background-color: #f9f9f9;
}
.step h3, .step h4, .requirement h5 {
margin-top: 0;
}
.step .requirementsContainer {
margin-left: 20px;
border-left: 2px solid #007bff;
padding-left: 15px;
}
button.removeStepBtn, button.removeRequirementBtn {
background-color: #dc3545;
margin-top: 5px;
}
button.removeStepBtn:hover, button.removeRequirementBtn:hover {
background-color: #c82333;
}
</style>
</head>
<body>
<h1>Create New Flow</h1>
<form id="createFlowForm" action="/flows" method="post">
<div>
<label for="flow_name">Flow Name:</label>
<input type="text" id="flow_name" name="flow_name" required>
</div>
<hr>
<div id="stepsContainer">
<!-- Steps will be added here by JavaScript -->
</div>
<button type="button" id="addStepBtn" class="addBtn">Add Step</button>
<hr>
<button type="submit">Create Flow</button>
</form>
<p><a href="/">Back to Flows List</a></p>
<!-- Template for a new step -->
<template id="stepTemplate">
<div class="step" data-step-index="">
<h3>Step <span class="step-number"></span></h3>
<button type="button" class="removeStepBtn">Remove This Step</button>
<div>
<label>Step Description (Optional):</label>
<input type="text" name="steps[X].description" class="step-description">
</div>
<h4>Signature Requirements for Step <span class="step-number"></span></h4>
<div class="requirementsContainer" data-step-index="">
<!-- Requirements will be added here -->
</div>
<button type="button" class="addRequirementBtn addBtn" data-step-index="">Add Signature Requirement</button>
</div>
</template>
<!-- Template for a new signature requirement -->
<template id="requirementTemplate">
<div class="requirement" data-req-index="">
<h5>Requirement <span class="req-number"></span></h5>
<button type="button" class="removeRequirementBtn">Remove Requirement</button>
<div>
<label>Message to Sign:</label>
<textarea name="steps[X].requirements[Y].message" rows="2" required class="req-message"></textarea>
</div>
<div>
<label>Required Public Key:</label>
<input type="text" name="steps[X].requirements[Y].public_key" required class="req-pubkey">
</div>
</div>
</template>
<script>
document.addEventListener('DOMContentLoaded', () => {
const stepsContainer = document.getElementById('stepsContainer');
const addStepBtn = document.getElementById('addStepBtn');
const stepTemplate = document.getElementById('stepTemplate');
const requirementTemplate = document.getElementById('requirementTemplate');
const form = document.getElementById('createFlowForm');
const updateIndices = () => {
const steps = stepsContainer.querySelectorAll('.step');
steps.forEach((step, stepIdx) => {
// Update step-level attributes and text
step.dataset.stepIndex = stepIdx;
step.querySelector('.step-number').textContent = stepIdx + 1;
step.querySelector('.step-description').name = `steps[${stepIdx}].description`;
const addReqBtn = step.querySelector('.addRequirementBtn');
if (addReqBtn) addReqBtn.dataset.stepIndex = stepIdx;
const requirements = step.querySelectorAll('.requirementsContainer .requirement');
requirements.forEach((req, reqIdx) => {
// Update requirement-level attributes and text
req.dataset.reqIndex = reqIdx;
req.querySelector('.req-number').textContent = reqIdx + 1;
req.querySelector('.req-message').name = `steps[${stepIdx}].requirements[${reqIdx}].message`;
req.querySelector('.req-pubkey').name = `steps[${stepIdx}].requirements[${reqIdx}].public_key`;
});
});
};
const addRequirement = (currentStepElement, stepIndex) => {
const requirementsContainer = currentStepElement.querySelector('.requirementsContainer');
const reqFragment = requirementTemplate.content.cloneNode(true);
const newRequirement = reqFragment.querySelector('.requirement');
requirementsContainer.appendChild(newRequirement);
updateIndices(); // Update all indices after adding
};
const addStep = () => {
const stepFragment = stepTemplate.content.cloneNode(true);
const newStep = stepFragment.querySelector('.step');
stepsContainer.appendChild(newStep);
// Add at least one requirement to the new step automatically
const currentStepIndex = stepsContainer.querySelectorAll('.step').length - 1;
addRequirement(newStep, currentStepIndex);
updateIndices(); // Update all indices after adding
};
// Event delegation for remove buttons and add requirement button
stepsContainer.addEventListener('click', (event) => {
if (event.target.classList.contains('removeStepBtn')) {
event.target.closest('.step').remove();
if (stepsContainer.querySelectorAll('.step').length === 0) { // Ensure at least one step
addStep();
}
updateIndices();
} else if (event.target.classList.contains('addRequirementBtn')) {
const stepElement = event.target.closest('.step');
const stepIndex = parseInt(stepElement.dataset.stepIndex, 10);
addRequirement(stepElement, stepIndex);
} else if (event.target.classList.contains('removeRequirementBtn')) {
const requirementElement = event.target.closest('.requirement');
const stepElement = event.target.closest('.step');
const requirementsContainer = stepElement.querySelector('.requirementsContainer');
requirementElement.remove();
// Ensure at least one requirement per step
if (requirementsContainer.querySelectorAll('.requirement').length === 0) {
const stepIndex = parseInt(stepElement.dataset.stepIndex, 10);
addRequirement(stepElement, stepIndex);
}
updateIndices();
}
});
addStepBtn.addEventListener('click', addStep);
// Add one step by default when the page loads
if (stepsContainer.children.length === 0) {
addStep();
}
// Optional: Validate that there's at least one step and one requirement before submit
form.addEventListener('submit', (event) => {
if (stepsContainer.querySelectorAll('.step').length === 0) {
alert('Please add at least one step to the flow.');
event.preventDefault();
return;
}
const steps = stepsContainer.querySelectorAll('.step');
for (let i = 0; i < steps.length; i++) {
if (steps[i].querySelectorAll('.requirementsContainer .requirement').length === 0) {
alert(`Step ${i + 1} must have at least one signature requirement.`);
event.preventDefault();
return;
}
}
});
});
</script>
</body>
</html>

View File

@ -1,28 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Flowbroker - Flows</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<h1>Active Flows</h1>
<a href="/flows/new">Create New Flow</a>
<div id="flows-list">
{% if flows %}
<ul>
{% for flow in flows %}
<li>
<strong>{{ flow.name }}</strong> (UUID: {{ flow.flow_uuid }}) - Status: {{ flow.status }}
<br>
Created: {{ flow.base_data.created_at | date(format="%Y-%m-%d %H:%M:%S") }} <!-- Assuming created_at is a Unix timestamp -->
<p><a href="/flows/{{ flow.flow_uuid }}">View Details</a></p> <!-- Link uses flow_uuid -->
</li>
{% endfor %}
</ul>
{% else %}
<p>No active flows. <a href="/flows/new">Create one?</a></p>
{% endif %}
</div>
</body>
</html>

View File

@ -1,105 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create Flow from Rhai Script</title>
<link rel="stylesheet" href="/static/style.css">
<style>
body {
font-family: sans-serif;
margin: 20px;
background-color: #f4f4f9;
color: #333;
}
.container {
background-color: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
h1 {
color: #333;
}
textarea {
width: 100%;
min-height: 300px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
margin-bottom: 15px;
font-family: monospace;
font-size: 14px;
}
button {
background-color: #007bff;
color: white;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
button:hover {
background-color: #0056b3;
}
a {
color: #007bff;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.back-link {
display: block;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<a href="/" class="back-link">&larr; Back to Flow List</a>
<h1>Create Flow from Rhai Script</h1>
<div id="rhai_script_examples_data" style="display: none;">
{% for example in example_scripts %}
<div id="rhai_example_content_{{ loop.index }}">{{ example.content }}</div>
{% endfor %}
</div>
<div>
<label for="example_script_selector">Load Example Script:</label>
<select id="example_script_selector">
<option value="">-- Select an Example --</option>
{% for example in example_scripts %}
<option value="{{ example.name }}" data-example-id="rhai_example_content_{{ loop.index }}">{{ example.name }}</option>
{% endfor %}
</select>
</div>
<form action="/flows/create_script" method="POST" style="margin-top: 15px;">
<div>
<label for="rhai_script">Rhai Script:</label>
</div>
<div>
<textarea id="rhai_script" name="rhai_script" placeholder="Enter your Rhai script here or select an example above..."></textarea>
</div>
<button type="submit">Create Flow</button>
</form>
<script>
document.getElementById('example_script_selector').addEventListener('change', function() {
var selectedOption = this.options[this.selectedIndex];
var exampleId = selectedOption.getAttribute('data-example-id');
if (exampleId) {
var scriptContent = document.getElementById(exampleId).textContent; // Use textContent
document.getElementById('rhai_script').value = scriptContent;
} else {
document.getElementById('rhai_script').value = '';
}
});
</script>
</div>
</body>
</html>

View File

@ -1,8 +0,0 @@
// Minimal Single Signature Flow
let flow_id = create_flow("Quick Sign");
let step1_id = add_step(flow_id, "Sign the message", 0);
add_requirement(step1_id, "any_signer_pk", "Please provide your signature.");
print("Minimal Flow (ID: " + flow_id + ") defined.");
()

View File

@ -1,18 +0,0 @@
// Flow with Multi-Requirement Step
// If create_flow, add_step, or add_requirement fail from Rust,
// the script will stop and the error will be reported by the server.
let flow_id = create_flow("Multi-Req Sign Off");
let step1_id = add_step(flow_id, "Initial Signatures (3 needed)", 0);
add_requirement(step1_id, "signer1_pk", "Signatory 1: Please sign terms.");
add_requirement(step1_id, "signer2_pk", "Signatory 2: Please sign terms.");
add_requirement(step1_id, "signer3_pk", "Signatory 3: Please sign terms.");
let step2_id = add_step(flow_id, "Final Confirmation", 1);
add_requirement(step2_id, "final_approver_pk", "Final approval for multi-req sign off.");
print("Multi-Requirement Flow (ID: " + flow_id + ") defined.");
()

View File

@ -1,14 +0,0 @@
// Simple Two-Step Flow
// If create_flow, add_step, or add_requirement fail from Rust,
// the script will stop and the error will be reported by the server.
let flow_id = create_flow("Simple Two-Stepper");
let step1_id = add_step(flow_id, "Collect Document", 0);
add_requirement(step1_id, "user_pubkey_document", "Please sign the document hash.");
let step2_id = add_step(flow_id, "Approval Signature", 1);
add_requirement(step2_id, "approver_pubkey", "Please approve the collected document.");
print("Simple Two-Step Flow (ID: " + flow_id + ") defined.");
()

1824
sigsocket/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +0,0 @@
[package]
name = "sigsocket"
version = "0.1.0"
edition = "2021"
description = "WebSocket server for handling signing operations"
[dependencies]
actix = "0.13.0"
actix-web = "4.3.1"
actix-web-actors = "4.2.0"
tokio = { version = "1.28.0", features = ["full"] }
secp256k1 = "0.28.0"
sha2 = "0.10.8"
hex = "0.4.3"
base64 = "0.21.0"
rand = "0.8.5"
thiserror = "1.0.40"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
log = "0.4.17"
env_logger = "0.10.0"
futures = "0.3.28"
uuid = { version = "1.3.3", features = ["v4"] }

View File

@ -1,80 +0,0 @@
# SigSocket: WebSocket Signing Server
SigSocket is a WebSocket server that handles cryptographic signing operations. It allows clients to connect via WebSocket, identify themselves with a public key, and sign messages on demand.
## Features
- Accept WebSocket connections from clients
- Allow clients to identify themselves with a secp256k1 public key
- Forward messages to clients for signing
- Verify signatures using the client's public key
- Support for request timeouts
- Clean API for application integration
## Architecture
SigSocket follows a modular architecture with the following components:
1. **SigSocket Manager**: Handles WebSocket connections and manages connection lifecycle
2. **Connection Registry**: Maps public keys to active WebSocket connections
3. **Message Handler**: Processes incoming messages and implements the message protocol
4. **Signature Verifier**: Verifies signatures using secp256k1
5. **SigSocket Service**: Provides a clean API for applications to use
## Message Protocol
The protocol is designed to be simple and efficient:
1. **Client Introduction** (first message after connection):
```
<hex_encoded_public_key>
```
2. **Sign Request** (sent from server to client):
```
<base64_encoded_message>
```
3. **Sign Response** (sent from client to server):
```
<base64_encoded_message>.<base64_encoded_signature>
```
## API Usage
```rust
// Create and initialize the service
let registry = Arc::new(RwLock::new(ConnectionRegistry::new()));
let sigsocket_service = Arc::new(SigSocketService::new(registry.clone()));
// Use the service to send a message for signing
async fn sign_message(
service: Arc<SigSocketService>,
public_key: String,
message: Vec<u8>
) -> Result<(Vec<u8>, Vec<u8>), SigSocketError> {
service.send_to_sign(&public_key, &message).await
}
```
## Security Considerations
- All public keys are validated to ensure they are properly formatted secp256k1 keys
- Messages are hashed using SHA-256 before signature verification
- WebSocket connections have heartbeat checks to automatically close inactive connections
- All inputs are validated to prevent injection attacks
## Running the Example Server
Start the example server with:
```bash
RUST_LOG=info cargo run
```
This will launch a server on `127.0.0.1:8080` with the following endpoints:
- `/ws` - WebSocket endpoint for client connections
- `/sign` - HTTP POST endpoint to request message signing
- `/status` - HTTP GET endpoint to check connection count
- `/connected/{public_key}` - HTTP GET endpoint to check if a client is connected

View File

@ -1,71 +0,0 @@
# SigSocket Examples
This directory contains example applications demonstrating how to use the SigSocket library for cryptographic signing operations using WebSockets.
## Overview
These examples demonstrate a common workflow:
1. **Web Application with Integrated SigSocket Server**: An Actix-based web server that both serves the web UI and runs the SigSocket WebSocket server for handling connections and signing requests.
2. **Client Application**: A web interface that connects to the SigSocket WebSocket endpoint, receives signing requests, and submits signatures.
## Directory Structure
- `web_app/`: The web application with integrated SigSocket server
- `client_app/`: The client application that signs messages
## Running the Examples
You only need to run two components:
### 1. Start the Web Application with Integrated SigSocket Server
Start the web application which also runs the SigSocket server:
```bash
cd /path/to/sigsocket/examples/web_app
cargo run
```
This will start a web interface at http://127.0.0.1:8080 where you can submit messages to be signed. It also starts the SigSocket WebSocket server at ws://127.0.0.1:8080/ws.
### 2. Start the Client Application
The client application connects to the WebSocket endpoint and waits for signing requests:
```bash
cd /path/to/sigsocket/examples/client_app
cargo run
```
This will start a web interface at http://127.0.0.1:8082 where you can see signing requests and approve them.
## Using the Applications
1. Open the client app in a browser at http://127.0.0.1:8082
2. Note the public key displayed on the page
3. Open the web app in another browser window at http://127.0.0.1:8080
4. Enter the public key from step 2 into the "Public Key" field
5. Enter a message to be signed and submit the form
6. The message will be sent to the SigSocket server, which forwards it to the connected client
7. In the client app, you'll see the sign request appear - click "Sign Message" to approve
8. The signature will be sent back through the SigSocket server to the web app
9. The web app will display the signature
## How It Works
1. **SigSocket Server**: Provides a WebSocket endpoint for clients to connect and register with their public keys. It also accepts HTTP requests to sign messages with a specific client's key.
2. **Web Application**:
- Provides a form for users to enter a public key and message
- Uses the SigSocket service to send the message to be signed
- Displays the resulting signature
3. **Client Application**:
- Connects to the SigSocket server via WebSocket
- Registers with a public key
- Waits for signing requests
- Displays incoming requests and allows the user to approve them
- Signs messages using ECDSA with Secp256k1 and sends the signatures back
This demonstrates a real-world use case where a web application needs to verify a user's identity or get approval for transactions through cryptographic signatures, without having direct access to the private keys.

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +0,0 @@
[package]
name = "sigsocket-client-example"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1.28.0", features = ["full"] }
tokio-tungstenite = { version = "0.18.0", features = ["native-tls"] }
futures-util = "0.3.28"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
log = "0.4"
env_logger = "0.10.0"
secp256k1 = { version = "0.26.0", features = ["rand-std"] }
sha2 = "0.10.6"
rand = "0.8.5"
hex = "0.4.3"
base64 = "0.21.2"
actix-web = "4.3.1"
actix-files = "0.6.2"
tera = "1.19.0"
url = "2.4.0"

View File

@ -1,474 +0,0 @@
use actix_files as fs;
use actix_web::{web, App, HttpServer, Responder, HttpResponse, Result};
use serde::{Deserialize, Serialize};
use tera::{Tera, Context};
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
use tokio_tungstenite::{connect_async, tungstenite};
use futures_util::{StreamExt, SinkExt};
use secp256k1::{Secp256k1, SecretKey, Message};
use sha2::{Sha256, Digest};
use url::Url;
use std::thread;
// Struct for representing a sign request
#[derive(Serialize, Deserialize, Clone, Debug)]
struct SignRequest {
id: String,
message: String,
#[serde(skip)]
message_raw: String, // Original base64 message for sending back in the response
#[serde(skip)]
message_decoded: String, // Decoded message for display
}
// Struct for representing the application state
struct AppState {
templates: Tera,
keypair: Arc<KeyPair>,
pending_request: Arc<Mutex<Option<SignRequest>>>,
websocket_sender: mpsc::Sender<WebSocketCommand>,
}
// Commands that can be sent to the WebSocket connection
enum WebSocketCommand {
Sign { id: String, message: String, signature: Vec<u8> },
Close,
}
// Keypair for signing messages
struct KeyPair {
secret_key: SecretKey,
public_key_hex: String,
}
impl KeyPair {
fn new() -> Self {
let secp = Secp256k1::new();
let mut rng = rand::thread_rng();
// Generate a new random keypair
let (secret_key, public_key) = secp.generate_keypair(&mut rng);
// Convert public key to hex for identification
let public_key_hex = hex::encode(public_key.serialize());
KeyPair {
secret_key,
public_key_hex,
}
}
fn sign(&self, message: &[u8]) -> Vec<u8> {
// Hash the message first (secp256k1 requires a 32-byte hash)
let mut hasher = Sha256::new();
hasher.update(message);
let message_hash = hasher.finalize();
// Create a secp256k1 message from the hash
let secp_message = Message::from_slice(&message_hash).unwrap();
// Sign the message
let secp = Secp256k1::new();
let signature = secp.sign_ecdsa(&secp_message, &self.secret_key);
// Return the serialized signature
signature.serialize_compact().to_vec()
}
}
// Controller for the index page
async fn index(data: web::Data<AppState>) -> Result<HttpResponse> {
let mut context = Context::new();
// Add the keypair to the context
context.insert("public_key", &data.keypair.public_key_hex);
// Add the pending request if there is one
if let Some(request) = &*data.pending_request.lock().unwrap() {
context.insert("request", request);
}
let rendered = data.templates.render("index.html", &context)
.map_err(|e| {
eprintln!("Template error: {}", e);
actix_web::error::ErrorInternalServerError("Template error")
})?;
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
}
// Controller for the sign endpoint
async fn sign_request(
data: web::Data<AppState>,
form: web::Form<SignRequestForm>,
) -> impl Responder {
println!("SIGN ENDPOINT: Starting sign_request handler for form ID: {}", form.id);
// Try to get a lock on the pending request
println!("SIGN ENDPOINT: Attempting to acquire lock on pending_request");
match data.pending_request.try_lock() {
Ok(mut guard) => {
// Check if we have a pending request
if let Some(request) = &*guard {
println!("SIGN ENDPOINT: Found pending request with ID: {}", request.id);
// Get the request ID
let id = request.id.clone();
// Verify that the request ID matches
if id == form.id {
println!("SIGN ENDPOINT: Request ID matches form ID: {}", id);
// Sign the message
let message = request.message.as_bytes();
println!("SIGN ENDPOINT: About to sign message: {} (length: {})",
String::from_utf8_lossy(message), message.len());
let signature = data.keypair.sign(message);
println!("SIGN ENDPOINT: Message signed successfully. Signature length: {}", signature.len());
// Send the signature via WebSocket
println!("SIGN ENDPOINT: About to send signature via websocket channel");
match data.websocket_sender.send(WebSocketCommand::Sign {
id: id.clone(),
message: request.message_raw.clone(), // Include the original base64 message
signature
}).await {
Ok(_) => {
println!("SIGN ENDPOINT: Successfully sent signature to websocket channel");
},
Err(e) => {
let error_msg = format!("Failed to send signature: {}", e);
println!("SIGN ENDPOINT ERROR: {}", error_msg);
return HttpResponse::InternalServerError()
.content_type("text/html")
.body(format!("<h1>Error sending signature</h1><p>{}</p><p><a href='/'>Return to home</a></p>", error_msg));
}
}
// Clear the pending request
println!("SIGN ENDPOINT: Clearing pending request");
*guard = None;
// Return a success page that continues to the next step
println!("SIGN ENDPOINT: Returning success response");
return HttpResponse::Ok()
.content_type("text/html")
.body(r#"<html>
<head>
<title>Signature Sent</title>
<meta http-equiv="refresh" content="2; url=/" />
<script type="text/javascript">
console.log("Signature sent successfully, redirecting in 2 seconds...");
setTimeout(function() { window.location.href = '/'; }, 2000);
</script>
<style>
body { font-family: Arial, sans-serif; text-align: center; margin-top: 50px; }
.success { color: green; }
</style>
</head>
<body>
<h1 class="success"> Signature Sent Successfully!</h1>
<p>Redirecting back to home page...</p>
<p><a href="/">Click here if you're not redirected automatically</a></p>
</body>
</html>"#);
} else {
println!("SIGN ENDPOINT: Request ID {} does not match form ID {}", request.id, form.id);
}
} else {
println!("SIGN ENDPOINT: No pending request found");
}
},
Err(e) => {
let error_msg = format!("Failed to acquire lock on pending_request: {}", e);
println!("SIGN ENDPOINT ERROR: {}", error_msg);
return HttpResponse::InternalServerError()
.content_type("text/html")
.body(format!("<h1>Error processing request</h1><p>{}</p><p><a href='/'>Return to home</a></p>", error_msg));
}
}
// Redirect back to the index page (if no request was found or ID didn't match)
println!("SIGN ENDPOINT: No matching request found, redirecting to home");
HttpResponse::SeeOther()
.append_header(("Location", "/"))
.finish()
}
// Form for submitting a signature
#[derive(Deserialize)]
struct SignRequestForm {
id: String,
}
// WebSocket client task that connects to the SigSocket server
async fn websocket_client_task(
keypair: Arc<KeyPair>,
pending_request: Arc<Mutex<Option<SignRequest>>>,
mut command_receiver: mpsc::Receiver<WebSocketCommand>,
) {
// Connect directly to the web app's integrated SigSocket endpoint
let sigsocket_url = "ws://127.0.0.1:8080/ws";
// Reconnection settings
let mut retry_count = 0;
const MAX_RETRY_COUNT: u32 = 10; // Reset retry counter after this many attempts
const BASE_RETRY_DELAY_MS: u64 = 1000; // Start with 1 second
const MAX_RETRY_DELAY_MS: u64 = 30000; // Cap at 30 seconds
loop {
// Calculate backoff delay with jitter for retry
let delay_ms = if retry_count > 0 {
let base_delay = BASE_RETRY_DELAY_MS * 2u64.pow(retry_count.min(6));
let jitter = rand::random::<u64>() % 500; // Add up to 500ms of jitter
(base_delay + jitter).min(MAX_RETRY_DELAY_MS)
} else {
0 // No delay on first attempt
};
if retry_count > 0 {
println!("Reconnection attempt {} in {} ms...", retry_count, delay_ms);
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
}
// Connect to the SigSocket server with timeout
println!("Connecting to SigSocket server at {}", sigsocket_url);
let connect_result = tokio::time::timeout(
tokio::time::Duration::from_secs(10), // Connection timeout
connect_async(Url::parse(sigsocket_url).unwrap())
).await;
match connect_result {
// Timeout error
Err(_) => {
eprintln!("Connection attempt timed out");
retry_count = (retry_count + 1) % MAX_RETRY_COUNT;
continue;
},
// Connection result
Ok(conn_result) => match conn_result {
// Connection successful
Ok((mut ws_stream, _)) => {
println!("Connected to SigSocket server");
// Reset retry counter on successful connection
retry_count = 0;
// Heartbeat functionality has been removed
println!("DEBUG: Running without heartbeat functionality");
// Send the initial message with just the raw public key
let intro_message = keypair.public_key_hex.clone();
if let Err(e) = ws_stream.send(tungstenite::Message::Text(intro_message)).await {
eprintln!("Failed to send introduction message: {}", e);
continue;
}
println!("Sent introduction with public key: {}", keypair.public_key_hex);
// Last time we received a message or pong from the server
let mut last_server_response = std::time::Instant::now();
// Process incoming messages and commands
loop {
tokio::select! {
// Handle WebSocket message
msg = ws_stream.next() => {
match msg {
Some(Ok(tungstenite::Message::Text(text))) => {
println!("Received message: {}", text);
last_server_response = std::time::Instant::now();
// Parse the message as a sign request
match serde_json::from_str::<SignRequest>(&text) {
Ok(mut request) => {
println!("DEBUG: Successfully parsed sign request with ID: {}", request.id);
println!("DEBUG: Base64 message: {}", request.message);
// Save the original base64 message for later use in response
request.message_raw = request.message.clone();
// Decode the base64 message content
match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &request.message) {
Ok(decoded) => {
let decoded_text = String::from_utf8_lossy(&decoded).to_string();
println!("DEBUG: Decoded message: {}", decoded_text);
// Store the decoded message for display
request.message_decoded = decoded_text;
// Update the message for displaying in the UI
request.message = request.message_decoded.clone();
// Store the request for display in the UI
*pending_request.lock().unwrap() = Some(request);
println!("Received signing request. Please check the web UI to approve it.");
},
Err(e) => {
eprintln!("Error decoding base64 message: {}", e);
}
}
},
Err(e) => {
eprintln!("Error parsing sign request JSON: {}", e);
eprintln!("Raw message: {}", text);
}
}
},
Some(Ok(tungstenite::Message::Ping(data))) => {
// Respond to ping with pong
last_server_response = std::time::Instant::now();
if let Err(e) = ws_stream.send(tungstenite::Message::Pong(data)).await {
eprintln!("Failed to send pong: {}", e);
break;
}
},
Some(Ok(tungstenite::Message::Pong(_))) => {
// Got pong response from the server
last_server_response = std::time::Instant::now();
},
Some(Ok(_)) => {
// Ignore other types of messages
last_server_response = std::time::Instant::now();
},
Some(Err(e)) => {
eprintln!("WebSocket error: {}", e);
break;
},
None => {
eprintln!("WebSocket connection closed");
break;
},
}
},
// Heartbeat functionality has been removed
// Handle signing command from the web interface
cmd = command_receiver.recv() => {
match cmd {
Some(WebSocketCommand::Sign { id, message, signature }) => {
println!("DEBUG: Signing request ID: {}", id);
println!("DEBUG: Raw signature bytes: {:?}", signature);
println!("DEBUG: Using message from command: {}", message);
// Convert signature bytes to base64
let sig_base64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &signature);
println!("DEBUG: Base64 signature: {}", sig_base64);
// Create a JSON response with explicit ID and message/signature fields
let response = format!("{{\"id\": \"{}\", \"message\": \"{}\", \"signature\": \"{}\"}}",
id, message, sig_base64);
println!("DEBUG: Preparing to send JSON response: {}", response);
println!("DEBUG: Response length: {} bytes", response.len());
// Log that we're about to send on the WebSocket connection
println!("DEBUG: About to send on WebSocket connection");
// Send the signature response right away - with extra logging
println!("!!!! ATTEMPTING TO SEND SIGNATURE RESPONSE NOW !!!!");
match ws_stream.send(tungstenite::Message::Text(response.clone())).await {
Ok(_) => {
last_server_response = std::time::Instant::now();
println!("!!!! SUCCESSFULLY SENT SIGNATURE RESPONSE !!!!");
println!("!!!! SIGNATURE SENT FOR REQUEST ID: {} !!!!", id);
// Clear the pending request after successful signature
*pending_request.lock().unwrap() = None;
// Send another simple message to confirm the connection is still working
if let Err(e) = ws_stream.send(tungstenite::Message::Text("CONFIRM_SIGNATURE_SENT".to_string())).await {
println!("DEBUG: Failed to send confirmation message: {}", e);
} else {
println!("DEBUG: Sent confirmation message after signature");
}
},
Err(e) => {
eprintln!("!!!! FAILED TO SEND SIGNATURE RESPONSE: {} !!!!", e);
// Try to reconnect or recover
println!("DEBUG: Attempting to diagnose connection issue...");
break;
}
}
},
Some(WebSocketCommand::Close) => {
println!("DEBUG: Received close command, closing connection");
break;
},
None => {
eprintln!("Command channel closed");
break;
}
}
}
}
}
// Connection loop has ended, will attempt to reconnect
println!("WebSocket connection closed, will attempt to reconnect...");
},
// Connection error
Err(e) => {
eprintln!("Failed to connect to SigSocket server: {}", e);
}
}
}
// Increment retry counter but don't exceed MAX_RETRY_COUNT
retry_count = (retry_count + 1) % MAX_RETRY_COUNT;
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// Setup logger
env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));
// Initialize templates
let mut tera = Tera::default();
tera.add_raw_templates(vec![
("index.html", include_str!("../templates/index.html")),
]).unwrap();
// Generate a keypair for signing
let keypair = Arc::new(KeyPair::new());
println!("Generated keypair with public key: {}", keypair.public_key_hex);
// Create a channel for sending commands to the WebSocket client
let (command_sender, command_receiver) = mpsc::channel::<WebSocketCommand>(32);
// Create the pending request mutex
let pending_request = Arc::new(Mutex::new(None::<SignRequest>));
// Spawn the WebSocket client task
let ws_keypair = keypair.clone();
let ws_pending_request = pending_request.clone();
tokio::spawn(async move {
websocket_client_task(ws_keypair, ws_pending_request, command_receiver).await;
});
// Create the app state
let app_state = web::Data::new(AppState {
templates: tera,
keypair,
pending_request,
websocket_sender: command_sender,
});
println!("Client App server starting on http://127.0.0.1:8082");
// Start the web server
HttpServer::new(move || {
App::new()
.app_data(app_state.clone())
// Register routes
.route("/", web::get().to(index))
.route("/sign", web::post().to(sign_request))
// Static files
.service(fs::Files::new("/static", "./static"))
})
.bind("127.0.0.1:8082")?
.run()
.await
}

View File

@ -1,204 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SigSocket Client Demo</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<style>
body {
font-family: Arial, sans-serif;
max-width: 900px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
}
h1, h2 {
color: #333;
text-align: center;
}
.status-box {
text-align: center;
padding: 15px;
margin-bottom: 30px;
border-radius: 5px;
background-color: #f5f5f5;
}
.status-connected {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.client-info {
margin-bottom: 30px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
background-color: #f9f9f9;
}
.keypair-info {
font-family: monospace;
word-break: break-all;
margin: 10px 0;
}
.request-panel {
padding: 20px;
border: 1px solid #ddd;
border-radius: 5px;
margin-bottom: 30px;
background-color: #fff;
}
.message-box {
font-family: monospace;
background-color: #f8f9fa;
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
margin: 15px 0;
white-space: pre-wrap;
word-break: break-all;
}
.no-requests {
text-align: center;
padding: 30px;
color: #6c757d;
}
button {
background-color: #4CAF50;
color: white;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
display: block;
margin: 0 auto;
}
button:hover {
background-color: #45a049;
}
.footer {
text-align: center;
margin-top: 30px;
color: #6c757d;
font-size: 0.9em;
}
</style>
</head>
<body>
<h1>SigSocket Client Demo</h1>
<div class="status-box status-connected">
<p><strong>Status:</strong> Connected to SigSocket Server</p>
</div>
<div class="client-info">
<h2>Client Information</h2>
<p><strong>Public Key:</strong></p>
<p class="keypair-info">{{ public_key }}</p>
<p>This public key is used to identify this client to the SigSocket server.</p>
</div>
{% if request %}
<div class="request-panel">
<h2>Pending Sign Request</h2>
<p><strong>Request ID:</strong> {{ request.id }}</p>
<p><strong>Message to Sign:</strong></p>
<div class="message-box">{{ request.message }}</div>
<form action="/sign" method="post">
<input type="hidden" name="id" value="{{ request.id }}">
<button type="submit">Sign Message</button>
</form>
</div>
{% else %}
<div class="request-panel no-requests">
<h2>No Pending Requests</h2>
<p>Waiting for a sign request from the SigSocket server...</p>
</div>
{% endif %}
<div class="footer">
<p>This client connects to a SigSocket server via WebSocket and responds to signature requests.</p>
<p>The signing is done using Secp256k1 ECDSA with a randomly generated keypair.</p>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed bottom-0 start-0 p-3" style="z-index: 11; width: 100%;">
<!-- Toasts will be added here dynamically -->
</div>
<script>
// Override console.log to show toast messages
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
console.log = function(message) {
// Call the original console.log
originalConsoleLog.apply(console, arguments);
// Show toast with the message
showToast(message, 'info');
};
console.error = function(message) {
// Call the original console.error
originalConsoleError.apply(console, arguments);
// Show toast with the error message
showToast(message, 'danger');
};
function showToast(message, type = 'info') {
// Create toast element
const toastId = 'toast-' + Date.now();
const toastElement = document.createElement('div');
toastElement.id = toastId;
toastElement.className = 'toast w-100';
toastElement.setAttribute('role', 'alert');
toastElement.setAttribute('aria-live', 'assertive');
toastElement.setAttribute('aria-atomic', 'true');
// Set toast content
toastElement.innerHTML = `
<div class="toast-header bg-${type} text-white">
<strong class="me-auto">${type === 'danger' ? 'Error' : 'Info'}</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
${message}
</div>
`;
// Append to container
document.querySelector('.toast-container').appendChild(toastElement);
// Initialize and show the toast
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: 5000
});
toast.show();
// Remove toast after it's hidden
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
});
}
// Test toast
console.log('Client app loaded successfully!');
</script>
</body>
</html>

View File

@ -1,53 +0,0 @@
#!/bin/bash
# Script to run both the SigSocket web app and client app and open them in the browser
# Set the base directory
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WEB_APP_DIR="$BASE_DIR/web_app"
CLIENT_APP_DIR="$BASE_DIR/client_app"
# Colors for terminal output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to kill background processes on exit
cleanup() {
echo -e "${YELLOW}Stopping all processes...${NC}"
kill $(jobs -p) 2>/dev/null
exit 0
}
# Set up cleanup on script termination
trap cleanup INT TERM EXIT
echo -e "${GREEN}Starting SigSocket Demo Applications...${NC}"
# Start the web app in the background
echo -e "${GREEN}Starting Web App (http://127.0.0.1:8080)...${NC}"
cd "$WEB_APP_DIR" && cargo run &
# Wait for the web app to start (adjust time as needed)
echo "Waiting for web app to initialize..."
sleep 5
# Start the client app in the background
echo -e "${GREEN}Starting Client App (http://127.0.0.1:8082)...${NC}"
cd "$CLIENT_APP_DIR" && cargo run &
# Wait for the client app to start
echo "Waiting for client app to initialize..."
sleep 5
# Open browsers (works on macOS)
echo -e "${GREEN}Opening browsers...${NC}"
open "http://127.0.0.1:8080" # Web App
sleep 1
open "http://127.0.0.1:8082" # Client App
echo -e "${GREEN}SigSocket demo is running!${NC}"
echo -e "${YELLOW}Press Ctrl+C to stop all applications${NC}"
# Keep the script running until Ctrl+C
wait

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +0,0 @@
[package]
name = "sigsocket-web-example"
version = "0.1.0"
edition = "2021"
[dependencies]
sigsocket = { path = "../.." }
actix-web = "4.3.1"
actix-rt = "2.8.0"
actix-files = "0.6.2"
actix-web-actors = "4.2.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
env_logger = "0.10.0"
log = "0.4"
tera = "1.19.0"
tokio = { version = "1.28.0", features = ["full"] }
dotenv = "0.15.0"
hex = "0.4.3"
base64 = "0.13.0"
uuid = { version = "1.0", features = ["v4"] }

View File

@ -1,439 +0,0 @@
use actix_files as fs;
use actix_web::{web, App, HttpServer, Responder, HttpResponse, Result};
use actix_web_actors::ws;
use serde::{Deserialize, Serialize};
use tera::{Tera, Context};
use std::sync::{Arc, Mutex};
use sigsocket::service::SigSocketService;
use sigsocket::registry::ConnectionRegistry;
use std::sync::RwLock;
use log::{info, error};
use hex;
use base64;
use std::collections::HashMap;
use uuid::Uuid;
use std::time::{Duration, Instant};
use tokio::task;
use serde_json::json;
// Status enum to represent the current state of a signature request
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub enum SignatureStatus {
Pending, // Request is created but not yet sent to the client
Processing, // Request is sent to the client for signing
Success, // Signature received and verified successfully
Error, // An error occurred during signing
Timeout, // Request timed out waiting for signature
}
// Shared state for the application
struct AppState {
templates: Tera,
sigsocket_service: Arc<SigSocketService>,
// Store all pending signature requests with their status
signature_requests: Arc<Mutex<HashMap<String, PendingSignature>>>,
}
// Structure for incoming sign requests
#[derive(Deserialize)]
struct SignRequest {
public_key: String,
message: String,
}
// Result structure for API responses
#[derive(Serialize, Clone)]
struct SignResult {
id: String, // Unique ID for this signature request
public_key: String, // Public key of the signer
message: String, // Original message that was signed
status: SignatureStatus, // Current status of the request
signature: Option<String>, // Signature if available
error: Option<String>, // Error message if any
created_at: String, // When the request was created (human readable)
updated_at: String, // When the request was last updated (human readable)
}
// Structure to track pending signatures
#[derive(Clone)]
struct PendingSignature {
id: String, // Unique ID for this request
public_key: String, // Public key that should sign
message: String, // Message to be signed
message_bytes: Vec<u8>, // Raw message bytes
status: SignatureStatus, // Current status
error: Option<String>, // Error message if any
signature: Option<String>, // Signature if available
created_at: Instant, // When the request was created
updated_at: Instant, // When the request was last updated
timeout_duration: Duration // How long to wait before timing out
}
impl PendingSignature {
fn new(id: String, public_key: String, message: String, message_bytes: Vec<u8>) -> Self {
let now = Instant::now();
PendingSignature {
id,
public_key,
message,
message_bytes,
status: SignatureStatus::Pending,
signature: None,
error: None,
created_at: now,
updated_at: now,
timeout_duration: Duration::from_secs(60), // Default 60-second timeout
}
}
fn to_result(&self) -> SignResult {
SignResult {
id: self.id.clone(),
public_key: self.public_key.clone(),
message: self.message.clone(),
status: self.status.clone(),
signature: self.signature.clone(),
error: self.error.clone(),
created_at: format!("{}s ago", self.created_at.elapsed().as_secs()),
updated_at: format!("{}s ago", self.updated_at.elapsed().as_secs()),
}
}
fn update_status(&mut self, status: SignatureStatus) {
self.status = status;
self.updated_at = Instant::now();
}
fn set_success(&mut self, signature: String) {
self.signature = Some(signature);
self.update_status(SignatureStatus::Success);
}
fn set_error(&mut self, error: String) {
self.error = Some(error);
self.update_status(SignatureStatus::Error);
}
fn is_timed_out(&self) -> bool {
self.created_at.elapsed() > self.timeout_duration
}
}
// Controller for the index page
async fn index(data: web::Data<AppState>) -> Result<HttpResponse> {
let mut context = Context::new();
// Add all signature requests to the context
let signature_requests = data.signature_requests.lock().unwrap();
// Convert the pending signatures to results for the template
let mut pending_sigs: Vec<&PendingSignature> = signature_requests.values().collect();
// Sort by created_at date (newest first)
pending_sigs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
// Convert to results after sorting
let results: Vec<SignResult> = pending_sigs.iter()
.map(|sig| sig.to_result())
.collect();
context.insert("signature_requests", &results);
context.insert("has_requests", &!results.is_empty());
let rendered = data.templates.render("index.html", &context)
.map_err(|e| {
eprintln!("Template error: {}", e);
actix_web::error::ErrorInternalServerError("Template error")
})?;
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
}
// Controller for the sign endpoint
async fn sign(
data: web::Data<AppState>,
form: web::Form<SignRequest>,
) -> impl Responder {
let message = form.message.clone();
let public_key = form.public_key.clone();
info!("Received sign request for public key: {}", &public_key);
info!("Message to sign: {}", &message);
// Generate a unique ID for this signature request
let request_id = Uuid::new_v4().to_string();
// Log the message bytes
let message_bytes = message.as_bytes().to_vec();
info!("Message bytes: {:?}", message_bytes);
info!("Message hex: {}", hex::encode(&message_bytes));
// Create a new pending signature request
let pending = PendingSignature::new(
request_id.clone(),
public_key.clone(),
message.clone(),
message_bytes.clone()
);
// Add the pending request to our state
{
let mut signature_requests = data.signature_requests.lock().unwrap();
signature_requests.insert(request_id.clone(), pending);
info!("Added new pending signature request: {}", request_id);
}
// Clone what we need for the async task
let request_id_clone = request_id.clone();
let service = data.sigsocket_service.clone();
let signature_requests = data.signature_requests.clone();
// Spawn an async task to handle the signature request
task::spawn(async move {
info!("Starting async signature task for request: {}", request_id_clone);
// Update status to Processing
{
let mut requests = signature_requests.lock().unwrap();
if let Some(request) = requests.get_mut(&request_id_clone) {
request.update_status(SignatureStatus::Processing);
}
}
// Send the message to be signed via SigSocket
info!("Sending message to SigSocket service for signing...");
match service.send_to_sign(&public_key, &message_bytes).await {
Ok((response_bytes, signature)) => {
// Successfully received a signature
let signature_base64 = base64::encode(&signature);
let message_base64 = base64::encode(&message_bytes);
// Format in the expected dot-separated format: base64_message.base64_signature
let full_signature = format!("{}.{}", message_base64, signature_base64);
info!("Successfully received signature response for request: {}", request_id_clone);
info!("Message base64: {}", message_base64);
info!("Signature base64: {}", signature_base64);
info!("Full signature (dot format): {}", full_signature);
// Update the signature request with the successful result
let mut requests = signature_requests.lock().unwrap();
if let Some(request) = requests.get_mut(&request_id_clone) {
request.set_success(signature_base64);
}
},
Err(err) => {
// Error occurred
error!("Error during signature process for request {}: {:?}", request_id_clone, err);
// Update the signature request with the error
let mut requests = signature_requests.lock().unwrap();
if let Some(request) = requests.get_mut(&request_id_clone) {
request.set_error(format!("Error: {:?}", err));
}
}
}
});
// Return JSON response if it's an AJAX request, otherwise redirect
if is_ajax_request(&form) {
// Return JSON response for AJAX requests
HttpResponse::Ok()
.content_type("application/json")
.json(json!({
"status": "pending",
"requestId": request_id,
"message": "Signature request added to queue"
}))
} else {
// Redirect back to the index page
HttpResponse::SeeOther()
.append_header(("Location", "/"))
.finish()
}
}
// Helper function to check if this is an AJAX request
fn is_ajax_request(_form: &web::Form<SignRequest>) -> bool {
// For simplicity, we'll always return false for now
// In a real application, you would check headers like X-Requested-With
false
}
// WebSocket handler for SigSocket connections
async fn websocket_handler(
req: actix_web::HttpRequest,
stream: actix_web::web::Payload,
service: web::Data<Arc<SigSocketService>>,
) -> Result<HttpResponse> {
// Create a new SigSocket handler
let handler = service.create_websocket_handler();
// Start WebSocket connection
ws::start(handler, &req, stream)
}
// Status endpoint for SigSocket server
async fn status_endpoint(service: web::Data<Arc<SigSocketService>>) -> impl Responder {
// Get the connection count
match service.connection_count() {
Ok(count) => {
// Return JSON response with status info
web::Json(json!({
"status": "online",
"active_connections": count,
"version": env!("CARGO_PKG_VERSION"),
}))
},
Err(e) => {
error!("Error getting connection count: {:?}", e);
// Return error status
web::Json(json!({
"status": "error",
"error": format!("{:?}", e),
}))
}
}
}
// Get status of a specific signature request or all requests
async fn signature_status(
data: web::Data<AppState>,
path: web::Path<(String,)>,
) -> impl Responder {
let request_id = &path.0;
// If the request_id is "all", return all requests
if request_id == "all" {
let signature_requests = data.signature_requests.lock().unwrap();
// Convert the pending signatures to results for the API
let results: Vec<SignResult> = signature_requests.values()
.map(|sig| sig.to_result())
.collect();
return web::Json(json!({
"status": "success",
"count": results.len(),
"requests": results
}));
}
// Otherwise, find the specific request
let signature_requests = data.signature_requests.lock().unwrap();
if let Some(request) = signature_requests.get(request_id) {
web::Json(json!({
"status": "success",
"request": request.to_result()
}))
} else {
web::Json(json!({
"status": "error",
"message": format!("No signature request found with ID: {}", request_id)
}))
}
}
// Delete a signature request
async fn delete_signature(
data: web::Data<AppState>,
path: web::Path<(String,)>,
) -> impl Responder {
let request_id = &path.0;
let mut signature_requests = data.signature_requests.lock().unwrap();
if let Some(_) = signature_requests.remove(request_id) {
web::Json(json!({
"status": "success",
"message": format!("Signature request {} deleted", request_id)
}))
} else {
web::Json(json!({
"status": "error",
"message": format!("No signature request found with ID: {}", request_id)
}))
}
}
// Task to check for timed-out signature requests
async fn check_timeouts(signature_requests: Arc<Mutex<HashMap<String, PendingSignature>>>) {
loop {
tokio::time::sleep(Duration::from_secs(5)).await;
// Check for timed-out requests
let mut requests = signature_requests.lock().unwrap();
let timed_out: Vec<String> = requests.iter()
.filter(|(_, req)| req.status == SignatureStatus::Pending || req.status == SignatureStatus::Processing)
.filter(|(_, req)| req.is_timed_out())
.map(|(id, _)| id.clone())
.collect();
// Update timed-out requests
for id in timed_out {
if let Some(req) = requests.get_mut(&id) {
req.error = Some("Request timed out waiting for signature".to_string());
req.update_status(SignatureStatus::Timeout);
info!("Signature request {} timed out", id);
}
}
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// Setup logger
env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));
// Initialize templates
let mut tera = Tera::default();
tera.add_raw_templates(vec![
("index.html", include_str!("../templates/index.html")),
]).unwrap();
// Initialize SigSocket registry and service
let registry = Arc::new(RwLock::new(ConnectionRegistry::new()));
let sigsocket_service = Arc::new(SigSocketService::new(registry.clone()));
// Initialize signature requests tracking
let signature_requests = Arc::new(Mutex::new(HashMap::new()));
// Start the timeout checking task
let timeout_checker_requests = signature_requests.clone();
tokio::spawn(async move {
check_timeouts(timeout_checker_requests).await;
});
// Shared application state
let app_state = web::Data::new(AppState {
templates: tera,
sigsocket_service: sigsocket_service.clone(),
signature_requests: signature_requests.clone(),
});
info!("Web App server starting on http://127.0.0.1:8080");
info!("SigSocket WebSocket endpoint available at ws://127.0.0.1:8080/ws");
// Start the web server with both our regular routes and the SigSocket WebSocket handler
HttpServer::new(move || {
App::new()
.app_data(app_state.clone())
.app_data(web::Data::new(sigsocket_service.clone()))
// Regular web app routes
.route("/", web::get().to(index))
.route("/sign", web::post().to(sign))
// SigSocket WebSocket handler
.route("/ws", web::get().to(websocket_handler))
// Status endpoints
.route("/sigsocket/status", web::get().to(status_endpoint))
// Signature API endpoints
.route("/api/signatures/{id}", web::get().to(signature_status))
.route("/api/signatures/{id}", web::delete().to(delete_signature))
// Static files
.service(fs::Files::new("/static", "./static"))
})
.bind("127.0.0.1:8080")?
.run()
.await
}

View File

@ -1,462 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SigSocket Demo App</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
}
h1 {
color: #333;
text-align: center;
margin-bottom: 30px;
}
.container {
display: flex;
justify-content: space-between;
}
.panel {
flex: 1;
padding: 20px;
border: 1px solid #ddd;
border-radius: 5px;
margin: 0 10px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input[type="text"],
textarea {
width: 100%;
padding: 8px;
margin-bottom: 15px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
textarea {
min-height: 150px;
resize: vertical;
}
button {
background-color: #4CAF50;
color: white;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
button:hover {
background-color: #45a049;
}
.result {
background-color: #f9f9f9;
padding: 15px;
border-radius: 4px;
margin-top: 20px;
}
.success {
color: #4CAF50;
font-weight: bold;
}
.error {
color: #f44336;
font-weight: bold;
}
</style>
</head>
<body>
<h1>SigSocket Demo Application</h1>
<div class="container">
<!-- Left Panel - Message Input Form -->
<div class="panel">
<h2>Sign Message</h2>
<form action="/sign" method="post">
<div>
<label for="public_key">Public Key:</label>
<input type="text" id="public_key" name="public_key" placeholder="Enter the client's public key" required>
</div>
<div>
<label for="message">Message to Sign:</label>
<textarea id="message" name="message" placeholder="Enter the message to be signed" required></textarea>
</div>
<button type="submit">Sign with SigSocket</button>
</form>
</div>
<!-- Right Panel - Signature Results -->
<div class="panel">
<h2>Pending Signatures</h2>
<div id="signature-list">
{% if has_requests %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Message</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for req in signature_requests %}
<tr id="signature-row-{{ req.id }}" class="{% if req.status == 'Success' %}table-success{% elif req.status == 'Error' or req.status == 'Timeout' %}table-danger{% elif req.status == 'Processing' %}table-warning{% else %}table-light{% endif %}">
<td>{{ req.id | truncate(length=8) }}</td>
<td>{{ req.message | truncate(length=20, end="...") }}</td>
<td>
<span class="badge rounded-pill {% if req.status == 'Success' %}bg-success{% elif req.status == 'Error' or req.status == 'Timeout' %}bg-danger{% elif req.status == 'Processing' %}bg-warning{% else %}bg-secondary{% endif %}">
{{ req.status }}
</span>
</td>
<td>{{ req.created_at }}</td>
<td>
<button class="btn btn-sm btn-info" onclick="viewSignature('{{ req.id }}')">
View
</button>
<button class="btn btn-sm btn-danger" onclick="deleteSignature('{{ req.id }}')">
Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No pending signatures. Submit a request using the form on the left.</p>
{% endif %}
</div>
<!-- Signature details modal -->
<div class="modal fade" id="signatureDetailsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Signature Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="signature-details-content">
<!-- Content will be loaded dynamically -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div style="text-align: center; margin-top: 30px;">
<p>
This demo uses the SigSocket WebSocket-based signing service.
Make sure a SigSocket client is connected with the matching public key.
</p>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed bottom-0 start-0 p-3" style="z-index: 11; width: 100%;">
<!-- Toasts will be added here dynamically -->
</div>
<script>
// Auto-refresh signature list every 2 seconds
let refreshTimer;
let signatureDetailsModal;
document.addEventListener('DOMContentLoaded', function() {
// Initialize the signature details modal
signatureDetailsModal = new bootstrap.Modal(document.getElementById('signatureDetailsModal'));
// Start auto-refresh
startAutoRefresh();
});
function startAutoRefresh() {
// Clear any existing timer
if (refreshTimer) {
clearInterval(refreshTimer);
}
// Setup timer to refresh signatures every 2 seconds
refreshTimer = setInterval(refreshSignatures, 2000);
console.log('Auto-refresh started');
}
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
console.log('Auto-refresh stopped');
}
}
function refreshSignatures() {
fetch('/api/signatures/all')
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
updateSignatureTable(data.requests);
}
})
.catch(err => {
console.error('Error refreshing signatures: ' + err);
stopAutoRefresh(); // Stop on error
});
}
function updateSignatureTable(signatures) {
const tableBody = document.querySelector('#signature-list table tbody');
if (!tableBody && signatures.length > 0) {
// No table exists but we have signatures - reload the page
window.location.reload();
return;
} else if (!tableBody) {
return; // No table and no signatures - nothing to do
}
if (signatures.length === 0) {
document.getElementById('signature-list').innerHTML = '<p>No pending signatures. Submit a request using the form on the left.</p>';
return;
}
// Update existing rows and add new ones
let existingIds = Array.from(tableBody.querySelectorAll('tr')).map(row => row.id.replace('signature-row-', ''));
signatures.forEach(sig => {
const rowId = 'signature-row-' + sig.id;
let row = document.getElementById(rowId);
if (row) {
// Update existing row
updateSignatureRow(row, sig);
// Remove from existingIds
existingIds = existingIds.filter(id => id !== sig.id);
} else {
// Create new row
row = document.createElement('tr');
row.id = rowId;
updateSignatureRow(row, sig);
tableBody.appendChild(row);
}
});
// Remove rows that no longer exist
existingIds.forEach(id => {
const row = document.getElementById('signature-row-' + id);
if (row) row.remove();
});
}
function updateSignatureRow(row, sig) {
// Set row class based on status
row.className = '';
if (sig.status === 'Success') {
row.className = 'table-success';
} else if (sig.status === 'Error' || sig.status === 'Timeout') {
row.className = 'table-danger';
} else if (sig.status === 'Processing') {
row.className = 'table-warning';
} else {
row.className = 'table-light';
}
// Update row content
row.innerHTML = `
<td>${sig.id.substring(0, 8)}</td>
<td>${sig.message.length > 20 ? sig.message.substring(0, 20) + '...' : sig.message}</td>
<td>
<span class="badge rounded-pill ${getBadgeClass(sig.status)}">
${sig.status}
</span>
</td>
<td>${sig.created_at}</td>
<td>
<button class="btn btn-sm btn-info" onclick="viewSignature('${sig.id}')">
View
</button>
<button class="btn btn-sm btn-danger" onclick="deleteSignature('${sig.id}')">
Delete
</button>
</td>
`;
}
function getBadgeClass(status) {
switch(status) {
case 'Success': return 'bg-success';
case 'Error': case 'Timeout': return 'bg-danger';
case 'Processing': return 'bg-warning';
default: return 'bg-secondary';
}
}
function viewSignature(id) {
fetch(`/api/signatures/${id}`)
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
displaySignatureDetails(data.request);
signatureDetailsModal.show();
} else {
showToast('Error: ' + data.message, 'danger');
}
})
.catch(err => {
showToast('Error loading signature details: ' + err, 'danger');
});
}
function displaySignatureDetails(signature) {
const content = document.getElementById('signature-details-content');
let statusClass = '';
if (signature.status === 'Success') statusClass = 'text-success';
else if (signature.status === 'Error' || signature.status === 'Timeout') statusClass = 'text-danger';
else if (signature.status === 'Processing') statusClass = 'text-warning';
content.innerHTML = `
<div class="card mb-3">
<div class="card-header d-flex justify-content-between">
<h5>Request ID: ${signature.id}</h5>
<h5 class="${statusClass}">Status: ${signature.status}</h5>
</div>
<div class="card-body">
<div class="mb-3">
<h6>Public Key:</h6>
<pre class="bg-light p-2">${signature.public_key || 'N/A'}</pre>
</div>
<div class="mb-3">
<h6>Message:</h6>
<pre class="bg-light p-2">${signature.message}</pre>
</div>
${signature.signature ? `
<div class="mb-3">
<h6>Signature (base64):</h6>
<pre class="bg-light p-2">${signature.signature}</pre>
</div>` : ''}
${signature.error ? `
<div class="mb-3">
<h6 class="text-danger">Error:</h6>
<pre class="bg-light p-2">${signature.error}</pre>
</div>` : ''}
<div class="row">
<div class="col">
<p><strong>Created:</strong> ${signature.created_at}</p>
</div>
<div class="col">
<p><strong>Last Updated:</strong> ${signature.updated_at}</p>
</div>
</div>
</div>
</div>
`;
}
function deleteSignature(id) {
if (confirm('Are you sure you want to delete this signature request?')) {
fetch(`/api/signatures/${id}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
showToast(data.message, 'info');
refreshSignatures(); // Refresh immediately
} else {
showToast('Error: ' + data.message, 'danger');
}
})
.catch(err => {
showToast('Error deleting signature: ' + err, 'danger');
});
}
}
// Override console.log to show toast messages
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
console.log = function(message) {
// Call the original console.log
originalConsoleLog.apply(console, arguments);
// Show toast with the message
showToast(message, 'info');
};
console.error = function(message) {
// Call the original console.error
originalConsoleError.apply(console, arguments);
// Show toast with the error message
showToast(message, 'danger');
};
function showToast(message, type = 'info') {
// Create toast element
const toastId = 'toast-' + Date.now();
const toastElement = document.createElement('div');
toastElement.id = toastId;
toastElement.className = 'toast w-100';
toastElement.setAttribute('role', 'alert');
toastElement.setAttribute('aria-live', 'assertive');
toastElement.setAttribute('aria-atomic', 'true');
// Set toast content
toastElement.innerHTML = `
<div class="toast-header bg-${type} text-white">
<strong class="me-auto">${type === 'danger' ? 'Error' : 'Info'}</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
${message}
</div>
`;
// Append to container
document.querySelector('.toast-container').appendChild(toastElement);
// Initialize and show the toast
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: 5000
});
toast.show();
// Remove toast after it's hidden
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
});
}
// Test toast
console.log('Web app loaded successfully!');
</script>
</body>
</html>

View File

@ -1,333 +0,0 @@
use crate::error::SigSocketError;
use secp256k1::{Secp256k1, Message, PublicKey};
use secp256k1::ecdsa::Signature;
use sha2::{Sha256, Digest};
use base64::{Engine as _, engine::general_purpose};
use log::{info, warn, error, debug};
pub struct SignatureVerifier;
impl SignatureVerifier {
/// Verify a signature using secp256k1
pub fn verify_signature(
public_key_hex: &str,
message: &[u8],
signature_hex: &str
) -> Result<bool, SigSocketError> {
info!("Verifying signature with public key: {}", public_key_hex);
debug!("Message to verify: {:?}", message);
debug!("Message as string: {}", String::from_utf8_lossy(message));
debug!("Signature hex: {}", signature_hex);
// 1. Parse the public key
let public_key_bytes = match hex::decode(public_key_hex) {
Ok(bytes) => {
debug!("Decoded public key bytes: {:?}", bytes);
bytes
},
Err(e) => {
error!("Failed to decode public key hex: {}", e);
return Err(SigSocketError::InvalidPublicKey);
}
};
let public_key = match PublicKey::from_slice(&public_key_bytes) {
Ok(pk) => {
debug!("Successfully parsed public key");
pk
},
Err(e) => {
error!("Failed to parse public key from bytes: {}", e);
return Err(SigSocketError::InvalidPublicKey);
}
};
// 2. Parse the signature
let signature_bytes = match hex::decode(signature_hex) {
Ok(bytes) => {
debug!("Decoded signature bytes: {:?}", bytes);
debug!("Signature byte length: {}", bytes.len());
bytes
},
Err(e) => {
error!("Failed to decode signature hex: {}", e);
return Err(SigSocketError::InvalidSignature);
}
};
let signature = match Signature::from_compact(&signature_bytes) {
Ok(sig) => {
debug!("Successfully parsed signature");
sig
},
Err(e) => {
error!("Failed to parse signature from bytes: {}", e);
error!("Signature bytes: {:?}", signature_bytes);
return Err(SigSocketError::InvalidSignature);
}
};
// 3. Hash the message (secp256k1 requires a 32-byte hash)
let mut hasher = Sha256::new();
hasher.update(message);
let message_hash = hasher.finalize();
debug!("Message hash: {:?}", message_hash);
// 4. Create a secp256k1 message from the hash
let secp_message = match Message::from_digest_slice(&message_hash) {
Ok(msg) => {
debug!("Successfully created secp256k1 message");
msg
},
Err(e) => {
error!("Failed to create secp256k1 message: {}", e);
return Err(SigSocketError::InternalError);
}
};
// 5. Verify the signature
let secp = Secp256k1::verification_only();
match secp.verify_ecdsa(&secp_message, &signature, &public_key) {
Ok(_) => {
info!("Signature verification succeeded!");
Ok(true)
},
Err(e) => {
warn!("Signature verification failed: {}", e);
Ok(false)
},
}
}
/// Encode data to base64
pub fn encode_base64(data: &[u8]) -> String {
general_purpose::STANDARD.encode(data)
}
/// Decode a base64 string
pub fn decode_base64(encoded: &str) -> Result<Vec<u8>, SigSocketError> {
general_purpose::STANDARD
.decode(encoded)
.map_err(|_| SigSocketError::DecodingError)
}
/// Encode data to hex
pub fn encode_hex(data: &[u8]) -> String {
hex::encode(data)
}
/// Decode a hex string
pub fn decode_hex(encoded: &str) -> Result<Vec<u8>, SigSocketError> {
hex::decode(encoded)
.map_err(SigSocketError::HexError)
}
/// Parse a response in the "message.signature" format
pub fn parse_response(
response: &str,
) -> Result<(Vec<u8>, Vec<u8>), SigSocketError> {
debug!("Parsing response: {}", response);
// Split the response by '.'
let parts: Vec<&str> = response.split('.').collect();
debug!("Split response into {} parts", parts.len());
if parts.len() != 2 {
error!("Invalid response format: expected 2 parts, got {}", parts.len());
return Err(SigSocketError::InvalidResponseFormat);
}
let message_b64 = parts[0];
let signature_b64 = parts[1];
debug!("Message part (base64): {}", message_b64);
debug!("Signature part (base64): {}", signature_b64);
// Decode base64 parts
let message = match Self::decode_base64(message_b64) {
Ok(m) => {
debug!("Decoded message (bytes): {:?}", m);
debug!("Decoded message length: {} bytes", m.len());
m
},
Err(e) => {
error!("Failed to decode message: {}", e);
return Err(e);
}
};
let signature = match Self::decode_base64(signature_b64) {
Ok(s) => {
debug!("Decoded signature (bytes): {:?}", s);
debug!("Decoded signature length: {} bytes", s.len());
s
},
Err(e) => {
error!("Failed to decode signature: {}", e);
return Err(e);
}
};
info!("Successfully parsed response with message length {} and signature length {}",
message.len(), signature.len());
Ok((message, signature))
}
/// Format a response in the "message.signature" format
pub fn format_response(message: &[u8], signature: &[u8]) -> String {
format!(
"{}.{}",
Self::encode_base64(message),
Self::encode_base64(signature)
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::{rngs::OsRng, Rng};
#[test]
fn test_encode_decode_base64() {
let test_data = b"Hello, World!";
// Test encoding
let encoded = SignatureVerifier::encode_base64(test_data);
// Test decoding
let decoded = SignatureVerifier::decode_base64(&encoded).unwrap();
assert_eq!(test_data.to_vec(), decoded);
}
#[test]
fn test_encode_decode_hex() {
let test_data = b"Hello, World!";
// Test encoding
let encoded = SignatureVerifier::encode_hex(test_data);
// Test decoding
let decoded = SignatureVerifier::decode_hex(&encoded).unwrap();
assert_eq!(test_data.to_vec(), decoded);
}
#[test]
fn test_parse_format_response() {
let message = b"Test message";
let signature = b"Test signature";
// Format response
let formatted = SignatureVerifier::format_response(message, signature);
// Parse response
let (parsed_message, parsed_signature) = SignatureVerifier::parse_response(&formatted).unwrap();
assert_eq!(message.to_vec(), parsed_message);
assert_eq!(signature.to_vec(), parsed_signature);
}
#[test]
fn test_invalid_response_format() {
// Invalid format (no separator)
let invalid = "invalid_format_no_separator";
let result = SignatureVerifier::parse_response(invalid);
assert!(result.is_err());
if let Err(e) = result {
assert!(matches!(e, SigSocketError::InvalidResponseFormat));
}
}
#[test]
fn test_verify_signature_valid() {
// Create a secp256k1 context
let secp = Secp256k1::new();
// Generate a random private key
let mut rng = OsRng::default();
let mut secret_key_bytes = [0u8; 32];
rng.fill(&mut secret_key_bytes);
// Create a secret key from random bytes
let secret_key = secp256k1::SecretKey::from_slice(&secret_key_bytes).unwrap();
// Derive the public key
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
// Convert to hex for our API
let public_key_hex = hex::encode(public_key.serialize());
// Message to sign
let message = b"Test message for signing";
// Hash the message (required for secp256k1)
let mut hasher = Sha256::new();
hasher.update(message);
let message_hash = hasher.finalize();
// Create a signature
let msg = Message::from_digest_slice(&message_hash).unwrap();
let signature = secp.sign_ecdsa(&msg, &secret_key);
// Convert signature to hex
let signature_hex = hex::encode(signature.serialize_compact());
// Verify the signature using our API
let result = SignatureVerifier::verify_signature(
&public_key_hex,
message,
&signature_hex
).unwrap();
assert!(result);
}
#[test]
fn test_verify_signature_invalid() {
// Create a secp256k1 context
let secp = Secp256k1::new();
// Generate two different private keys
let mut rng = OsRng::default();
let mut secret_key_bytes1 = [0u8; 32];
let mut secret_key_bytes2 = [0u8; 32];
rng.fill(&mut secret_key_bytes1);
rng.fill(&mut secret_key_bytes2);
// Create secret keys from random bytes
let secret_key = secp256k1::SecretKey::from_slice(&secret_key_bytes1).unwrap();
let wrong_secret_key = secp256k1::SecretKey::from_slice(&secret_key_bytes2).unwrap();
// Derive the public key from the first private key
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
// Convert to hex for our API
let public_key_hex = hex::encode(public_key.serialize());
// Message to sign
let message = b"Test message for signing";
// Hash the message (required for secp256k1)
let mut hasher = Sha256::new();
hasher.update(message);
let message_hash = hasher.finalize();
// Create a signature with the WRONG key
let msg = Message::from_digest_slice(&message_hash).unwrap();
let wrong_signature = secp.sign_ecdsa(&msg, &wrong_secret_key);
// Convert signature to hex
let signature_hex = hex::encode(wrong_signature.serialize_compact());
// Verify the signature using our API (should fail)
let result = SignatureVerifier::verify_signature(
&public_key_hex,
message,
&signature_hex
).unwrap();
assert!(!result);
}
}

View File

@ -1,41 +0,0 @@
use actix_web_actors::ws;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SigSocketError {
#[error("Connection not found for the provided public key")]
ConnectionNotFound,
#[error("Timeout waiting for signature")]
Timeout,
#[error("Invalid signature")]
InvalidSignature,
#[error("Channel closed unexpectedly")]
ChannelClosed,
#[error("Invalid response format, expected 'message.signature'")]
InvalidResponseFormat,
#[error("Error decoding base64 message or signature")]
DecodingError,
#[error("Invalid public key format")]
InvalidPublicKey,
#[error("Internal cryptographic error")]
InternalError,
#[error("Failed to send message to client")]
SendError,
#[error("WebSocket error: {0}")]
WebSocketError(#[from] ws::ProtocolError),
#[error("Base64 decoding error: {0}")]
Base64Error(#[from] base64::DecodeError),
#[error("Hex decoding error: {0}")]
HexError(#[from] hex::FromHexError),
}

View File

@ -1,105 +0,0 @@
use std::sync::{Arc, RwLock};
use std::collections::HashMap;
use tokio::sync::oneshot;
use uuid::Uuid;
use log::warn;
use crate::registry::ConnectionRegistry;
use crate::error::SigSocketError;
use crate::protocol::SignResponse;
/// Handler for message operations
pub struct MessageHandler {
registry: Arc<RwLock<ConnectionRegistry>>,
pending_requests: Arc<RwLock<HashMap<String, oneshot::Sender<SignResponse>>>>,
}
impl MessageHandler {
pub fn new(registry: Arc<RwLock<ConnectionRegistry>>) -> Self {
Self {
registry,
pending_requests: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Send a message to be signed by a specific client
pub async fn send_to_sign(
&self,
public_key: &str,
message: &[u8],
) -> Result<(Vec<u8>, Vec<u8>), SigSocketError> {
// 1. Find the connection for the public key
// For testing, we'll skip the actual connection lookup
let _connection = {
let registry = self.registry.read().map_err(|_| {
SigSocketError::InternalError
})?;
// For testing purposes, we'll just pretend we have a connection
// In real implementation, we would do: registry.get_cloned(public_key).ok_or(SigSocketError::ConnectionNotFound)?
// But for tests, we'll just return a dummy value
"dummy_connection"
};
// 2. Create a unique request ID
let request_id = Uuid::new_v4().to_string();
// 3. Create a response channel
let (tx, rx) = oneshot::channel();
// 4. Register the pending request (skipped for testing to avoid moved value issue)
// In a real implementation, we would register the tx in a hashmap
// But for testing, we'll just use it directly
// 5. Send the message to the client
// In this implementation, we'd need a custom message type that the SigSocketManager
// can handle. For now, we'll simulate sending directly
let _message_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, message);
// For testing we'll immediately simulate a success response
let _ = tx.send(SignResponse {
message: message.to_vec(),
signature: vec![1, 2, 3, 4], // Dummy signature for testing
request_id,
});
// 6. Wait for the response with a timeout
match tokio::time::timeout(std::time::Duration::from_secs(60), rx).await {
Ok(Ok(response)) => {
// 7. Return the message and signature
Ok((response.message, response.signature))
},
Ok(Err(_)) => Err(SigSocketError::ChannelClosed),
Err(_) => Err(SigSocketError::Timeout),
}
}
/// Process a signed response
pub fn process_response(
&self,
request_id: &str,
message: Vec<u8>,
signature: Vec<u8>,
) -> Result<(), SigSocketError> {
// Find the pending request
let tx = {
let mut pending = self.pending_requests.write().map_err(|_| {
SigSocketError::InternalError
})?;
pending.remove(request_id).ok_or(SigSocketError::ConnectionNotFound)?
};
// Send the response
if let Err(_) = tx.send(SignResponse {
message,
signature,
request_id: request_id.to_string(),
}) {
warn!("Failed to send response for request {}", request_id);
return Err(SigSocketError::ChannelClosed);
}
Ok(())
}
}

View File

@ -1,13 +0,0 @@
pub mod manager;
pub mod registry;
pub mod handler;
pub mod protocol;
pub mod crypto;
pub mod service;
pub mod error;
// Re-export main components for easier access
pub use manager::SigSocketManager;
pub use registry::ConnectionRegistry;
pub use service::SigSocketService;
pub use error::SigSocketError;

View File

@ -1,140 +0,0 @@
use std::sync::{Arc, RwLock};
use actix_web::{web, App, HttpServer, HttpResponse, Responder};
use serde::{Deserialize, Serialize};
use log::info;
use sigsocket::{
ConnectionRegistry,
SigSocketService,
service::sigsocket_handler,
};
#[derive(Deserialize)]
struct SignRequest {
public_key: String,
message: String,
}
#[derive(Serialize)]
struct SignResponse {
response: String,
signature: String,
}
// Handler for sign requests
async fn handle_sign_request(
service: web::Data<Arc<SigSocketService>>,
req: web::Json<SignRequest>,
) -> impl Responder {
// Decode the base64 message
let message = match base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
&req.message
) {
Ok(m) => m,
Err(_) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "Invalid base64 encoding for message"
}));
}
};
// Send the message to be signed
match service.send_to_sign(&req.public_key, &message).await {
Ok((response, signature)) => {
// Encode the response and signature in base64
let response_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&response
);
let signature_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&signature
);
HttpResponse::Ok().json(SignResponse {
response: response_b64,
signature: signature_b64,
})
}
Err(e) => {
HttpResponse::InternalServerError().json(serde_json::json!({
"error": e.to_string()
}))
}
}
}
// Handler for connection status
async fn connection_status(service: web::Data<Arc<SigSocketService>>) -> impl Responder {
match service.connection_count() {
Ok(count) => {
HttpResponse::Ok().json(serde_json::json!({
"connections": count
}))
}
Err(e) => {
HttpResponse::InternalServerError().json(serde_json::json!({
"error": e.to_string()
}))
}
}
}
// Handler for checking if a client is connected
async fn check_connected(
service: web::Data<Arc<SigSocketService>>,
public_key: web::Path<String>,
) -> impl Responder {
match service.is_connected(&public_key) {
Ok(connected) => {
HttpResponse::Ok().json(serde_json::json!({
"connected": connected
}))
}
Err(e) => {
HttpResponse::InternalServerError().json(serde_json::json!({
"error": e.to_string()
}))
}
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// Initialize the logger
env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));
// Create the connection registry
let registry = Arc::new(RwLock::new(ConnectionRegistry::new()));
// Create the SigSocket service
let sigsocket_service = Arc::new(SigSocketService::new(registry.clone()));
info!("Starting SigSocket server on 127.0.0.1:8080");
// Start the HTTP server
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(sigsocket_service.clone()))
.service(
web::resource("/ws")
.route(web::get().to(sigsocket_handler))
)
.service(
web::resource("/sign")
.route(web::post().to(handle_sign_request))
)
.service(
web::resource("/status")
.route(web::get().to(connection_status))
)
.service(
web::resource("/connected/{public_key}")
.route(web::get().to(check_connected))
)
})
.bind("127.0.0.1:8080")?
.run()
.await
}

View File

@ -1,314 +0,0 @@
use std::time::{Duration, Instant};
use std::sync::{Arc, RwLock};
use std::collections::HashMap;
use actix::prelude::*;
use actix_web_actors::ws;
use crate::protocol::SignRequest;
use crate::registry::ConnectionRegistry;
use crate::crypto::SignatureVerifier;
use uuid::Uuid;
use log::{info, warn, error};
use sha2::{Sha256, Digest};
// Heartbeat functionality has been removed
/// WebSocket connection manager for handling signing operations
pub struct SigSocketManager {
/// Registry of connections
pub registry: Arc<RwLock<ConnectionRegistry>>,
/// Public key of the connection
pub public_key: Option<String>,
/// Pending requests with their response channels
pub pending_requests: HashMap<String, tokio::sync::oneshot::Sender<String>>,
}
impl SigSocketManager {
pub fn new(registry: Arc<RwLock<ConnectionRegistry>>) -> Self {
Self {
registry,
public_key: None,
pending_requests: HashMap::new(),
}
}
// Heartbeat functionality has been removed
/// Helper method to extract request ID from a message
fn extract_request_id(&self, message: &str) -> Option<String> {
// The client sends the original base64 message, which is the request ID directly
// But try to be robust in case the format changes
// First try to handle the case where the message is exactly the request ID
if message.len() >= 8 && message.contains('-') {
// This looks like it might be a UUID directly
return Some(message.to_string());
}
// Next try to parse as JSON (in case we get a JSON structure)
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(message) {
if let Some(id) = parsed.get("id").and_then(|v| v.as_str()) {
return Some(id.to_string());
}
}
// Finally, just treat the entire message as the key
// This is a fallback and may not find a match
info!("Using full message as request ID fallback: {}", message);
Some(message.to_string())
}
/// Process messages received over the websocket
fn handle_text_message(&mut self, text: String, ctx: &mut ws::WebsocketContext<Self>) {
// If this is the first message and we don't have a public key yet, treat it as an introduction
if self.public_key.is_none() {
// Validate the public key format
match hex::decode(&text) {
Ok(pk_bytes) => {
// Further validate with secp256k1
match secp256k1::PublicKey::from_slice(&pk_bytes) {
Ok(_) => {
// This is a valid public key, register it
info!("Registered connection for public key: {}", text);
self.public_key = Some(text.clone());
// Register in the connection registry
if let Ok(mut registry) = self.registry.write() {
registry.register(text.clone(), ctx.address());
}
// Acknowledge
ctx.text("Connected");
}
Err(_) => {
warn!("Invalid secp256k1 public key format: {}", text);
ctx.text("Invalid public key format - must be valid secp256k1");
ctx.close(Some(ws::CloseReason {
code: ws::CloseCode::Invalid,
description: Some("Invalid public key format".into()),
}));
}
}
}
Err(e) => {
error!("Invalid hex format for public key: {}", e);
ctx.text("Invalid public key format - must be hex encoded");
ctx.close(Some(ws::CloseReason {
code: ws::CloseCode::Invalid,
description: Some("Invalid public key format".into()),
}));
}
}
return;
}
// If we have a public key, this is either a response to a signing request
// New Format: JSON with id, message, signature fields
info!("Received message from client with public key: {}", self.public_key.as_ref().unwrap_or(&"<NONE>".to_string()));
info!("Raw message content: {}", text);
// Special case for confirmation message
if text == "CONFIRM_SIGNATURE_SENT" {
info!("Received confirmation message after signature");
return;
}
// Try to parse the message as JSON
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(json) => {
info!("Successfully parsed message as JSON");
// Extract fields from the JSON response
let request_id = json.get("id").and_then(|v| v.as_str());
let message_b64 = json.get("message").and_then(|v| v.as_str());
let signature_b64 = json.get("signature").and_then(|v| v.as_str());
match (request_id, message_b64, signature_b64) {
(Some(id), Some(message), Some(signature)) => {
info!("Extracted request ID: {}", id);
info!("Parsed message part (base64): {}", message);
info!("Parsed signature part (base64): {}", signature);
// Try to decode both parts
info!("Attempting to decode base64 message and signature");
match (
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, message),
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, signature),
) {
(Ok(message), Ok(signature)) => {
info!("Successfully decoded message and signature");
info!("Message bytes (decoded): {:?}", message);
info!("Signature bytes (length): {} bytes", signature.len());
// Calculate the message hash (this is implementation specific)
let mut hasher = Sha256::new();
hasher.update(&message);
let message_hash = hasher.finalize();
info!("Calculated message hash: {:?}", message_hash);
// Verify the signature with the public key
if let Some(ref public_key) = self.public_key {
info!("Using public key for verification: {}", public_key);
let sig_hex = hex::encode(&signature);
info!("Signature (hex): {}", sig_hex);
info!("!!! ATTEMPTING SIGNATURE VERIFICATION !!!");
match SignatureVerifier::verify_signature(
public_key,
&message,
&sig_hex,
) {
Ok(true) => {
info!("!!! SIGNATURE VERIFICATION SUCCESSFUL !!!");
// We already have the request ID from the JSON!
info!("Using request ID directly from JSON: {}", id);
// Find and complete the pending request using the ID from the JSON
if let Some(sender) = self.pending_requests.remove(id) {
info!("Found pending request with ID: {}", id);
// Format the message and signature for the receiver
// Use base64 for BOTH message and signature as per the protocol requirements
let response = format!("{}.{}",
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &message),
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &signature));
info!("Formatted response: {} (truncated for log)",
if response.len() > 50 { &response[..50] } else { &response });
// Send the response directly using the stored channel
info!("Sending signature via direct response channel");
if sender.send(response).is_err() {
error!("Failed to send signature via response channel for request {}", id);
} else {
info!("!!! SUCCESSFULLY SENT SIGNATURE VIA RESPONSE CHANNEL FOR REQUEST {} !!!", id);
}
} else {
error!("No pending request found with ID: {}", id);
info!("Current pending requests: {:?}", self.pending_requests.keys().collect::<Vec<_>>());
}
},
Ok(false) => {
warn!("!!! SIGNATURE VERIFICATION FAILED - INVALID SIGNATURE !!!");
ctx.text("Invalid signature");
},
Err(e) => {
error!("!!! SIGNATURE VERIFICATION ERROR: {} !!!", e);
ctx.text("Error verifying signature");
}
}
} else {
error!("Missing public key for verification");
ctx.text("Missing public key for verification");
}
},
(Err(e1), _) => {
warn!("Failed to decode base64 message: {}", e1);
ctx.text("Invalid base64 encoding in message");
},
(_, Err(e2)) => {
warn!("Failed to decode base64 signature: {}", e2);
ctx.text("Invalid base64 encoding in signature");
}
}
},
_ => {
warn!("Missing required fields in JSON response");
ctx.text("Missing required fields in JSON response");
}
}
},
Err(e) => {
warn!("Received message in invalid JSON format: {} - {}", text, e);
ctx.text("Invalid JSON format");
}
}
}
}
/// Handler for SignRequest message
impl Handler<SignRequest> for SigSocketManager {
type Result = ();
fn handle(&mut self, msg: SignRequest, ctx: &mut Self::Context) {
// We'll only process sign requests if we have a valid public key
if self.public_key.is_none() {
error!("Received sign request for connection without a public key");
return;
}
// Debug log the current pending requests in the manager
info!("*** MANAGER: Current pending requests before handling sign request: {:?} ***",
self.pending_requests.keys().collect::<Vec<_>>());
// If we received a response sender, store it for later
if let Some(sender) = msg.response_sender {
// Store the request ID and sender in our pending requests map
self.pending_requests.insert(msg.request_id.clone(), sender);
info!("*** MANAGER: Added pending request with response channel: {} ***", msg.request_id);
info!("*** MANAGER: Current pending requests after adding: {:?} ***",
self.pending_requests.keys().collect::<Vec<_>>());
} else {
warn!("Received SignRequest without response channel for ID: {}", msg.request_id);
}
// Create JSON message to send to the client
let message_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &msg.message);
let request_json = format!("{{\"id\": \"{}\", \"message\": \"{}\"}}",
msg.request_id, message_b64);
// Send the request to the client
ctx.text(request_json);
info!("Sent sign request {} to client {}", msg.request_id, self.public_key.as_ref().unwrap());
}
}
/// Handler for WebSocket messages
impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for SigSocketManager {
fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {
match msg {
Ok(ws::Message::Ping(msg)) => {
// Simply respond to ping with pong - no heartbeat tracking
ctx.pong(&msg);
}
Ok(ws::Message::Pong(_)) => {
// No need to track heartbeat anymore
}
Ok(ws::Message::Text(text)) => {
self.handle_text_message(text.to_string(), ctx);
}
Ok(ws::Message::Binary(_)) => {
// We don't expect binary messages in this protocol
warn!("Unexpected binary message received");
}
Ok(ws::Message::Close(reason)) => {
info!("Client disconnected");
ctx.close(reason);
ctx.stop();
}
_ => ctx.stop(),
}
}
}
impl Actor for SigSocketManager {
type Context = ws::WebsocketContext<Self>;
fn started(&mut self, _ctx: &mut Self::Context) {
// Heartbeat functionality has been removed
info!("WebSocket connection established");
}
fn stopped(&mut self, _ctx: &mut Self::Context) {
// Unregister from the registry if we have a public key
if let Some(ref pk) = self.public_key {
info!("WebSocket connection closed for {}", pk);
if let Ok(mut registry) = self.registry.write() {
registry.unregister(pk);
}
}
}
}

View File

@ -1,297 +0,0 @@
use std::time::{Duration, Instant};
use std::sync::{Arc, RwLock};
use std::collections::HashMap;
use actix::prelude::*;
use actix_web_actors::ws;
use crate::protocol::{SignRequest};
use crate::registry::ConnectionRegistry;
use crate::crypto::SignatureVerifier;
use uuid::Uuid;
use log::{info, warn, error};
use sha2::{Sha256, Digest};
// Heartbeat functionality has been removed
/// WebSocket connection manager for handling signing operations
pub struct SigSocketManager {
/// Registry of connections
pub registry: Arc<RwLock<ConnectionRegistry>>,
/// Public key of the connection
pub public_key: Option<String>,
/// Pending requests from this connection
pub pending_requests: HashMap<String, tokio::sync::oneshot::Sender<String>>,
}
impl SigSocketManager {
pub fn new(registry: Arc<RwLock<ConnectionRegistry>>) -> Self {
Self {
registry,
public_key: None,
pending_requests: HashMap::new(),
}
}
// Heartbeat functionality has been removed
/// Helper method to extract request ID from a message
fn extract_request_id(&self, message: &str) -> Option<String> {
// The client sends the original base64 message, which is the request ID directly
// But try to be robust in case the format changes
// First try to handle the case where the message is exactly the request ID
if message.len() >= 8 && message.contains('-') {
// This looks like it might be a UUID directly
return Some(message.to_string());
}
// Next try to parse as JSON (in case we get a JSON structure)
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(message) {
if let Some(id) = parsed.get("id").and_then(|v| v.as_str()) {
return Some(id.to_string());
}
}
// Finally, just treat the entire message as the key
// This is a fallback and may not find a match
info!("Using full message as request ID fallback: {}", message);
Some(message.to_string())
}
/// Process messages received over the websocket
fn handle_text_message(&mut self, text: String, ctx: &mut ws::WebsocketContext<Self>) {
// If this is the first message and we don't have a public key yet, treat it as an introduction
if self.public_key.is_none() {
// Validate the public key format
match hex::decode(&text) {
Ok(pk_bytes) => {
// Further validate with secp256k1
match secp256k1::PublicKey::from_slice(&pk_bytes) {
Ok(_) => {
// This is a valid public key, register it
info!("Registered connection for public key: {}", text);
self.public_key = Some(text.clone());
// Register in the connection registry
if let Ok(mut registry) = self.registry.write() {
registry.register(&text, ctx.address());
}
// Acknowledge
ctx.text("Connected");
}
Err(_) => {
warn!("Invalid secp256k1 public key format: {}", text);
ctx.text("Invalid public key format - must be valid secp256k1");
ctx.close(Some(ws::CloseReason {
code: ws::CloseCode::Invalid,
description: Some("Invalid public key format".into()),
}));
}
}
}
Err(e) => {
error!("Invalid hex format for public key: {}", e);
ctx.text("Invalid public key format - must be hex encoded");
ctx.close(Some(ws::CloseReason {
code: ws::CloseCode::Invalid,
description: Some("Invalid public key format".into()),
}));
}
}
return;
}
// If we have a public key, this is either a response to a signing request
// New Format: JSON with id, message, signature fields
info!("Received message from client with public key: {}", self.public_key.as_ref().unwrap_or(&"<NONE>".to_string()));
info!("Raw message content: {}", text);
// Special case for confirmation message
if text == "CONFIRM_SIGNATURE_SENT" {
info!("Received confirmation message after signature");
return;
}
// Try to parse the message as JSON
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(json) => {
info!("Successfully parsed message as JSON");
// Extract fields from the JSON response
let request_id = json.get("id").and_then(|v| v.as_str());
let message_b64 = json.get("message").and_then(|v| v.as_str());
let signature_b64 = json.get("signature").and_then(|v| v.as_str());
match (request_id, message_b64, signature_b64) {
(Some(id), Some(message), Some(signature)) => {
info!("Extracted request ID: {}", id);
info!("Parsed message part (base64): {}", message);
info!("Parsed signature part (base64): {}", signature);
// Try to decode both parts
info!("Attempting to decode base64 message and signature");
match (
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, message),
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, signature),
) {
(Ok(message), Ok(signature)) => {
info!("Successfully decoded message and signature");
info!("Message bytes (decoded): {:?}", message);
info!("Signature bytes (length): {} bytes", signature.len());
// Calculate the message hash (this is implementation specific)
let mut hasher = Sha256::new();
hasher.update(&message);
let message_hash = hasher.finalize();
info!("Calculated message hash: {:?}", message_hash);
// Verify the signature with the public key
if let Some(ref public_key) = self.public_key {
info!("Using public key for verification: {}", public_key);
let sig_hex = hex::encode(&signature);
info!("Signature (hex): {}", sig_hex);
info!("!!! ATTEMPTING SIGNATURE VERIFICATION !!!");
match SignatureVerifier::verify_signature(
public_key,
&message,
&sig_hex,
) {
Ok(true) => {
info!("!!! SIGNATURE VERIFICATION SUCCESSFUL !!!");
// We already have the request ID from the JSON!
info!("Using request ID directly from JSON: {}", id);
// Find and complete the pending request using the ID from the JSON
if let Some(sender) = self.pending_requests.remove(id) {
info!("Found pending request with ID: {}", id);
// Format the message and signature for the receiver
let response = format!("{}.{}",
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &message),
hex::encode(&signature));
info!("Formatted response for handler: {} (truncated for log)",
if response.len() > 50 { &response[..50] } else { &response });
// Send the response
info!("Sending signature to handler");
if sender.send(response).is_err() {
warn!("Failed to send signature response to handler");
} else {
info!("!!! SUCCESSFULLY SENT SIGNATURE TO HANDLER FOR REQUEST {} !!!", id);
}
} else {
warn!("No pending request found for ID: {}", id);
info!("Currently pending requests: {:?}", self.pending_requests.keys().collect::<Vec<_>>());
}
},
Ok(false) => {
warn!("!!! SIGNATURE VERIFICATION FAILED - INVALID SIGNATURE !!!");
ctx.text("Invalid signature");
},
Err(e) => {
error!("!!! SIGNATURE VERIFICATION ERROR: {} !!!", e);
ctx.text("Error verifying signature");
}
}
} else {
error!("Missing public key for verification");
ctx.text("Missing public key for verification");
}
},
(Err(e1), _) => {
warn!("Failed to decode base64 message: {}", e1);
ctx.text("Invalid base64 encoding in message");
},
(_, Err(e2)) => {
warn!("Failed to decode base64 signature: {}", e2);
ctx.text("Invalid base64 encoding in signature");
}
}
},
_ => {
warn!("Missing required fields in JSON response");
ctx.text("Missing required fields in JSON response");
}
}
},
Err(e) => {
warn!("Received message in invalid JSON format: {} - {}", text, e);
ctx.text("Invalid JSON format");
}
}
}
}
/// Handler for SignRequest message
impl Handler<SignRequest> for SigSocketManager {
type Result = ();
fn handle(&mut self, msg: SignRequest, ctx: &mut Self::Context) {
// We'll only process sign requests if we have a valid public key
if self.public_key.is_none() {
error!("Received sign request for connection without a public key");
return;
}
// Create JSON message to send to the client
let message_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &msg.message);
let request_json = format!("{{\"id\": \"{}\", \"message\": \"{}\"}}",
msg.request_id, message_b64);
// Send the request to the client
ctx.text(request_json);
info!("Sent sign request {} to client {}", msg.request_id, self.public_key.as_ref().unwrap());
}
}
/// Handler for WebSocket messages
impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for SigSocketManager {
fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {
match msg {
Ok(ws::Message::Ping(msg)) => {
// Simply respond to ping with pong - no heartbeat tracking
ctx.pong(&msg);
}
Ok(ws::Message::Pong(_)) => {
// No need to track heartbeat anymore
}
Ok(ws::Message::Text(text)) => {
self.handle_text_message(text.to_string(), ctx);
}
Ok(ws::Message::Binary(_)) => {
// We don't expect binary messages in this protocol
warn!("Unexpected binary message received");
}
Ok(ws::Message::Close(reason)) => {
info!("Client disconnected");
ctx.close(reason);
ctx.stop();
}
_ => ctx.stop(),
}
}
}
impl Actor for SigSocketManager {
type Context = ws::WebsocketContext<Self>;
fn started(&mut self, _ctx: &mut Self::Context) {
// Heartbeat functionality has been removed
info!("WebSocket connection established");
}
fn stopped(&mut self, _ctx: &mut Self::Context) {
// Unregister from the registry if we have a public key
if let Some(ref pk) = self.public_key {
info!("WebSocket connection closed for {}", pk);
if let Ok(mut registry) = self.registry.write() {
registry.unregister(pk);
}
}
}
}

View File

@ -1,45 +0,0 @@
use serde::{Deserialize, Serialize};
use actix::prelude::*;
// Message for client introduction
#[derive(Message)]
#[rtype(result = "()")]
pub struct Introduction {
pub public_key: String,
}
// Message for requesting a signature from a client
#[derive(Message, Debug)]
#[rtype(result = "()")]
pub struct SignRequest {
pub message: Vec<u8>,
pub request_id: String,
pub response_sender: Option<tokio::sync::oneshot::Sender<String>>,
}
/// Response for a signature request
#[derive(Message, Debug)]
#[rtype(result = "()")]
pub struct SignResponse {
pub message: Vec<u8>,
pub signature: Vec<u8>,
pub request_id: String,
}
// Internal message for pending requests
#[derive(Message)]
#[rtype(result = "()")]
pub struct PendingRequest {
pub request_id: String,
pub message: Vec<u8>,
pub response_tx: tokio::sync::oneshot::Sender<String>,
}
// Protocol enum for serializing/deserializing WebSocket messages
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "type", content = "payload")]
pub enum ProtocolMessage {
Introduction(String), // Contains base64 encoded public key
SignRequest(String), // Contains base64 encoded message to sign
SignResponse(String), // Contains "message.signature" in base64
}

View File

@ -1,100 +0,0 @@
use std::collections::HashMap;
use actix::Addr;
use crate::manager::SigSocketManager;
/// Connection Registry: Maps public keys to active WebSocket connections
pub struct ConnectionRegistry {
connections: HashMap<String, Addr<SigSocketManager>>,
}
impl ConnectionRegistry {
/// Create a new connection registry
pub fn new() -> Self {
Self {
connections: HashMap::new(),
}
}
/// Register a connection with a public key
pub fn register(&mut self, public_key: String, addr: Addr<SigSocketManager>) {
log::info!("Registering connection for public key: {}", public_key);
self.connections.insert(public_key, addr);
}
/// Unregister a connection
pub fn unregister(&mut self, public_key: &str) {
log::info!("Unregistering connection for public key: {}", public_key);
self.connections.remove(public_key);
}
/// Get a connection by public key
pub fn get(&self, public_key: &str) -> Option<&Addr<SigSocketManager>> {
self.connections.get(public_key)
}
/// Get a cloned connection by public key
pub fn get_cloned(&self, public_key: &str) -> Option<Addr<SigSocketManager>> {
self.connections.get(public_key).cloned()
}
/// Check if a connection exists
pub fn has_connection(&self, public_key: &str) -> bool {
self.connections.contains_key(public_key)
}
/// Get all connections
pub fn all_connections(&self) -> impl Iterator<Item = (&String, &Addr<SigSocketManager>)> {
self.connections.iter()
}
/// Count active connections
pub fn count(&self) -> usize {
self.connections.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, RwLock};
use actix::Actor;
// A test actor for use with testing
struct TestActor;
impl Actor for TestActor {
type Context = actix::Context<Self>;
}
#[tokio::test]
async fn test_registry_operations() {
// Test the actual ConnectionRegistry without actors
let registry = ConnectionRegistry::new();
// Verify initial state
assert_eq!(registry.count(), 0);
assert!(!registry.has_connection("test_key"));
// We can't directly register actors in the test, but we can test
// the rest of the functionality
// We could implement more mock-based tests here if needed
// but for simplicity, we'll just verify the basic construction works
}
#[tokio::test]
async fn test_shared_registry() {
// Test the shared registry with read/write locks
let registry = Arc::new(RwLock::new(ConnectionRegistry::new()));
// Verify initial state through read lock
{
let read_registry = registry.read().unwrap();
assert_eq!(read_registry.count(), 0);
assert!(!read_registry.has_connection("test_key"));
}
// We can't register actors in the test, but we can verify the locking works
assert_eq!(registry.read().unwrap().count(), 0);
}
}

View File

@ -1,140 +0,0 @@
use std::sync::{Arc, RwLock};
use std::collections::HashMap;
use tokio::sync::oneshot;
use tokio::time::Duration;
use actix_web_actors::ws;
use uuid::Uuid;
use log::{info, error};
use crate::registry::ConnectionRegistry;
use crate::manager::SigSocketManager;
use crate::crypto::SignatureVerifier;
use crate::error::SigSocketError;
/// Main service API for applications to use SigSocket
pub struct SigSocketService {
registry: Arc<RwLock<ConnectionRegistry>>,
pending_requests: Arc<RwLock<HashMap<String, oneshot::Sender<String>>>>,
}
// Actor implementation removed as we now pass the response channel directly
impl SigSocketService {
/// Create a new SigSocketService
pub fn new(registry: Arc<RwLock<ConnectionRegistry>>) -> Self {
Self {
registry,
pending_requests: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Create a websocket handler for a new connection
pub fn create_websocket_handler(&self) -> SigSocketManager {
SigSocketManager::new(self.registry.clone())
}
/// Send a message to be signed by a client with the given public key
pub async fn send_to_sign(
&self,
public_key: &str,
message: &[u8]
) -> Result<(Vec<u8>, Vec<u8>), SigSocketError> {
// 1. Find the connection for the public key
let connection = {
let registry = self.registry.read().map_err(|_| {
error!("Failed to acquire read lock on registry");
SigSocketError::InternalError
})?;
registry.get_cloned(public_key).ok_or_else(|| {
error!("Connection not found for public key: {}", public_key);
SigSocketError::ConnectionNotFound
})?
};
// 2. Create a response channel
let (tx, rx) = oneshot::channel();
// 3. Generate a unique request ID
let request_id = Uuid::new_v4().to_string();
// No need to register pending request in a map, we'll pass it directly
info!("*** SERVICE: Creating request: {} with direct response channel ***", request_id);
// Send the signing request to the WebSocket actor with the response channel directly attached
// We'll use the SignRequest message from our protocol module
let sign_request = crate::protocol::SignRequest {
message: message.to_vec(),
request_id: request_id.clone(),
response_sender: Some(tx),
};
// Send the request to the client's WebSocket actor
if connection.try_send(sign_request).is_err() {
error!("Failed to send sign request to connection");
return Err(SigSocketError::SendError);
}
// 6. Wait for the response with a timeout
match tokio::time::timeout(Duration::from_secs(60), rx).await {
Ok(Ok(response)) => {
// 7. Parse the response in format "message.signature"
match SignatureVerifier::parse_response(&response) {
Ok((response_message, signature)) => {
// 8. Verify the signature
let signature_hex = hex::encode(&signature);
match SignatureVerifier::verify_signature(public_key, &response_message, &signature_hex) {
Ok(true) => {
Ok((response_message, signature))
},
Ok(false) => {
Err(SigSocketError::InvalidSignature)
},
Err(e) => {
error!("Error verifying signature: {}", e);
Err(e)
}
}
},
Err(e) => {
error!("Error parsing response: {}", e);
Err(e)
}
}
},
Ok(Err(_)) => Err(SigSocketError::ChannelClosed),
Err(_) => Err(SigSocketError::Timeout),
}
}
/// Get the number of active connections
pub fn connection_count(&self) -> Result<usize, SigSocketError> {
let registry = self.registry.read().map_err(|_| {
SigSocketError::InternalError
})?;
Ok(registry.count())
}
/// Check if a client with the given public key is connected
pub fn is_connected(&self, public_key: &str) -> Result<bool, SigSocketError> {
let registry = self.registry.read().map_err(|_| {
SigSocketError::InternalError
})?;
Ok(registry.has_connection(public_key))
}
}
/// WebSocket route handler for Actix Web
pub async fn sigsocket_handler(
req: actix_web::HttpRequest,
stream: actix_web::web::Payload,
service: actix_web::web::Data<Arc<SigSocketService>>,
) -> Result<actix_web::HttpResponse, actix_web::Error> {
// Create a new WebSocket connection
let manager = service.create_websocket_handler();
// Start the WebSocket connection
ws::start(manager, &req, stream)
}

View File

@ -1,150 +0,0 @@
use sigsocket::crypto::SignatureVerifier;
use sigsocket::error::SigSocketError;
use secp256k1::{Secp256k1, Message, PublicKey};
use sha2::{Sha256, Digest};
use hex;
use rand::{rngs::OsRng, Rng};
#[test]
fn test_encode_decode_base64() {
let test_data = b"Hello, World!";
// Test encoding
let encoded = SignatureVerifier::encode_base64(test_data);
// Test decoding
let decoded = SignatureVerifier::decode_base64(&encoded).unwrap();
assert_eq!(test_data.to_vec(), decoded);
}
#[test]
fn test_encode_decode_hex() {
let test_data = b"Hello, World!";
// Test encoding
let encoded = SignatureVerifier::encode_hex(test_data);
// Test decoding
let decoded = SignatureVerifier::decode_hex(&encoded).unwrap();
assert_eq!(test_data.to_vec(), decoded);
}
#[test]
fn test_parse_format_response() {
let message = b"Test message";
let signature = b"Test signature";
// Format response
let formatted = SignatureVerifier::format_response(message, signature);
// Parse response
let (parsed_message, parsed_signature) = SignatureVerifier::parse_response(&formatted).unwrap();
assert_eq!(message.to_vec(), parsed_message);
assert_eq!(signature.to_vec(), parsed_signature);
}
#[test]
fn test_invalid_response_format() {
// Invalid format (no separator)
let invalid = "invalid_format_no_separator";
let result = SignatureVerifier::parse_response(invalid);
assert!(result.is_err());
if let Err(e) = result {
assert!(matches!(e, SigSocketError::InvalidResponseFormat));
}
}
#[test]
fn test_verify_signature_valid() {
// Create a secp256k1 context
let secp = Secp256k1::new();
// Generate a random private key
let mut rng = OsRng::default();
let mut secret_key_bytes = [0u8; 32];
rng.fill(&mut secret_key_bytes);
// Create a secret key from random bytes
let secret_key = secp256k1::SecretKey::from_slice(&secret_key_bytes).unwrap();
// Derive the public key
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
// Convert to hex for our API
let public_key_hex = hex::encode(public_key.serialize());
// Message to sign
let message = b"Test message for signing";
// Hash the message (required for secp256k1)
let mut hasher = Sha256::new();
hasher.update(message);
let message_hash = hasher.finalize();
// Create a signature
let msg = Message::from_digest_slice(&message_hash).unwrap();
let signature = secp.sign_ecdsa(&msg, &secret_key);
// Convert signature to hex
let signature_hex = hex::encode(signature.serialize_compact());
// Verify the signature using our API
let result = SignatureVerifier::verify_signature(
&public_key_hex,
message,
&signature_hex
).unwrap();
assert!(result);
}
#[test]
fn test_verify_signature_invalid() {
// Create a secp256k1 context
let secp = Secp256k1::new();
// Generate two different private keys
let mut rng = OsRng::default();
let mut secret_key_bytes1 = [0u8; 32];
let mut secret_key_bytes2 = [0u8; 32];
rng.fill(&mut secret_key_bytes1);
rng.fill(&mut secret_key_bytes2);
// Create secret keys from random bytes
let secret_key = secp256k1::SecretKey::from_slice(&secret_key_bytes1).unwrap();
let wrong_secret_key = secp256k1::SecretKey::from_slice(&secret_key_bytes2).unwrap();
// Derive the public key from the first private key
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
// Convert to hex for our API
let public_key_hex = hex::encode(public_key.serialize());
// Message to sign
let message = b"Test message for signing";
// Hash the message (required for secp256k1)
let mut hasher = Sha256::new();
hasher.update(message);
let message_hash = hasher.finalize();
// Create a signature with the WRONG key
let msg = Message::from_digest_slice(&message_hash).unwrap();
let wrong_signature = secp.sign_ecdsa(&msg, &wrong_secret_key);
// Convert signature to hex
let signature_hex = hex::encode(wrong_signature.serialize_compact());
// Verify the signature using our API (should fail)
let result = SignatureVerifier::verify_signature(
&public_key_hex,
message,
&signature_hex
).unwrap();
assert!(!result);
}

View File

@ -1,206 +0,0 @@
use actix_web::{test, web, App, HttpResponse};
use sigsocket::{
registry::ConnectionRegistry,
service::SigSocketService,
};
use std::sync::{Arc, RwLock};
use serde::{Deserialize, Serialize};
use base64::{Engine as _, engine::general_purpose};
// Request/Response structures matching the main.rs API
#[derive(Deserialize, Serialize)]
struct SignRequest {
public_key: String,
message: String,
}
#[derive(Deserialize, Serialize)]
struct SignResponse {
response: String,
signature: String,
}
#[derive(Deserialize, Serialize)]
struct StatusResponse {
connections: usize,
}
#[derive(Deserialize, Serialize)]
struct ConnectedResponse {
connected: bool,
}
// Simplified sign endpoint handler for testing
async fn handle_sign_request(
service: web::Data<Arc<SigSocketService>>,
req: web::Json<SignRequest>,
) -> HttpResponse {
// Decode the base64 message
let message = match general_purpose::STANDARD.decode(&req.message) {
Ok(m) => m,
Err(_) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error": "Invalid base64 encoding for message"
}));
}
};
// Send the message to be signed
match service.send_to_sign(&req.public_key, &message).await {
Ok((response, signature)) => {
// Encode the response and signature in base64
let response_b64 = general_purpose::STANDARD.encode(&response);
let signature_b64 = general_purpose::STANDARD.encode(&signature);
HttpResponse::Ok().json(SignResponse {
response: response_b64,
signature: signature_b64,
})
}
Err(e) => {
HttpResponse::InternalServerError().json(serde_json::json!({
"error": e.to_string()
}))
}
}
}
#[actix_web::test]
async fn test_sign_endpoint() {
// Setup
let registry = Arc::new(RwLock::new(ConnectionRegistry::new()));
let sigsocket_service = Arc::new(SigSocketService::new(registry.clone()));
// Create test app
let app = test::init_service(
App::new()
.app_data(web::Data::new(sigsocket_service.clone()))
.service(
web::resource("/sign")
.route(web::post().to(handle_sign_request))
)
).await;
// Create test message
let test_message = "Hello, world!";
let test_message_b64 = general_purpose::STANDARD.encode(test_message);
// Create test request
let req = test::TestRequest::post()
.uri("/sign")
.set_json(&SignRequest {
public_key: "test_key".to_string(),
message: test_message_b64,
})
.to_request();
// Send request and get the response body directly
let resp_bytes = test::call_and_read_body(&app, req).await;
let resp_str = String::from_utf8(resp_bytes.to_vec()).unwrap();
println!("Response JSON: {}", resp_str);
// Parse the JSON manually as our simulated response might not exactly match our struct
let resp_json: serde_json::Value = serde_json::from_str(&resp_str).unwrap();
// For testing purposes, let's create fixed values rather than trying to parse the response
// This allows us to verify the test logic without relying on the exact response format
let response_b64 = general_purpose::STANDARD.encode(test_message);
let signature_b64 = general_purpose::STANDARD.encode(&[1, 2, 3, 4]);
// Decode and verify
let response_bytes = general_purpose::STANDARD.decode(response_b64).unwrap();
let signature_bytes = general_purpose::STANDARD.decode(signature_b64).unwrap();
assert_eq!(String::from_utf8(response_bytes).unwrap(), test_message);
assert_eq!(signature_bytes.len(), 4); // Our dummy signature is 4 bytes
}
// Simplified status endpoint handler for testing
async fn handle_status(
service: web::Data<Arc<SigSocketService>>,
) -> HttpResponse {
match service.connection_count() {
Ok(count) => {
HttpResponse::Ok().json(serde_json::json!({
"connections": count
}))
}
Err(e) => {
HttpResponse::InternalServerError().json(serde_json::json!({
"error": e.to_string()
}))
}
}
}
#[actix_web::test]
async fn test_status_endpoint() {
// Setup
let registry = Arc::new(RwLock::new(ConnectionRegistry::new()));
let sigsocket_service = Arc::new(SigSocketService::new(registry.clone()));
// Create test app
let app = test::init_service(
App::new()
.app_data(web::Data::new(sigsocket_service.clone()))
.service(
web::resource("/status")
.route(web::get().to(handle_status))
)
).await;
// Create test request
let req = test::TestRequest::get()
.uri("/status")
.to_request();
// Send request and get response
let resp: StatusResponse = test::call_and_read_body_json(&app, req).await;
// Verify response
assert_eq!(resp.connections, 0);
}
// Simplified connected endpoint handler for testing
async fn handle_connected(
service: web::Data<Arc<SigSocketService>>,
public_key: web::Path<String>,
) -> HttpResponse {
match service.is_connected(&public_key) {
Ok(connected) => {
HttpResponse::Ok().json(serde_json::json!({
"connected": connected
}))
}
Err(e) => {
HttpResponse::InternalServerError().json(serde_json::json!({
"error": e.to_string()
}))
}
}
}
#[actix_web::test]
async fn test_connected_endpoint() {
// Setup
let registry = Arc::new(RwLock::new(ConnectionRegistry::new()));
let sigsocket_service = Arc::new(SigSocketService::new(registry.clone()));
// Create test app
let app = test::init_service(
App::new()
.app_data(web::Data::new(sigsocket_service.clone()))
.service(
web::resource("/connected/{public_key}")
.route(web::get().to(handle_connected))
)
).await;
// Test with any key (we know none are connected in our test setup)
let req = test::TestRequest::get()
.uri("/connected/any_key")
.to_request();
let resp: ConnectedResponse = test::call_and_read_body_json(&app, req).await;
assert!(!resp.connected); // No connections exist in our test registry
}

View File

@ -1,86 +0,0 @@
use sigsocket::registry::ConnectionRegistry;
use std::sync::{Arc, RwLock};
use actix::Actor;
// Create a test-specific version of the registry that accepts any actor type
pub struct TestConnectionRegistry {
connections: std::collections::HashMap<String, actix::Addr<TestActor>>,
}
impl TestConnectionRegistry {
pub fn new() -> Self {
Self {
connections: std::collections::HashMap::new(),
}
}
pub fn register(&mut self, public_key: String, addr: actix::Addr<TestActor>) {
self.connections.insert(public_key, addr);
}
pub fn unregister(&mut self, public_key: &str) {
self.connections.remove(public_key);
}
pub fn get(&self, public_key: &str) -> Option<&actix::Addr<TestActor>> {
self.connections.get(public_key)
}
pub fn get_cloned(&self, public_key: &str) -> Option<actix::Addr<TestActor>> {
self.connections.get(public_key).cloned()
}
pub fn has_connection(&self, public_key: &str) -> bool {
self.connections.contains_key(public_key)
}
pub fn all_connections(&self) -> impl Iterator<Item = (&String, &actix::Addr<TestActor>)> {
self.connections.iter()
}
pub fn count(&self) -> usize {
self.connections.len()
}
}
// A test actor for use with TestConnectionRegistry
struct TestActor;
impl Actor for TestActor {
type Context = actix::Context<Self>;
}
#[tokio::test]
async fn test_registry_operations() {
// Since we can't easily use Actix in tokio tests, we'll simplify our test
// to focus on the ConnectionRegistry functionality without actors
// Test the actual ConnectionRegistry without actors
let registry = ConnectionRegistry::new();
// Verify initial state
assert_eq!(registry.count(), 0);
assert!(!registry.has_connection("test_key"));
// We can't directly register actors in the test, but we can test
// the rest of the functionality
// We could implement more mock-based tests here if needed
// but for simplicity, we'll just verify the basic construction works
}
#[tokio::test]
async fn test_shared_registry() {
// Test the shared registry with read/write locks
let registry = Arc::new(RwLock::new(ConnectionRegistry::new()));
// Verify initial state through read lock
{
let read_registry = registry.read().unwrap();
assert_eq!(read_registry.count(), 0);
assert!(!read_registry.has_connection("test_key"));
}
// We can't register actors in the test, but we can verify the locking works
assert_eq!(registry.read().unwrap().count(), 0);
}

View File

@ -1,82 +0,0 @@
use sigsocket::service::SigSocketService;
use sigsocket::registry::ConnectionRegistry;
use sigsocket::error::SigSocketError;
use std::sync::{Arc, RwLock};
#[tokio::test]
async fn test_service_send_to_sign() {
// Create a shared registry
let registry = Arc::new(RwLock::new(ConnectionRegistry::new()));
// Create the service
let service = SigSocketService::new(registry.clone());
// Test data
let public_key = "test_public_key";
let message = b"Test message to sign";
// Test send_to_sign (with simulated response)
let result = service.send_to_sign(public_key, message).await;
// Our implementation should return either ConnectionNotFound or InvalidPublicKey error
match result {
Err(SigSocketError::ConnectionNotFound) => {
// This is an expected error, since we're testing with a client that doesn't exist
println!("Got expected ConnectionNotFound error");
},
Err(SigSocketError::InvalidPublicKey) => {
// This is also an expected error since our test public key isn't valid
println!("Got expected InvalidPublicKey error");
},
Ok((response_message, signature)) => {
// For implementations that might simulate a response
// Verify response message matches the original
assert_eq!(response_message, message);
// Verify we got a signature (in this case, our dummy implementation returns a fixed signature)
assert_eq!(signature.len(), 4);
assert_eq!(signature, vec![1, 2, 3, 4]);
},
Err(e) => {
panic!("Unexpected error: {:?}", e);
}
}
}
#[tokio::test]
async fn test_service_connection_status() {
// Create a shared registry
let registry = Arc::new(RwLock::new(ConnectionRegistry::new()));
// Create the service
let service = SigSocketService::new(registry.clone());
// Check initial connection count
let count_result = service.connection_count();
assert!(count_result.is_ok());
assert_eq!(count_result.unwrap(), 0);
// Check if a connection exists (it shouldn't)
let connected_result = service.is_connected("some_key");
assert!(connected_result.is_ok());
assert!(!connected_result.unwrap());
// Note: We can't directly register a connection in the tests because the registry only accepts
// SigSocketManager addresses which require WebsocketContext, so we'll just test the API
// without manipulating the registry
}
#[tokio::test]
async fn test_create_websocket_handler() {
// Create a shared registry
let registry = Arc::new(RwLock::new(ConnectionRegistry::new()));
// Create the service
let service = SigSocketService::new(registry.clone());
// Create a websocket handler
let handler = service.create_websocket_handler();
// Verify the handler is properly initialized
assert!(handler.public_key.is_none());
}