diff --git a/backend/src/queries/currentDelegation.ts b/backend/src/queries/currentDelegation.ts new file mode 100644 index 0000000000000000000000000000000000000000..716060df23dad35c9e7ba007d6764e7434517df0 --- /dev/null +++ b/backend/src/queries/currentDelegation.ts @@ -0,0 +1,25 @@ +export const getCurrentDelegationQuery: string = ` +SELECT +CASE + WHEN drep_hash.raw IS NULL THEN NULL + ELSE ENCODE(drep_hash.raw, 'hex') + END AS drep_raw, + drep_hash.view AS drep_view, + ENCODE(tx.hash, 'hex') +FROM + delegation_vote +JOIN + tx ON tx.id = delegation_vote.tx_id +JOIN + drep_hash ON drep_hash.id = delegation_vote.drep_hash_id +JOIN + stake_address ON stake_address.id = delegation_vote.addr_id +WHERE + stake_address.hash_raw = DECODE($1, 'hex') +AND NOT EXISTS ( + SELECT * + FROM delegation_vote AS dv2 + WHERE dv2.addr_id = delegation_vote.addr_id + AND dv2.tx_id > delegation_vote.tx_id +) +LIMIT 1;`; diff --git a/backend/src/queries/drepAddrData.ts b/backend/src/queries/drepAddrData.ts index a5c92ef52d39f86315ff6bccdec0bfedfefedce7..0beaafca221554a2907895621f8fb32760d7749c 100644 --- a/backend/src/queries/drepAddrData.ts +++ b/backend/src/queries/drepAddrData.ts @@ -1,5 +1,5 @@ export const getDrepAddrData: string = ` - WITH LatestRegistration AS ( +WITH LatestRegistration AS ( SELECT dr.id AS reg_id, dr.drep_hash_id, diff --git a/backend/src/queries/voterGovActions.ts b/backend/src/queries/voterGovActions.ts new file mode 100644 index 0000000000000000000000000000000000000000..d8b482e860d676eb6054f6471bd27af05c229a64 --- /dev/null +++ b/backend/src/queries/voterGovActions.ts @@ -0,0 +1,185 @@ +export const getVoterGovActionsQuery = ( + queryType: string, + itemsPerPage: number, + offset?: number, +) => ` +WITH DelegatedDReps AS ( + ${ + queryType === 'stake' + ? ` + SELECT + dh.view AS drep_id + FROM + delegation_vote AS dv + JOIN + stake_address AS sa ON sa.id = dv.addr_id + JOIN + drep_hash AS dh ON dh.id = dv.drep_hash_id + WHERE + sa.view = $1 + GROUP BY + dh.view` + : queryType === 'drep' + ? `WITH LatestRegistration AS ( + SELECT + dr.drep_hash_id, + tx_out.stake_address_id as stake_addr, + ROW_NUMBER() OVER (PARTITION BY dr.drep_hash_id ORDER BY dr.tx_id DESC) AS RegRowNum + FROM + drep_registration AS dr + LEFT JOIN + tx AS reg_tx ON dr.tx_id = reg_tx.id + LEFT JOIN + tx_out ON reg_tx.id = tx_out.tx_id + WHERE + tx_out.stake_address_id IS NOT NULL + ), + DRepDelegations AS ( + SELECT DISTINCT + dh.view AS original_drep_id, + COALESCE(delegated_dh.view, dh.view) AS drep_id + FROM + drep_hash AS dh + LEFT JOIN + LatestRegistration AS lr ON dh.id = lr.drep_hash_id AND lr.RegRowNum = 1 + LEFT JOIN + delegation_vote AS dv ON lr.stake_addr = dv.addr_id + LEFT JOIN + drep_hash AS delegated_dh ON dv.drep_hash_id = delegated_dh.id + WHERE + dh.view = $1 + ) + SELECT drep_id + FROM DRepDelegations` + : `SELECT + dh.view AS drep_id + FROM + tx_out AS txo + JOIN + stake_address AS sa ON sa.id = txo.stake_address_id + JOIN + delegation_vote AS dv ON dv.addr_id = sa.id + JOIN + drep_hash AS dh ON dh.id = dv.drep_hash_id + WHERE + txo.address = $1 + AND txo.consumed_by_tx_id IS NULL + GROUP BY + dh.view + ` + } +), +GovActions AS ( + SELECT + SUBSTRING(CAST(gat.hash AS TEXT) FROM 3) AS gov_action_proposal_id, + gap.type, + gap.description, + vp.vote::text, + va.url, + ocvd.json AS metadata, + b.epoch_no, + b.time AS time_voted, + encode(vt.hash, 'hex') AS vote_tx_hash, + dh.view AS drep_id + FROM + voting_procedure vp + JOIN + gov_action_proposal gap ON gap.id = vp.gov_action_proposal_id + JOIN + drep_hash dh ON dh.id = vp.drep_voter + LEFT JOIN + voting_anchor va ON va.id = gap.voting_anchor_id + LEFT JOIN + off_chain_vote_data AS ocvd ON ocvd.voting_anchor_id = va.id + JOIN + tx gat ON gat.id = gap.tx_id + JOIN + tx vt ON vt.id = vp.tx_id + JOIN + block b ON b.id = vt.block_id + JOIN + DelegatedDReps ddr ON dh.view = ddr.drep_id +) +SELECT * FROM GovActions +ORDER BY time_voted DESC +LIMIT ${itemsPerPage} +OFFSET ${offset} +`; + +export const getVoterGovActionsCountQuery = (queryType: string) => ` +WITH DelegatedDReps AS ( + ${ + queryType === 'stake' + ? ` + SELECT + dh.view AS drep_id + FROM + delegation_vote AS dv + JOIN + stake_address AS sa ON sa.id = dv.addr_id + JOIN + drep_hash AS dh ON dh.id = dv.drep_hash_id + WHERE + sa.view = $1 + GROUP BY + dh.view` + : queryType === 'drep' + ? `WITH LatestRegistration AS ( + SELECT + dr.drep_hash_id, + tx_out.stake_address_id as stake_addr, + ROW_NUMBER() OVER (PARTITION BY dr.drep_hash_id ORDER BY dr.tx_id DESC) AS RegRowNum + FROM + drep_registration AS dr + LEFT JOIN + tx AS reg_tx ON dr.tx_id = reg_tx.id + LEFT JOIN + tx_out ON reg_tx.id = tx_out.tx_id + WHERE + tx_out.stake_address_id IS NOT NULL + ), + DRepDelegations AS ( + SELECT DISTINCT + dh.view AS original_drep_id, + COALESCE(delegated_dh.view, dh.view) AS drep_id + FROM + drep_hash AS dh + LEFT JOIN + LatestRegistration AS lr ON dh.id = lr.drep_hash_id AND lr.RegRowNum = 1 + LEFT JOIN + delegation_vote AS dv ON lr.stake_addr = dv.addr_id + LEFT JOIN + drep_hash AS delegated_dh ON dv.drep_hash_id = delegated_dh.id + WHERE + dh.view = $1 + ) + SELECT drep_id + FROM DRepDelegations` + : `SELECT + dh.view AS drep_id + FROM + tx_out AS txo + JOIN + stake_address AS sa ON sa.id = txo.stake_address_id + JOIN + delegation_vote AS dv ON dv.addr_id = sa.id + JOIN + drep_hash AS dh ON dh.id = dv.drep_hash_id + WHERE + txo.address = $1 + AND txo.consumed_by_tx_id IS NULL + GROUP BY + dh.view + ` + } +) +SELECT COUNT(*) AS total +FROM + voting_procedure vp +JOIN + gov_action_proposal gap ON gap.id = vp.gov_action_proposal_id +JOIN + drep_hash dh ON dh.id = vp.drep_voter +JOIN + DelegatedDReps ddr ON dh.view = ddr.drep_id +`; diff --git a/backend/src/voter/voter.controller.ts b/backend/src/voter/voter.controller.ts index 6705d1cf762c131e0793d280a8eeb52383a09207..c0dcbfa5fb1cf84d30fbfbd57dffda4b5c7c53e3 100644 --- a/backend/src/voter/voter.controller.ts +++ b/backend/src/voter/voter.controller.ts @@ -1,9 +1,15 @@ -import { Controller, Get, Param } from '@nestjs/common'; +import { + Controller, + DefaultValuePipe, + Get, + Param, + ParseIntPipe, + Query, +} from '@nestjs/common'; import { VoterService } from './voter.service'; @Controller('voters') export class VoterController { - constructor(private readonly voterService: VoterService) {} //can either be a stakeKey, raw address hex or a drepid @Get(':voterIdentity') @@ -14,4 +20,15 @@ export class VoterController { getAdaHolderCurrentDelegation(@Param('stakeKey') stakeKey: string) { return this.voterService.getAdaHolderCurrentDelegation(stakeKey); } + //can either be a stakeKey, raw address hex or a drepid + @Get(':voterIdentity/governance-actions') + getGovActions( + @Param('voterIdentity') voterIdentity: string, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) + page: number, + @Query('perPage', new DefaultValuePipe(6), ParseIntPipe) + perPage: number, + ) { + return this.voterService.getGovActions(voterIdentity, page, perPage); + } } diff --git a/backend/src/voter/voter.service.ts b/backend/src/voter/voter.service.ts index 180244280750577d3699e2dfb69b03d15d700756..b50f6ec47fec2baaf423c9545b590eac4befe41f 100644 --- a/backend/src/voter/voter.service.ts +++ b/backend/src/voter/voter.service.ts @@ -1,11 +1,15 @@ import { Injectable } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; -import { VoterData } from 'src/common/types'; -import { DrepService } from 'src/drep/drep.service'; +import { Delegation, VoterData } from 'src/common/types'; +import { getCurrentDelegationQuery } from 'src/queries/currentDelegation'; import { getDrepAddrData } from 'src/queries/drepAddrData'; import { getAddrDataQuery } from 'src/queries/getAddrData'; import { getStakeKeyData } from 'src/queries/getStakeKeyData'; import { getVoterDelegationHistory } from 'src/queries/getVoterDelegationHistory'; +import { + getVoterGovActionsCountQuery, + getVoterGovActionsQuery, +} from 'src/queries/voterGovActions'; import { DataSource } from 'typeorm'; @Injectable() @@ -48,39 +52,61 @@ export class VoterService { break; } - return Array.isArray(voterData) ? { - ...voterData[0], - delegationHistory, - isDelegated: delegationHistory.length > 0, - } : null; + return Array.isArray(voterData) + ? { + ...voterData[0], + delegationHistory, + isDelegated: delegationHistory.length > 0, + } + : null; } - async getAdaHolderCurrentDelegation(stakeKey: string) { + async getAdaHolderCurrentDelegation(stakeKey: string): Promise<Delegation> { const delegation = await this.cexplorerService.manager.query( - `SELECT - CASE - WHEN drep_hash.raw IS NULL THEN NULL - ELSE ENCODE(drep_hash.raw, 'hex') - END AS drep_raw, - drep_hash.view AS drep_view, - ENCODE(tx.hash, 'hex') - FROM - delegation_vote - JOIN - tx ON tx.id = delegation_vote.tx_id - JOIN - drep_hash ON drep_hash.id = delegation_vote.drep_hash_id - JOIN - stake_address ON stake_address.id = delegation_vote.addr_id - WHERE - stake_address.hash_raw = DECODE('${stakeKey}', 'hex') - AND NOT EXISTS ( - SELECT * - FROM delegation_vote AS dv2 - WHERE dv2.addr_id = delegation_vote.addr_id - AND dv2.tx_id > delegation_vote.tx_id - ) - LIMIT 1;`, + getCurrentDelegationQuery, + [stakeKey], ); return delegation[0]; } + + async getGovActions( + voterIdentity: string, + currentPage: number, + itemsPerPage: number, + ) { + const offset = (currentPage - 1) * itemsPerPage; + let queryType: 'stake' | 'drep' | 'wallet'; + let param: string; + + if (voterIdentity.startsWith('stake')) { + queryType = 'stake'; + param = voterIdentity; + } else if (voterIdentity.startsWith('drep')) { + queryType = 'drep'; + param = voterIdentity; + } else { + queryType = 'wallet'; + param = voterIdentity; + } + + const govActions = await this.cexplorerService.manager.query( + getVoterGovActionsQuery(queryType, itemsPerPage, offset), + [param], + ); + + const totalResults = await this.cexplorerService.manager.query( + getVoterGovActionsCountQuery(queryType), + [param], + ); + + const totalItems = parseInt(totalResults[0]?.total, 10); + const totalPages = Math.ceil(totalItems / itemsPerPage); + + return { + data: govActions, + totalItems, + currentPage, + itemsPerPage, + totalPages, + }; + } } diff --git a/frontend/src/app/[locale]/voters/[voterId]/impact/page.tsx b/frontend/src/app/[locale]/voters/[voterId]/impact/page.tsx index 6b27186ca3bf167df9fe530757b72cc363deea55..9ac795b18554348f2d0ade344307da93a52096fa 100644 --- a/frontend/src/app/[locale]/voters/[voterId]/impact/page.tsx +++ b/frontend/src/app/[locale]/voters/[voterId]/impact/page.tsx @@ -1,10 +1,13 @@ +'use client'; import VoterImpact from '@/components/voters/VoterImpact'; import React from 'react'; const page = () => { return ( - <div className='base_container mx-auto h-screen bg-white'> - <VoterImpact /> + <div className="min-h-screen"> + <div className="flex flex-col gap-3 bg-white px-6 py-4"> + <VoterImpact /> + </div> </div> ); }; diff --git a/frontend/src/components/atoms/DrepDelegatorCard.tsx b/frontend/src/components/atoms/DrepDelegatorCard.tsx index 01735a59890d0816ab3d4a06efe3591eaf1d8d15..ac34c05c10c128957225a4d8beea727d0bc0d03c 100644 --- a/frontend/src/components/atoms/DrepDelegatorCard.tsx +++ b/frontend/src/components/atoms/DrepDelegatorCard.tsx @@ -41,9 +41,17 @@ const DrepDelegatorCard = ({ item }: { item: DelegationData }) => { <p className="text-sm font-bold"> {formatTotalStake(item?.total_stake, item?.added_power)} ₳ </p> - <p className="text-base"> - {shortenAddress(item?.stake_address, addressLength)} - </p> + <Link + prefetch={false} + href={ + item?.stake_address ? `/voters/${item?.stake_address}` : '#' + } + className="hover:font-medium" + > + <p className="text-base"> + {shortenAddress(item?.stake_address, addressLength)} + </p> + </Link> <div className="flex w-full items-center justify-center"> {!!item.previous_drep ? ( isPreviousTargetDRep ? ( diff --git a/frontend/src/components/atoms/DrepVoteTimelineCard.tsx b/frontend/src/components/atoms/DrepVoteTimelineCard.tsx index 83aaee0e5cc5cb3074f3b39e6d3f1832bd3281bc..6acffcab76ad81887eee1515895e7ea4b232d34f 100644 --- a/frontend/src/components/atoms/DrepVoteTimelineCard.tsx +++ b/frontend/src/components/atoms/DrepVoteTimelineCard.tsx @@ -19,7 +19,7 @@ const DrepVoteTimelineCard = ({ item }: { item: any }) => { return ( <Box id="epoch-card" - className="flex max-w-md flex-col gap-3 rounded-xl bg-white p-3 shadow-lg" + className="flex w-full flex-col gap-3 rounded-xl bg-white p-3 shadow-lg" > <VoteStatusChip date={item.time_voted} /> <hr /> diff --git a/frontend/src/components/voters/VoterDashboardTabs.tsx b/frontend/src/components/voters/VoterDashboardTabs.tsx index c401077fe7e80e38a31d9795102aa4b22929af1d..b66ee8ee96dc665a166f332439d2be446713c8d1 100644 --- a/frontend/src/components/voters/VoterDashboardTabs.tsx +++ b/frontend/src/components/voters/VoterDashboardTabs.tsx @@ -1,11 +1,17 @@ import { Tab, Tabs } from '@mui/material'; -import { useParams, useRouter } from 'next/navigation'; -import React, { useState } from 'react'; +import { useParams, usePathname, useRouter } from 'next/navigation'; +import React, { useEffect, useState } from 'react'; const VoterDashboardTabs = () => { const { voterId } = useParams(); const router = useRouter(); + const pathname = usePathname(); const [activeTab, setActiveTab] = useState(0); + + useEffect(() => { + if (pathname.includes('impact')) setActiveTab(1); + }, []); + const handleChange = (event: React.SyntheticEvent, newValue: number) => { setActiveTab(newValue); }; diff --git a/frontend/src/components/voters/VoterImpact.tsx b/frontend/src/components/voters/VoterImpact.tsx index 190c3b33e991eac1773223c5492ce77989d670d4..abc88e786a325082ca2f8f33e8c2453095d2c984 100644 --- a/frontend/src/components/voters/VoterImpact.tsx +++ b/frontend/src/components/voters/VoterImpact.tsx @@ -1,11 +1,104 @@ -import { Typography } from '@mui/material'; -import React from 'react'; +import { useGetVoterGovActionsQuery } from '@/hooks/useGetVoterGovActions'; +import { Box, Paper, Typography } from '@mui/material'; +import { useParams, useSearchParams } from 'next/navigation'; +import React, { useEffect, useState } from 'react'; +import DrepVoteTimelineCard from '../atoms/DrepVoteTimelineCard'; +import GovActionLoader from '../Loaders/GovActionLoader'; +import { Address } from '@emurgo/cardano-serialization-lib-asmjs'; +import Pagination from '../molecules/Pagination'; const VoterImpact = () => { + const [currentPage, setCurrentPage] = useState(1); + const { voterId } = useParams(); + const searchParams = useSearchParams(); + + useEffect(() => { + setCurrentPage(Number(searchParams.get('page') || 1)); + }, [searchParams]); + + const convertAddressToBech32 = (address: string) => { + if ( + address.includes('addr') || + address.includes('stake') || + address.includes('drep') + ) { + return address; + } else return Address.from_bytes(Buffer.from(address, 'hex')).to_bech32(); + }; + + const { voterGovActions, isVoterGovActionsLoading } = + useGetVoterGovActionsQuery(convertAddressToBech32(voterId as string), currentPage); + return ( - <div> - <Typography variant="h1">Coming Soon</Typography> - </div> + <Box className="flex flex-col gap-6"> + <Typography variant="h4" fontWeight="bold"> + Your DReps' Governance Contributions{' '} + </Typography> + <Typography className="px-2 leading-relaxed"> + This page displays all governance actions that have been voted on by + DReps (Delegate Representatives) you have delegated to. It includes + votes from DReps you've directly delegated to, as well as any predefined + DRep options you may have selected. This comprehensive view helps you + stay informed about how your chosen representatives are participating in + the decision-making process. + </Typography> + {isVoterGovActionsLoading && ( + <ul + role="list" + className="grid grid-cols-1 gap-6 border-t border-green-400 py-6 lg:grid-cols-2 xl:grid-cols-3" + > + {Array.from({ length: 6 }).map((_, index) => ( + <li key={index}> + <GovActionLoader /> + </li> + ))} + </ul> + )} + {!isVoterGovActionsLoading && voterGovActions?.data.length === 0 && ( + <Box sx={{ py: 3 }}> + <Paper + elevation={2} + sx={{ + p: 3, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + height: 200, + }} + > + <Typography variant="body1" color="text.secondary"> + Your DRep(s) have not yet voted on governance actions. + </Typography> + </Paper> + </Box> + )} + {!isVoterGovActionsLoading && voterGovActions?.data.length > 0 && ( + <ul + role="list" + className="grid grid-cols-1 gap-6 border-t border-green-400 py-6 lg:grid-cols-2 xl:grid-cols-3" + > + {voterGovActions && + voterGovActions?.data.length > 0 && + voterGovActions?.data.map((action) => ( + <li key={action?.vote_tx_hash}> + <DrepVoteTimelineCard item={action} /> + </li> + ))} + </ul> + )} + {!isVoterGovActionsLoading && + voterGovActions?.data && + voterGovActions?.data.length > 0 && ( + <Box className="flex justify-end"> + <Pagination + currentPage={voterGovActions.currentPage} + totalPages={voterGovActions.totalPages} + totalItems={voterGovActions.totalItems} + dataType="Governance Actions" + /> + </Box> + )} + </Box> ); }; diff --git a/frontend/src/constants/queryKeys.ts b/frontend/src/constants/queryKeys.ts index bbb43fbc021687fd36d74c557645636763b64ab4..8a63ff82847dc3dcb26d28e8b16bf26bcc7012ab 100644 --- a/frontend/src/constants/queryKeys.ts +++ b/frontend/src/constants/queryKeys.ts @@ -6,5 +6,6 @@ export const QUERY_KEYS = { getAllDRepsKey: 'getAllDRepsKey', getDRepStatsKey: 'getDRepStatsKey', getDRepTimelineKey: 'getDRepTimelineKey', - getDrepDelegators: 'getDrepDelegators' + getDrepDelegators: 'getDrepDelegators', + getVoterGovActions: 'getVoterGovActions' }; diff --git a/frontend/src/hooks/useGetVoterGovActions.ts b/frontend/src/hooks/useGetVoterGovActions.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9ab4fedc5bd7ec9ef47ae6e6b6903335f1dbfdb --- /dev/null +++ b/frontend/src/hooks/useGetVoterGovActions.ts @@ -0,0 +1,15 @@ +import { useQuery } from 'react-query'; +import { getVoterGovActions } from '@/services/requests/getVoterGovActions'; +import { QUERY_KEYS } from '@/constants/queryKeys'; +import { VoterGovActions } from '../../types/api'; + +export const useGetVoterGovActionsQuery = (voterIdentity: string, page?: number,) => { + const { data, isLoading } = useQuery<VoterGovActions>({ + queryKey: [QUERY_KEYS.getVoterGovActions, voterIdentity, page], + queryFn: async () => await getVoterGovActions(voterIdentity, page), + enabled: !!voterIdentity, + refetchOnWindowFocus: false, + }); + + return { voterGovActions: data, isVoterGovActionsLoading: isLoading }; +}; diff --git a/frontend/src/services/requests/getVoterGovActions.ts b/frontend/src/services/requests/getVoterGovActions.ts new file mode 100644 index 0000000000000000000000000000000000000000..f209a115830f419454896b8d8cbf8ced1278f19d --- /dev/null +++ b/frontend/src/services/requests/getVoterGovActions.ts @@ -0,0 +1,14 @@ +import axiosInstance from '../axiosInstance'; + +export const getVoterGovActions = async ( + voterIdentity: string, + page?: number, +) => { + const response = await axiosInstance.get( + `/voters/${voterIdentity}/governance-actions`, + { + params: { page }, + }, + ); + return response.data; +}; diff --git a/frontend/types/api.ts b/frontend/types/api.ts index 52c761ba67a7de1b870705899ec42ddf0eb757a7..5ed6810a941efe55a68c5972f657304f0e0834ad 100644 --- a/frontend/types/api.ts +++ b/frontend/types/api.ts @@ -18,6 +18,27 @@ export type Delegators = { totalPages: number; }; +export type GovAction = { + gov_action_proposal_id: string | null; + type: string | null; + description: {}; + vote: string | null; + url: string | null; + metadata: string | null; + epoch_no: number | null; + time_voted: string | null; + vote_tx_hash: string | null; + drep_id: string | null; +}; + +export type VoterGovActions = { + data: GovAction[]; + totalItems: number; + currentPage: number; + itemsPerPage: number; + totalPages: number; +}; + export type SingleDRep = { drep_deletedAt?: string | null; drep_id?: number | null; @@ -80,11 +101,11 @@ export type DelegationData = { total_stake: string; added_power: boolean; }; -export interface VoterData{ +export interface VoterData { address: string; total_value: number; drep_id: string; stake_address: string; delegationHistory: any[]; isDelegated: boolean; -} \ No newline at end of file +}