From 381880324ac13ab2778b194afecd8576b08a4ec3 Mon Sep 17 00:00:00 2001 From: Cerkoryn <30681834+Cerkoryn@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:10:56 -0500 Subject: [PATCH 1/2] feat: add survey-linked info action creation and voting support --- PR_DESCRIPTION.md | 134 +++++++++ .../backend/sql/get-proposal-survey-tally.sql | 120 ++++++++ govtool/backend/sql/get-proposal-survey.sql | 130 ++++++++ govtool/backend/src/VVA/API.hs | 21 +- govtool/backend/src/VVA/Survey.hs | 82 +++++ govtool/backend/vva-be.cabal | 3 + .../components/molecules/VoteActionForm.tsx | 281 +++++++++++++++++- .../CreateGovernanceActionForm.tsx | 77 ++++- .../StorageInformation.tsx | 2 +- govtool/frontend/src/consts/queryKeys.ts | 2 + govtool/frontend/src/context/wallet.tsx | 73 ++++- .../forms/useCreateGovernanceActionForm.ts | 207 +++++++++++-- .../src/hooks/forms/useVoteActionForm.tsx | 136 ++++++--- govtool/frontend/src/hooks/queries/index.ts | 2 + .../queries/useGetProposalSurveyQuery.ts | 25 ++ .../queries/useGetProposalSurveyTallyQuery.ts | 26 ++ govtool/frontend/src/models/api.ts | 68 +++++ .../src/pages/GovernanceActionDetails.tsx | 271 ++++++++++++++++- .../services/requests/getProposalSurvey.ts | 13 + .../requests/getProposalSurveyTally.ts | 17 ++ .../frontend/src/services/requests/index.ts | 2 + govtool/frontend/src/utils/index.ts | 1 + govtool/frontend/src/utils/survey.ts | 11 + 23 files changed, 1604 insertions(+), 100 deletions(-) create mode 100644 PR_DESCRIPTION.md create mode 100644 govtool/backend/sql/get-proposal-survey-tally.sql create mode 100644 govtool/backend/sql/get-proposal-survey.sql create mode 100644 govtool/backend/src/VVA/Survey.hs create mode 100644 govtool/frontend/src/hooks/queries/useGetProposalSurveyQuery.ts create mode 100644 govtool/frontend/src/hooks/queries/useGetProposalSurveyTallyQuery.ts create mode 100644 govtool/frontend/src/services/requests/getProposalSurvey.ts create mode 100644 govtool/frontend/src/services/requests/getProposalSurveyTally.ts create mode 100644 govtool/frontend/src/utils/survey.ts diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 000000000..6e843bda7 --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,134 @@ +# PR: Support CIP-XXXX survey-linked Info Actions and optional survey voting metadata + +## Summary +This PR adds end-to-end support in GovTool for: +1. Creating a survey payload (`surveyDetails`) via transaction metadata label `17`. +2. Linking that survey from an Info Action anchor using `kind = cardano-governance-survey-link` and `surveyRef`. +3. Voting on the linked survey from the governance vote flow by attaching `surveyResponse` metadata (also label `17`) to the vote transaction. +4. Displaying linked survey details and tally results on the Governance Action details page. + +The implementation follows the current CIP-XXXX behavior decisions used for this branch: +- Survey + Info Action creation uses a **two-transaction flow**. +- Blank survey submissions are allowed: if no survey answers are provided, GovTool submits the governance vote without `surveyResponse` metadata. + +## Why +Info Actions currently lack a standardized in-app way to: +- publish structured surveys, +- link them deterministically to governance actions, +- and collect responses directly in governance vote transactions. + +This PR closes that gap by wiring both creation and voting paths, plus backend resolution/tally endpoints and frontend visualization. + +## Backend changes + +### New API endpoints +Added in `govtool/backend/src/VVA/API.hs`: +- `GET /proposal/survey/:proposalId` +- `GET /proposal/survey/:proposalId/tally?weighting=CredentialBased|StakeBased` + +### New survey module +Added `govtool/backend/src/VVA/Survey.hs` with: +- SQL-backed proposal survey resolution +- SQL-backed tally response +- safe fallback payloads when no data is available + +### New SQL files +Added: +- `govtool/backend/sql/get-proposal-survey.sql` +- `govtool/backend/sql/get-proposal-survey-tally.sql` + +`get-proposal-survey-tally.sql` includes fail-safe handling for malformed `answers` (non-array values are treated as empty arrays instead of failing the query). + +### Build/package wiring +Updated `govtool/backend/vva-be.cabal` to include: +- new SQL files under `extra-source-files` +- new exposed module `VVA.Survey` + +## Frontend changes + +### Survey API integration +Added request + query layer for survey endpoints: +- `govtool/frontend/src/services/requests/getProposalSurvey.ts` +- `govtool/frontend/src/services/requests/getProposalSurveyTally.ts` +- `govtool/frontend/src/hooks/queries/useGetProposalSurveyQuery.ts` +- `govtool/frontend/src/hooks/queries/useGetProposalSurveyTallyQuery.ts` + +Updated exports and query keys: +- `govtool/frontend/src/services/requests/index.ts` +- `govtool/frontend/src/hooks/queries/index.ts` +- `govtool/frontend/src/consts/queryKeys.ts` + +### New API models +Extended `govtool/frontend/src/models/api.ts` with survey-related types: +- `SurveyRef`, `SurveyQuestion`, `SurveyDetails` +- `ProposalSurveyResponse`, `ProposalSurveyTallyResponse` + +### Governance Action details page +Updated `govtool/frontend/src/pages/GovernanceActionDetails.tsx` to: +- fetch linked survey for Info Actions, +- render validation status, +- fetch/render tally with weighting toggle (`CredentialBased` / `StakeBased`), +- display per-question method results. + +### Vote flow: attach optional surveyResponse metadata +Updated: +- `govtool/frontend/src/components/molecules/VoteActionForm.tsx` +- `govtool/frontend/src/hooks/forms/useVoteActionForm.tsx` + +Behavior: +- If survey link/details are valid and at least one valid answer is provided, submit vote with metadata label `17` containing `surveyResponse`. +- If no answers are provided, submit vote without `surveyResponse` metadata (blank survey allowed). +- Invalid custom JSON answer input blocks submission until corrected. + +### Wallet metadata support +Updated `govtool/frontend/src/context/wallet.tsx` to support metadata auxiliary data in tx building: +- added `buildMetadataAuxiliaryData(label, payload)` helper +- extended `buildSignSubmitConwayCertTx` args to support: + - `auxiliaryData` + - `skipPendingCheck` + - `skipStakeKeyRegistration` + - `trackPending` +- builder now injects auxiliary data into tx when provided + +### Info Action create flow with attached survey (two-step) +Updated: +- `govtool/frontend/src/hooks/forms/useCreateGovernanceActionForm.ts` +- `govtool/frontend/src/components/organisms/CreateGovernanceActionSteps/CreateGovernanceActionForm.tsx` +- `govtool/frontend/src/components/organisms/CreateGovernanceActionSteps/StorageInformation.tsx` +- `govtool/frontend/src/utils/survey.ts` +- `govtool/frontend/src/utils/index.ts` + +Behavior: +1. User can enable `attachSurvey` and provide `surveyDetailsJson` (basic structural validation in form). +2. First submit creates a survey tx by posting metadata label `17` with `surveyDetails`. +3. `surveyHash` is computed using the current client hash utility and paired with returned survey tx id. +4. Metadata is regenerated for the Info Action anchor payload with: + - `specVersion: "1.0.0"` + - `kind: "cardano-governance-survey-link"` + - `surveyRef: { surveyTxId, surveyHash }` +5. User is prompted to upload refreshed metadata URL and submit again. +6. Second submit creates the Info Action transaction. + +## Behavior decisions in this PR +- **Blank survey answers are allowed**: no `surveyResponse` metadata is attached in that case. +- Survey response metadata is attached only when there is at least one valid answer. +- Survey creation path remains gated to Info Action linkage flow. + +## Compatibility and risk +- Existing non-survey governance action flows remain unchanged. +- Existing voting continues to work without survey metadata. +- Main risk area is metadata hash/link correctness across toolchains; this PR keeps validation fail-safe and exposes link/validation states in the UI. + +## Testing +### What was verified +- Static review of endpoint wiring, request/query hooks, and UI integration. +- `git diff --check` clean. + +### Not verified in this environment +- Full TypeScript/Haskell compile and runtime integration tests could not be executed here due missing local dependencies and restricted network access for package retrieval. + +Recommended CI/manual checks after merge: +1. Create survey tx (label `17`) + create Info Action link tx. +2. Vote with survey answers and confirm metadata is attached. +3. Vote without survey answers and confirm governance vote still submits without `surveyResponse` metadata. +4. Validate survey/tally endpoints against known proposals. diff --git a/govtool/backend/sql/get-proposal-survey-tally.sql b/govtool/backend/sql/get-proposal-survey-tally.sql new file mode 100644 index 000000000..c40239f61 --- /dev/null +++ b/govtool/backend/sql/get-proposal-survey-tally.sql @@ -0,0 +1,120 @@ +WITH context AS ( + SELECT + gov_action_proposal.id AS proposal_db_id, + off_chain_vote_data.json->'surveyRef'->>'surveyTxId' AS survey_tx_id, + off_chain_vote_data.json->'surveyRef'->>'surveyHash' AS survey_hash, + survey_meta.json->'surveyDetails' AS survey_details + FROM gov_action_proposal + JOIN tx AS creator_tx ON creator_tx.id = gov_action_proposal.tx_id + LEFT JOIN voting_anchor ON voting_anchor.id = gov_action_proposal.voting_anchor_id + LEFT JOIN off_chain_vote_data ON off_chain_vote_data.voting_anchor_id = voting_anchor.id + LEFT JOIN tx survey_tx + ON encode(survey_tx.hash, 'hex') = LOWER(off_chain_vote_data.json->'surveyRef'->>'surveyTxId') + LEFT JOIN tx_metadata survey_meta + ON survey_meta.tx_id = survey_tx.id + AND survey_meta.key = 17 + AND survey_meta.json ? 'surveyDetails' + WHERE encode(creator_tx.hash, 'hex') = ? + AND gov_action_proposal.index = ? + LIMIT 1 +), +LatestDrepDistr AS ( + SELECT + *, + ROW_NUMBER() OVER (PARTITION BY hash_id ORDER BY epoch_no DESC) AS rn + FROM drep_distr +), +responses AS ( + SELECT + encode(drep_hash.raw, 'hex') AS response_credential, + tx_metadata.id AS metadata_id, + block.slot_no AS slot_no, + tx.block_index AS tx_index, + tx_metadata.json->'surveyResponse' AS survey_response, + COALESCE(ldd.amount, 0) AS drep_voting_power + FROM context + JOIN tx_metadata + ON tx_metadata.key = 17 + AND tx_metadata.json ? 'surveyResponse' + AND LOWER(tx_metadata.json->'surveyResponse'->>'surveyTxId') = LOWER(context.survey_tx_id) + AND LOWER(tx_metadata.json->'surveyResponse'->>'surveyHash') = LOWER(context.survey_hash) + JOIN tx ON tx.id = tx_metadata.tx_id + JOIN block ON block.id = tx.block_id + JOIN voting_procedure + ON voting_procedure.tx_id = tx.id + AND voting_procedure.gov_action_proposal_id = context.proposal_db_id + AND voting_procedure.drep_voter IS NOT NULL + JOIN drep_hash ON drep_hash.id = voting_procedure.drep_voter + LEFT JOIN LatestDrepDistr ldd + ON ldd.hash_id = voting_procedure.drep_voter + AND ldd.rn = 1 +), +latest AS ( + SELECT DISTINCT ON (response_credential) + * + FROM responses + ORDER BY + response_credential, + slot_no DESC, + tx_index DESC, + metadata_id DESC +), +question_counts AS ( + SELECT + answer->>'questionId' AS question_id, + COUNT(*)::bigint AS answers_count, + SUM( + CASE + WHEN ? = 'StakeBased' THEN drep_voting_power + ELSE 1 + END + )::bigint AS weighted_count + FROM latest + JOIN LATERAL jsonb_array_elements( + CASE + WHEN jsonb_typeof(latest.survey_response->'answers') = 'array' + THEN latest.survey_response->'answers' + ELSE '[]'::jsonb + END + ) AS answer ON true + GROUP BY answer->>'questionId' +), +method_results AS ( + SELECT + COALESCE( + jsonb_agg( + jsonb_build_object( + 'questionId', q->>'questionId', + 'question', q->>'question', + 'methodType', q->>'methodType', + 'count', COALESCE(question_counts.answers_count, 0), + 'weightedCount', COALESCE(question_counts.weighted_count, 0) + ) + ), + '[]'::jsonb + ) AS results + FROM context + LEFT JOIN LATERAL jsonb_array_elements(context.survey_details->'questions') AS q ON true + LEFT JOIN question_counts ON question_counts.question_id = q->>'questionId' +) +SELECT + jsonb_build_object( + 'surveyTxId', context.survey_tx_id, + 'surveyHash', context.survey_hash, + 'weightingMode', ?, + 'totals', jsonb_build_object( + 'totalSeen', COALESCE((SELECT COUNT(*) FROM responses), 0), + 'valid', COALESCE((SELECT COUNT(*) FROM latest), 0), + 'invalid', GREATEST( + COALESCE((SELECT COUNT(*) FROM responses), 0) + - COALESCE((SELECT COUNT(*) FROM latest), 0), + 0 + ), + 'deduped', COALESCE((SELECT COUNT(*) FROM latest), 0), + 'uniqueResponders', COALESCE((SELECT COUNT(*) FROM latest), 0) + ), + 'methodResults', COALESCE(method_results.results, '[]'::jsonb), + 'errors', '[]'::jsonb + ) AS survey_tally +FROM context +CROSS JOIN method_results; diff --git a/govtool/backend/sql/get-proposal-survey.sql b/govtool/backend/sql/get-proposal-survey.sql new file mode 100644 index 000000000..c78aa6bd3 --- /dev/null +++ b/govtool/backend/sql/get-proposal-survey.sql @@ -0,0 +1,130 @@ +WITH proposal_data AS ( + SELECT + gov_action_proposal.id AS proposal_db_id, + gov_action_proposal.type::text AS proposal_type, + gov_action_proposal.voting_anchor_id AS voting_anchor_id, + creator_block.slot_no AS creator_slot, + meta.network_name::text AS network_name, + COALESCE(latest_epoch_param.gov_action_lifetime, 0) AS gov_action_lifetime + FROM gov_action_proposal + JOIN tx AS creator_tx ON creator_tx.id = gov_action_proposal.tx_id + JOIN block AS creator_block ON creator_block.id = creator_tx.block_id + CROSS JOIN meta + LEFT JOIN LATERAL ( + SELECT ep.gov_action_lifetime + FROM epoch_param ep + ORDER BY ep.epoch_no DESC + LIMIT 1 + ) latest_epoch_param ON true + WHERE encode(creator_tx.hash, 'hex') = ? + AND gov_action_proposal.index = ? + LIMIT 1 +), +survey_link AS ( + SELECT + proposal_data.*, + off_chain_vote_data.json AS anchor_json, + off_chain_vote_data.json->>'kind' AS kind, + off_chain_vote_data.json->'surveyRef'->>'surveyTxId' AS survey_tx_id, + off_chain_vote_data.json->'surveyRef'->>'surveyHash' AS survey_hash + FROM proposal_data + LEFT JOIN voting_anchor ON voting_anchor.id = proposal_data.voting_anchor_id + LEFT JOIN off_chain_vote_data ON off_chain_vote_data.voting_anchor_id = voting_anchor.id +), +survey_payload AS ( + SELECT + survey_link.*, + survey_meta.json->'surveyDetails' AS survey_details, + CASE + WHEN (survey_meta.json->'surveyDetails'->'lifecycle'->>'startSlot') ~ '^[0-9]+$' + THEN (survey_meta.json->'surveyDetails'->'lifecycle'->>'startSlot')::bigint + ELSE NULL + END AS lifecycle_start_slot, + CASE + WHEN (survey_meta.json->'surveyDetails'->'lifecycle'->>'endSlot') ~ '^[0-9]+$' + THEN (survey_meta.json->'surveyDetails'->'lifecycle'->>'endSlot')::bigint + ELSE NULL + END AS lifecycle_end_slot + FROM survey_link + LEFT JOIN tx survey_tx + ON encode(survey_tx.hash, 'hex') = LOWER(survey_link.survey_tx_id) + LEFT JOIN tx_metadata survey_meta + ON survey_meta.tx_id = survey_tx.id + AND survey_meta.key = 17 + AND survey_meta.json ? 'surveyDetails' +) +SELECT + jsonb_build_object( + 'linked', (survey_tx_id IS NOT NULL AND survey_hash IS NOT NULL), + 'actionLifecycle', jsonb_build_object( + 'startSlot', creator_slot, + 'endSlot', creator_slot + ( + gov_action_lifetime + * CASE WHEN network_name IN ('mainnet', 'preprod') THEN 432000 ELSE 86400 END + ) + ), + 'surveyRef', CASE + WHEN survey_tx_id IS NOT NULL AND survey_hash IS NOT NULL + THEN jsonb_build_object( + 'surveyTxId', survey_tx_id, + 'surveyHash', survey_hash + ) + ELSE NULL + END, + 'computedSurveyHash', survey_hash, + 'linkValidation', jsonb_build_object( + 'valid', + ( + proposal_type = 'InfoAction' + AND kind = 'cardano-governance-survey-link' + AND survey_tx_id IS NOT NULL + AND survey_hash IS NOT NULL + AND survey_details IS NOT NULL + ), + 'errors', + to_jsonb(array_remove(ARRAY[ + CASE WHEN proposal_type <> 'InfoAction' + THEN 'Survey linkage is only valid for InfoAction proposals.' END, + CASE WHEN kind IS DISTINCT FROM 'cardano-governance-survey-link' + THEN 'Anchor metadata kind is not cardano-governance-survey-link.' END, + CASE WHEN survey_tx_id IS NULL OR survey_hash IS NULL + THEN 'Missing surveyRef.surveyTxId or surveyRef.surveyHash in anchor metadata.' END, + CASE WHEN survey_tx_id IS NOT NULL AND survey_details IS NULL + THEN 'Referenced surveyTxId has no label 17 surveyDetails payload.' END + ], NULL)) + ), + 'surveyDetails', survey_details, + 'surveyDetailsValidation', jsonb_build_object( + 'valid', + ( + survey_details IS NOT NULL + AND lifecycle_start_slot IS NOT NULL + AND lifecycle_end_slot IS NOT NULL + AND lifecycle_start_slot = creator_slot + AND lifecycle_end_slot = creator_slot + ( + gov_action_lifetime + * CASE WHEN network_name IN ('mainnet', 'preprod') THEN 432000 ELSE 86400 END + ) + ), + 'errors', + to_jsonb(array_remove(ARRAY[ + CASE WHEN survey_details IS NULL + THEN 'Missing surveyDetails payload.' END, + CASE WHEN survey_details IS NOT NULL + AND (lifecycle_start_slot IS NULL OR lifecycle_end_slot IS NULL) + THEN 'surveyDetails.lifecycle.startSlot/endSlot are required for linked InfoAction surveys.' END, + CASE WHEN survey_details IS NOT NULL + AND lifecycle_start_slot IS NOT NULL + AND lifecycle_end_slot IS NOT NULL + AND ( + lifecycle_start_slot <> creator_slot + OR lifecycle_end_slot <> creator_slot + ( + gov_action_lifetime + * CASE WHEN network_name IN ('mainnet', 'preprod') THEN 432000 ELSE 86400 END + ) + ) + THEN 'surveyDetails.lifecycle must match InfoAction lifecycle (start/end slot mismatch).' END + ], NULL)) + ) + ) AS survey_payload +FROM survey_payload; diff --git a/govtool/backend/src/VVA/API.hs b/govtool/backend/src/VVA/API.hs index c0a594f7a..331d5542b 100644 --- a/govtool/backend/src/VVA/API.hs +++ b/govtool/backend/src/VVA/API.hs @@ -26,7 +26,7 @@ import qualified Data.Text as Text import qualified Data.Text.Lazy as TL import qualified Data.Text.Lazy.Encoding as TL import Data.Time (TimeZone, localTimeToUTC) -import Data.Time.LocalTime (TimeZone, getCurrentTimeZone) +import Data.Time.LocalTime (getCurrentTimeZone) import qualified Data.Vector as V import Data.Hashable (hash, hashWithSalt) @@ -50,6 +50,7 @@ import qualified VVA.Epoch as Epoch import qualified VVA.Ipfs as Ipfs import VVA.Network as Network import qualified VVA.Proposal as Proposal +import qualified VVA.Survey as Survey import qualified VVA.Transaction as Transaction import qualified VVA.Types as Types import VVA.Types (App, AppEnv (..), @@ -89,6 +90,8 @@ type VVAApi = :> QueryParam "search" Text :> Get '[JSON] ListProposalsResponse :<|> "proposal" :> "get" :> Capture "proposalId" GovActionId :> QueryParam "drepId" HexText :> Get '[JSON] GetProposalResponse + :<|> "proposal" :> "survey" :> Capture "proposalId" GovActionId :> Get '[JSON] AnyValue + :<|> "proposal" :> "survey" :> Capture "proposalId" GovActionId :> "tally" :> QueryParam "weighting" Text :> Get '[JSON] AnyValue :<|> "proposal" :> "enacted-details" :> QueryParam "type" GovernanceActionType :> Get '[JSON] (Maybe EnactedProposalDetailsResponse) :<|> "epoch" :> "params" :> Get '[JSON] GetCurrentEpochParamsResponse :<|> "transaction" :> "status" :> Capture "transactionId" HexText :> Get '[JSON] GetTransactionStatusResponse @@ -109,6 +112,8 @@ server = upload :<|> getStakeKeyVotingPower :<|> listProposals :<|> getProposal + :<|> getProposalSurvey + :<|> getProposalSurveyTally :<|> getEnactedProposalDetails :<|> getCurrentEpochParams :<|> getTransactionStatus @@ -485,6 +490,20 @@ getProposal g@(GovActionId govActionTxHash govActionIndex) mDrepId' = do , getProposalResponseVote = voteResponse } +getProposalSurvey :: App m => GovActionId -> m AnyValue +getProposalSurvey (GovActionId govActionTxHash govActionIndex) = do + payload <- Survey.getProposalSurvey (unHexText govActionTxHash) govActionIndex + pure $ AnyValue (Just payload) + +getProposalSurveyTally :: App m => GovActionId -> Maybe Text -> m AnyValue +getProposalSurveyTally (GovActionId govActionTxHash govActionIndex) mWeighting = do + let weighting = fromMaybe "CredentialBased" mWeighting + payload <- Survey.getProposalSurveyTally + (unHexText govActionTxHash) + govActionIndex + weighting + pure $ AnyValue (Just payload) + getEnactedProposalDetails :: App m => Maybe GovernanceActionType -> m (Maybe EnactedProposalDetailsResponse) getEnactedProposalDetails maybeType = do let proposalType = maybe "HardForkInitiation" governanceActionTypeToText maybeType diff --git a/govtool/backend/src/VVA/Survey.hs b/govtool/backend/src/VVA/Survey.hs new file mode 100644 index 000000000..87077f1fe --- /dev/null +++ b/govtool/backend/src/VVA/Survey.hs @@ -0,0 +1,82 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} + +module VVA.Survey where + +import Control.Monad.Reader + +import Data.Aeson (Value, object, (.=)) +import Data.ByteString (ByteString) +import Data.FileEmbed (embedFile) +import Data.Has (Has) +import Data.String (fromString) +import Data.Text (Text, unpack) +import qualified Data.Text.Encoding as Text + +import qualified Database.PostgreSQL.Simple as SQL +import VVA.Config +import VVA.Pool (ConnectionPool, withPool) + +sqlFrom :: ByteString -> SQL.Query +sqlFrom bs = fromString $ unpack $ Text.decodeUtf8 bs + +getProposalSurveySql :: SQL.Query +getProposalSurveySql = sqlFrom $(embedFile "sql/get-proposal-survey.sql") + +getProposalSurveyTallySql :: SQL.Query +getProposalSurveyTallySql = sqlFrom $(embedFile "sql/get-proposal-survey-tally.sql") + +emptySurveyPayload :: Value +emptySurveyPayload = + object + [ "linked" .= False + , "actionLifecycle" .= object ["startSlot" .= (0 :: Integer), "endSlot" .= (0 :: Integer)] + , "surveyRef" .= (Nothing :: Maybe Value) + , "computedSurveyHash" .= (Nothing :: Maybe Text) + , "linkValidation" .= object ["valid" .= False, "errors" .= ["No survey link found for this proposal."]] + , "surveyDetails" .= (Nothing :: Maybe Value) + , "surveyDetailsValidation" .= object ["valid" .= False, "errors" .= ([] :: [Text])] + ] + +emptySurveyTallyPayload :: Text -> Value +emptySurveyTallyPayload weighting = + object + [ "surveyTxId" .= (Nothing :: Maybe Text) + , "surveyHash" .= (Nothing :: Maybe Text) + , "weightingMode" .= weighting + , "totals" .= object + [ "totalSeen" .= (0 :: Integer) + , "valid" .= (0 :: Integer) + , "invalid" .= (0 :: Integer) + , "deduped" .= (0 :: Integer) + , "uniqueResponders" .= (0 :: Integer) + ] + , "methodResults" .= ([] :: [Value]) + , "errors" .= ["No survey tally available for this proposal."] + ] + +getProposalSurvey :: + (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m) => + Text -> + Integer -> + m Value +getProposalSurvey txHash index = withPool $ \conn -> do + result <- liftIO $ + SQL.query conn getProposalSurveySql (txHash, index) :: IO [SQL.Only Value] + case result of + [SQL.Only payload] -> pure payload + _ -> pure emptySurveyPayload + +getProposalSurveyTally :: + (Has ConnectionPool r, Has VVAConfig r, MonadReader r m, MonadIO m) => + Text -> + Integer -> + Text -> + m Value +getProposalSurveyTally txHash index weighting = withPool $ \conn -> do + result <- liftIO $ + SQL.query conn getProposalSurveyTallySql (txHash, index, weighting, weighting) :: IO [SQL.Only Value] + case result of + [SQL.Only payload] -> pure payload + _ -> pure (emptySurveyTallyPayload weighting) diff --git a/govtool/backend/vva-be.cabal b/govtool/backend/vva-be.cabal index 8eb0de86d..781acc327 100644 --- a/govtool/backend/vva-be.cabal +++ b/govtool/backend/vva-be.cabal @@ -38,6 +38,8 @@ extra-source-files: sql/get-filtered-dreps-voting-power.sql sql/get-previous-enacted-governance-action-proposal-details.sql sql/get-account-info.sql + sql/get-proposal-survey.sql + sql/get-proposal-survey-tally.sql executable vva-be main-is: Main.hs @@ -131,4 +133,5 @@ library , VVA.Network , VVA.Account , VVA.Ipfs + , VVA.Survey ghc-options: -threaded diff --git a/govtool/frontend/src/components/molecules/VoteActionForm.tsx b/govtool/frontend/src/components/molecules/VoteActionForm.tsx index 6a49ac90b..c44e4bc4c 100644 --- a/govtool/frontend/src/components/molecules/VoteActionForm.tsx +++ b/govtool/frontend/src/components/molecules/VoteActionForm.tsx @@ -5,13 +5,15 @@ import { Trans } from "react-i18next"; import { Button, Radio, Typography } from "@atoms"; import { useModal } from "@context"; import { + SurveyResponsePayload, useScreenDimension, useVoteActionForm, useTranslation, useGetVoterInfo, useGetVoteContextTextFromFile, + useGetProposalSurveyQuery, } from "@hooks"; -import { formatDisplayDate } from "@utils"; +import { formatDisplayDate, getFullGovActionId } from "@utils"; import { errorRed, fadedPurple } from "@/consts"; import { ProposalData, ProposalVote, Vote } from "@/models"; import { VoteContextModalState, SubmittedVotesModalState } from "../organisms"; @@ -30,19 +32,35 @@ export const VoteActionForm = ({ proposal, proposal: { expiryDate, expiryEpochNo }, }: VoteActionFormProps) => { + const [surveyAnswers, setSurveyAnswers] = useState< + Record< + string, + { + selection?: number[]; + numericValue?: number; + customValue?: string; + } + > + >({}); + const [surveyError, setSurveyError] = useState(null); const [voteContextHash, setVoteContextHash] = useState(); const [voteContextUrl, setVoteContextUrl] = useState(); const [showWholeVoteContext, setShowWholeVoteContext] = useState(false); const { voter } = useGetVoterInfo(); + const fullProposalId = getFullGovActionId(proposal.txHash, proposal.index); + const { data: proposalSurvey } = useGetProposalSurveyQuery( + fullProposalId, + proposal.type === "InfoAction", + ); const { voteContextText, valid: voteContextValid = true } = useGetVoteContextTextFromFile(voteContextUrl, voteContextHash) || {}; const finalVoteContextText = - ((previousVote != null || undefined) && !voteContextUrl && !voteContextHash) - ? "" - : voteContextText; + previousVote && !voteContextUrl && !voteContextHash + ? "" + : voteContextText; const { isMobile } = useScreenDimension(); const { openModal, closeModal } = useModal(); @@ -56,21 +74,95 @@ export const VoteActionForm = ({ setValue, vote, canVote, - } = useVoteActionForm({ previousVote, voteContextHash, voteContextUrl, closeModal }); + } = useVoteActionForm({ + previousVote, + voteContextHash, + voteContextUrl, + closeModal, + }); + + const handleVoteClick = (isVoteChanged: boolean) => { + const shouldAttachSurveyResponse = + proposalSurvey?.linked && + proposalSurvey?.linkValidation?.valid && + proposalSurvey?.surveyDetailsValidation?.valid && + proposalSurvey?.surveyRef && + proposalSurvey?.surveyDetails; + + let surveyResponsePayload: SurveyResponsePayload | undefined; + if (shouldAttachSurveyResponse) { + let hasInvalidSurveyAnswer = false; + const answers = proposalSurvey.surveyDetails.questions.flatMap((question) => { + const answer = surveyAnswers[question.questionId]; + if (!answer) return []; + + if (Array.isArray(answer.selection)) { + return [ + { + questionId: question.questionId, + selection: answer.selection, + }, + ]; + } + + if ( + typeof answer.numericValue === "number" && + Number.isFinite(answer.numericValue) + ) { + return [ + { + questionId: question.questionId, + numericValue: answer.numericValue, + }, + ]; + } + + if (typeof answer.customValue === "string" && answer.customValue.trim()) { + try { + const customValue = JSON.parse(answer.customValue); + return [ + { + questionId: question.questionId, + customValue, + }, + ]; + } catch (_error) { + setSurveyError("Invalid custom survey answer JSON."); + hasInvalidSurveyAnswer = true; + return []; + } + } + + return []; + }); + + if (hasInvalidSurveyAnswer) { + return; + } + + if (answers.length) { + surveyResponsePayload = { + specVersion: "1.0.0", + surveyTxId: proposalSurvey.surveyRef.surveyTxId, + surveyHash: proposalSurvey.surveyRef.surveyHash, + answers, + }; + } + } - const handleVoteClick = (isVoteChanged:boolean) => { + setSurveyError(null); openModal({ type: "voteContext", state: { onSubmit: (url, hash) => { setVoteContextUrl(url); setVoteContextHash(hash ?? undefined); - confirmVote(vote as Vote, url, hash); + confirmVote(vote as Vote, url, hash, surveyResponsePayload); setVoteContextData(url, hash); }, vote: vote as Vote, confirmVote, - previousRationale: isVoteChanged ? undefined : finalVoteContextText + previousRationale: isVoteChanged ? undefined : finalVoteContextText, } satisfies VoteContextModalState, }); }; @@ -91,10 +183,10 @@ export const VoteActionForm = ({ if (previousVote?.url) { setVoteContextUrl(previousVote.url); } - if (previousVote?.metadataHash) { + if (previousVote?.metadataHash) { setVoteContextHash(previousVote.metadataHash); } - }, [previousVote?.url, setVoteContextUrl]); + }, [previousVote?.metadataHash, previousVote?.url]); const renderCancelButton = useMemo( () => ( @@ -131,7 +223,7 @@ export const VoteActionForm = ({ {t("govActions.changeVote")} ), - [confirmVote, areFormErrors, vote, isVoteLoading], + [canVote, handleVoteClick, isVoteLoading, t], ); return ( @@ -223,6 +315,173 @@ export const VoteActionForm = ({ disabled={isInProgress} /> + {proposalSurvey?.linked && + proposalSurvey?.linkValidation?.valid && + proposalSurvey?.surveyDetailsValidation?.valid && + proposalSurvey?.surveyDetails && ( + + + Survey response + + + {proposalSurvey.surveyDetails.title} + + {proposalSurvey.surveyDetails.questions.map((question) => { + const localAnswer = surveyAnswers[question.questionId]; + const methodType = question.methodType; + const selection = localAnswer?.selection ?? []; + + return ( + + {question.question} + {(methodType === + "urn:cardano:poll-method:single-choice:v1" || + methodType === "urn:cardano:poll-method:multi-select:v1") && + (question.options ?? []).map((option, optionIndex) => { + const isSingle = + methodType === + "urn:cardano:poll-method:single-choice:v1"; + const checked = selection.includes(optionIndex); + + return ( + + ); + })} + {methodType === "urn:cardano:poll-method:numeric-range:v1" && ( + { + setSurveyError(null); + const numericValue = Number(event.target.value); + setSurveyAnswers((prev) => ({ + ...prev, + [question.questionId]: { numericValue }, + })); + }} + /> + )} + {![ + "urn:cardano:poll-method:single-choice:v1", + "urn:cardano:poll-method:multi-select:v1", + "urn:cardano:poll-method:numeric-range:v1", + ].includes(methodType) && ( +