diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 943fb96..6d8d8c4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -135,8 +135,8 @@ build_frontend: docker push "$CI_APPLICATION_REPOSITORY/1694.io-frontend:$CI_APPLICATION_TAG" docker push "$CI_APPLICATION_REPOSITORY/1694.io-frontend:latest" - only: - - branches + rules: + - if: '$DEPLOY_WEBSITE == "true"' build_backend: stage: build @@ -160,8 +160,8 @@ build_backend: -t "$CI_APPLICATION_REPOSITORY/1694.io-backend:latest" . docker push "$CI_APPLICATION_REPOSITORY/1694.io-backend:$CI_APPLICATION_TAG" docker push "$CI_APPLICATION_REPOSITORY/1694.io-backend:latest" - only: - - branches + rules: + - if: '$DEPLOY_WEBSITE == "true"' .deploy_postgres_web: &deploy_postgres_web <<: *deploy_template @@ -252,8 +252,8 @@ app-sancho: - job: build_backend environment: name: sancho - only: - - branches + rules: + - if: '$DEPLOY_WEBSITE == "true"' postgres-app-preview: <<: *deploy_postgres_web @@ -388,7 +388,7 @@ production: needs: - job: app-preview rules: - - if: $CI_COMMIT_BRANCH == 'main' + - if: '$CI_COMMIT_BRANCH == "main" && ($DEPLOY_WEBSITE == "true")' when: manual ui-chrome-tests: diff --git a/backend/.env.example b/backend/.env.example index be81869..1003659 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,4 +1,14 @@ BLOCKFROST_NETWORK_PROJECT_ID= BLOCKFROST_NETWORK_URL= +BLOCKFROST_IPFS_URL= BLOCKFROST_IPFS_PROJECT_ID= +BLOCKFROST_NETWORK_PROJECT_ID_FALLBACK= +BLOCKFROST_NETWORK_URL_FALLBACK= +BLOCKFROST_IPFS_URL_FALLBACK= +BLOCKFROST_IPFS_PROJECT_ID_FALLBACK= +DATABASE_HOST= +DATABASE_PORT= +DATABASE_USERNAME= +DATABASE_PASSWORD= +DATABASE_NAME= IPFS_GATEWAY= \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index cd9b8ad..e9ca675 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,13 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js", + "migration:make": "yarn run typeorm migration:create -d src/typeorm.config.ts -n", + "migration:generate": "yarn run typeorm migration:generate -d src/typeorm.config.ts", + "migration:show": "yarn run typeorm migration:show -d src/typeorm.config.ts", + "migrate:up": "yarn run typeorm migration:run -d src/typeorm.config.ts", + "migrate:down": "yarn run typeorm migration:revert -d src/typeorm.config.ts" }, "dependencies": { "@nestjs/axios": "^3.0.2", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 35e785d..c517419 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -13,6 +13,7 @@ import { AuthService } from './auth/auth.service'; import { ProposalsModule } from './proposals/proposals.module'; import { MiscellaneousModule } from './miscellaneous/miscellaneous.module'; import {NotificationsModule} from "./notifications/notifications.module"; +import { BlockfrostModule } from './blockfrost/blockfrost.module'; @Module({ imports: [ @@ -31,7 +32,8 @@ import {NotificationsModule} from "./notifications/notifications.module"; ReactionsModule, ProposalsModule, MiscellaneousModule, - NotificationsModule + NotificationsModule, + BlockfrostModule ], controllers: [], providers: [AuthService], diff --git a/backend/src/attachment/attachment.module.ts b/backend/src/attachment/attachment.module.ts index d144858..fedbda5 100644 --- a/backend/src/attachment/attachment.module.ts +++ b/backend/src/attachment/attachment.module.ts @@ -5,6 +5,7 @@ import { Attachment } from 'src/entities/attachment.entity'; import { AttachmentController } from './attachment.controller'; import { HttpModule } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; +import { BlockfrostService } from 'src/blockfrost/blockfrost.service'; @Module({ imports: [ @@ -15,6 +16,6 @@ import { ConfigService } from '@nestjs/config'; }), ], controllers: [AttachmentController], - providers: [AttachmentService, ConfigService], + providers: [AttachmentService, BlockfrostService] }) export class AttachmentModule {} diff --git a/backend/src/attachment/attachment.service.ts b/backend/src/attachment/attachment.service.ts index 454da54..318ef7d 100644 --- a/backend/src/attachment/attachment.service.ts +++ b/backend/src/attachment/attachment.service.ts @@ -14,8 +14,8 @@ import { IPFSResponse, } from 'src/common/types'; import { HttpService } from '@nestjs/axios'; -import { ConfigService } from '@nestjs/config'; import { Response } from 'express'; +import { BlockfrostService } from 'src/blockfrost/blockfrost.service'; @Injectable() export class AttachmentService { @@ -23,7 +23,7 @@ export class AttachmentService { @InjectDataSource('default') private voltaireService: DataSource, private httpService: HttpService, - private configService: ConfigService, + private blockfrostService: BlockfrostService, ) {} async parseMimeType(mimeType: string) { switch (mimeType) { @@ -200,24 +200,24 @@ export class AttachmentService { try { const res = await lastValueFrom( this.httpService.post( - 'https://ipfs.blockfrost.io/api/v0/ipfs/add', + `${this.blockfrostService.blockfrostIPFSURL}/api/v0/ipfs/add`, attachment, { headers: { - project_id: this.configService.get( - 'BLOCKFROST_IPFS_PROJECT_ID', - ), + project_id: this.blockfrostService.blockfrostIPFSProjectID, }, }, ), ); const ipfsRes = res.data as IPFSResponse; //then auto-pin the attachment - const ipfsPinStatus =await this.pinAttachmentToIPFS(ipfsRes.ipfs_hash) as IPFSPinResponse; + const ipfsPinStatus = (await this.pinAttachmentToIPFS( + ipfsRes.ipfs_hash, + )) as IPFSPinResponse; return { ...ipfsRes, - state: ipfsPinStatus.state - } + state: ipfsPinStatus.state, + }; } catch (error) { console.error(error.response.data || error.response || error); throw new HttpException(error.response.data, error.response.status); @@ -227,13 +227,11 @@ export class AttachmentService { try { const res = await lastValueFrom( this.httpService.post( - `https://ipfs.blockfrost.io/api/v0/ipfs/pin/add/${hash}`, + `${this.blockfrostService.blockfrostIPFSURL}/api/v0/ipfs/pin/add/${hash}`, {}, { headers: { - project_id: this.configService.get( - 'BLOCKFROST_IPFS_PROJECT_ID', - ), + project_id: this.blockfrostService.blockfrostIPFSProjectID, }, }, ), @@ -248,12 +246,10 @@ export class AttachmentService { try { const res = await lastValueFrom( this.httpService.get( - `https://ipfs.blockfrost.io/api/v0/ipfs/pin/list/${hash}`, + `${this.blockfrostService.blockfrostIPFSURL}/api/v0/ipfs/pin/list/${hash}`, { headers: { - project_id: this.configService.get( - 'BLOCKFROST_IPFS_PROJECT_ID', - ), + project_id: this.blockfrostService.blockfrostIPFSProjectID, }, }, ), @@ -268,13 +264,11 @@ export class AttachmentService { try { const res = await lastValueFrom( this.httpService.post( - `https://ipfs.blockfrost.io/api/v0/ipfs/pin/remove/${hash}`, + `${this.blockfrostService.blockfrostIPFSURL}/api/v0/ipfs/pin/remove/${hash}`, {}, { headers: { - project_id: this.configService.get( - 'BLOCKFROST_IPFS_PROJECT_ID', - ), + project_id: this.blockfrostService.blockfrostIPFSProjectID, }, }, ), @@ -289,12 +283,10 @@ export class AttachmentService { try { const response = await lastValueFrom( this.httpService.get( - `https://ipfs.blockfrost.io/api/v0/ipfs/gateway/${hash}`, + `${this.blockfrostService.blockfrostIPFSURL}/api/v0/ipfs/gateway/${hash}`, { headers: { - project_id: this.configService.get( - 'BLOCKFROST_IPFS_PROJECT_ID', - ), + project_id: this.blockfrostService.blockfrostIPFSProjectID, }, responseType: 'stream', // Used stream to handle large files or non-JSON data }, diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 2f67c7c..f2c83e3 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,10 +1,23 @@ import { Global, Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import jwtConstants from './jwtConstants'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +type Payload = { + drepId?: string; + voterId?: string; + stakeKey: string; + signature: string; + key: string; +}; @Injectable() export class AuthService { - constructor(private jwtService: JwtService) {} - async signJWT(payload: any, tte: number | string) { + constructor( + private jwtService: JwtService, + @InjectDataSource('default') + private voltaireService: DataSource, + ) {} + async signJWT(payload: Payload, tte: number | string) { const accessSecret = jwtConstants.secret; return this.jwtService.signAsync(payload, { secret: accessSecret, @@ -17,11 +30,34 @@ export class AuthService { secret: accessSecret, }); } - //the payload could consist of the - async login(payload: any, tte: number | string) { + //the payload could consist of the + async login(payload: Payload, tte: number | string) { //basically should check if the user signature is valid in the case of a drep or just provide a token for a normal user. const token = await this.signJWT(payload, tte); - return { token }; + const signatureDto = { + drep: payload.drepId, + voterId: payload.voterId, + stakeKey: payload.stakeKey, + signatureKey: payload.key, + signature: payload.signature, + }; + //check for existing signature + const existingSig = await this.voltaireService + .getRepository('Signature') + .findOne({ + where: { signature: payload.signature, signatureKey:payload.key, stakeKey: payload.stakeKey, }, + }); + if (existingSig) { + //update the signature + const updatedSig=await this.voltaireService + .getRepository('Signature') + .update(existingSig.id, signatureDto) + return { token, updatedSig }; + } + const insertedSig = await this.voltaireService + .getRepository('Signature') + .insert(signatureDto); + return { token, insertedSig }; } async verifyLogin(token: string) { // should check if there is an existing drep signature in the db. Well this is for dreps who have a profile. diff --git a/backend/src/blockfrost/blockfrost.module.ts b/backend/src/blockfrost/blockfrost.module.ts new file mode 100644 index 0000000..21b22bb --- /dev/null +++ b/backend/src/blockfrost/blockfrost.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { BlockfrostService } from './blockfrost.service'; +import { HttpModule } from '@nestjs/axios'; + +@Module({ + imports: [ + HttpModule.register({ + maxRedirects: 5, + }), + ], + providers: [BlockfrostService], +}) +export class BlockfrostModule {} diff --git a/backend/src/blockfrost/blockfrost.service.ts b/backend/src/blockfrost/blockfrost.service.ts new file mode 100644 index 0000000..caa911c --- /dev/null +++ b/backend/src/blockfrost/blockfrost.service.ts @@ -0,0 +1,104 @@ +import { HttpService } from '@nestjs/axios'; +import { HttpException, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import { lastValueFrom } from 'rxjs'; + +@Injectable() +export class BlockfrostService { + blockfrostAPIURL: string; + blockfrostAPIProjectID: string; + blockfrostIPFSURL: string; + blockfrostIPFSProjectID: string; + blockfrostAPIFallbackURL: string; + blockfrostAPIFallbackProjectID: string; + blockfrostIPFSFallbackURL: string; + blockfrostIPFSFallbackProjectID: string; + constructor( + private configService: ConfigService, + private httpService: HttpService, + ) { + //use the external blockfrost API to fetch data(fallback) before the local blockfrost API is ready + this.blockfrostAPIURL = this.configService.get( + 'BLOCKFROST_NETWORK_URL', + ); + this.blockfrostAPIFallbackURL = this.configService.get( + 'BLOCKFROST_NETWORK_URL_FALLBACK', + ); + this.blockfrostAPIFallbackProjectID = this.configService.get( + 'BLOCKFROST_NETWORK_PROJECT_ID_FALLBACK', + ); + this.blockfrostAPIProjectID = this.configService.get( + 'BLOCKFROST_NETWORK_PROJECT_ID', + ); + this.blockfrostIPFSFallbackURL = this.configService.get( + 'BLOCKFROST_IPFS_URL_FALLBACK', + ); + this.blockfrostIPFSURL = this.configService.get( + 'BLOCKFROST_IPFS_URL', + ); + this.blockfrostIPFSFallbackProjectID = this.configService.get( + 'BLOCKFROST_IPFS_PROJECT_ID_FALLBACK', + ); + this.blockfrostIPFSProjectID = this.configService.get( + 'BLOCKFROST_IPFS_PROJECT_ID', + ); + } + async getLatestBlock() { + try { + //fetch the latest block from external blockfrost API + const apiUrl = `${this.blockfrostAPIFallbackURL}/api/v0/blocks/latest`; //use the fallback API + const response = await axios.get(apiUrl, { + headers: { + project_id: this.blockfrostAPIFallbackProjectID, //use the fallback project ID + }, + }); + return response.data; + } catch (error) { + console.log(error); + throw new HttpException( + error?.response?.data || 'An error occured', + error?.response?.status || 500, + ); + } + } + + async getLatestEpoch() { + try { + const apiUrl = `${this.blockfrostAPIURL}/api/v0/epochs/latest`; + const response = await lastValueFrom( + this.httpService.get(apiUrl, { + headers: { + project_id: this.blockfrostAPIProjectID, + }, + }), + ); + return response.data; + } catch (error) { + console.log(error); + throw new HttpException( + error?.response?.data || 'An error occured', + error?.response?.status || 500, + ); + } + } + async getEpochParameters() { + try { + const apiUrl = `${this.blockfrostAPIURL}/api/v0/epochs/latest/parameters`; + const response = await lastValueFrom( + this.httpService.get(apiUrl, { + headers: { + project_id: this.blockfrostAPIProjectID, + }, + }), + ); + return response.data; + } catch (error) { + console.log(error); + throw new HttpException( + error?.response?.data || 'An error occured', + error?.response?.status || 500, + ); + } + } +} diff --git a/backend/src/comments/comments.controller.ts b/backend/src/comments/comments.controller.ts index 6592ac4..3c565b0 100644 --- a/backend/src/comments/comments.controller.ts +++ b/backend/src/comments/comments.controller.ts @@ -18,12 +18,16 @@ export class CommentsController { @Param('parentEntity') parentEntity: string, @Body('comment') comment: string, @Body('voter') voter: string, + @Body('rootEntity') rootEntity: string, + @Body('rootEntityId') rootEntityId: number, ) { return this.commentsService.insertComment( parentId, parentEntity, comment, voter, + rootEntity, + rootEntityId, ); } diff --git a/backend/src/comments/comments.service.ts b/backend/src/comments/comments.service.ts index f6a3652..40ed2c4 100644 --- a/backend/src/comments/comments.service.ts +++ b/backend/src/comments/comments.service.ts @@ -23,13 +23,13 @@ export class CommentsService { comment.id, 'comment', ); - comment.comments= await this.getComments(comment.id, 'comment'); + comment.comments = await this.getComments(comment.id, 'comment'); } - const sortedComments=comments.sort( + const sortedComments = comments.sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), ); - return sortedComments + return sortedComments; } async insertComment( @@ -37,6 +37,8 @@ export class CommentsService { parentEntity: string, comment: string, voter: string, + rootEntity: string, + rootEntityId: number, ) { const newComment = this.voltaireService.getRepository('Comment').create({ parentId, @@ -44,6 +46,18 @@ export class CommentsService { content: comment, voter, }); + + if (rootEntity === 'note') { + const note = await this.voltaireService + .getRepository('Note') + .findOne({ where: { id: rootEntityId } }); + if (note) { + await this.voltaireService + .getRepository('Note') + .update({ id: rootEntityId }, { updatedAt: new Date() }); + } + } + return this.voltaireService.getRepository('Comment').save(newComment); } diff --git a/backend/src/common/types.ts b/backend/src/common/types.ts index 7af0890..cd4d508 100644 --- a/backend/src/common/types.ts +++ b/backend/src/common/types.ts @@ -59,3 +59,146 @@ export type IPFSPinStatusResponse = { size: string; state: 'queued' | 'pinned' | 'unpinned' | 'failed' | 'gc'; }; + +/** + * Represents the response object for a Blockfrost block. + * + * @remarks + * This type contains information about a specific block in the Blockfrost blockchain. + * + * + * @remarks {height} - block number + */ +export type BlockfrostBlockRes = { + time: number; + height: number; // block number + hash: string; + slot: number; + epoch: number; + epoch_slot: number; + slot_leader: string; + size: number; + tx_count: number; + output: string; + fees: string; + block_vrf: string; + op_cert: string; + op_cert_counter: string; + previous_block: string; + next_block: null | string; + confirmations: number; +}; +export type NodeBlockRes = { + hash: string; + epoch_no: number; + slot_no: string; + epoch_slot_no: number; + block_no: number; + previous_id: string; + slot_leader: string; + size: number; + time: string; + tx_count: string; + proto_major: number; + proto_minor: number; + vrf_key: string; + op_cert: string; + op_cert_counter: string; +}; +export interface VoterData { + address: string; + total_stake: number; + drep_id: string; + stake_address: string; + delegationHistory: any[]; + isDelegated: boolean; +} +export type DRepRegistrationData = { + drep_hash_id: number; + reg_tx_hash: string; + date_of_registration: Date; + epoch_of_registration: number; +}; +export type EpochActivityResponse = { + start_time: Date; + end_time: Date; + no: number; + type: string; +}; +export type VotingActivityHistory = { + view: string; + gov_action_proposal_id: string; + prop_inception: Date; + type: string; + description: string; + voting_anchor_id: string; + vote: string; + metadata: any; + time_voted: Date; + proposal_epoch: number; + voting_epoch: number; + url: string; +}; +export type DRepDelegatorsHistoryRecord = { + stake_address: string; + target_drep: string; + current_drep: string; + previous_drep: string; + timestamp: string; + delegation_epoch: number; + tx_hash: string; + type: 'delegation'; + total_stake: string; + added_power: boolean; +}; + +export type DRepDelegatorsHistoryResponse = DRepDelegatorsHistoryRecord[]; + +export type VoterNoteResponseRecord = { + deletedAt?: Date; + id: number; + createdAt: Date; + note_updatedAt: Date; + title: string; + tag?: string; + content: string; + visibility: string; + drepId?: number; + authorId?: number; + comments?: any[]; + reactions?: any[]; + type: 'note'; + timestamp: string; +}; +export type VoterNoteResponse = VoterNoteResponseRecord[]; +export type ClaimedProfile = { + type: 'claimed_profile'; + drep_id: string; + claimingId: string; + claimedDRepId: string; +}; + +export interface DRepTimelineParams { + drep: any; + drepVoterId: string; + stakeKeyBech32?: string; + delegation?: Delegation; + beforeDate?: number; + tillDate?: number; + filterValues?: string[]; +} +export interface TimelineEntry { + type: string; + timestamp: string | Date; + [key: string]: any; +} +export interface TimelineFilters { + includeVotingActivity: boolean; + includeDelegations: boolean; + includeNotes: boolean; + includeClaimedProfile: boolean; + includeRegistration: boolean; +} + + + diff --git a/backend/src/db.module.ts b/backend/src/db.module.ts index 410df7b..3ec0d46 100644 --- a/backend/src/db.module.ts +++ b/backend/src/db.module.ts @@ -1,13 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import { Drep } from './entities/drep.entity'; -import { Attachment } from './entities/attachment.entity'; -import { Reaction } from './entities/reaction.entity'; -import { Note } from './entities/note.entity'; -import { Comment } from './entities/comment.entity'; -import { Signature } from './entities/signatures.entity'; -import { Metadata } from './entities/metadata.entity'; @Module({ imports: [ @@ -18,20 +11,18 @@ import { Metadata } from './entities/metadata.entity'; useFactory: (configService: ConfigService) => ({ type: 'postgres', host: configService.get('DATABASE_HOST', 'web_db'), - port: configService.get('DATABASE_PORT', 5432), + port: +configService.get('DATABASE_PORT', 5432), username: configService.get('DATABASE_USERNAME', 'postgres'), password: configService.get('DATABASE_PASSWORD', 'postgres'), database: configService.get('DATABASE_NAME', '1694'), - entities: [ - Drep, - Note, - Attachment, - Comment, - Reaction, - Signature, - Metadata, - ], - synchronize: true, + entities: [__dirname + '/entities/*.entity.{ts,js}'], + synchronize: false, + migrations: [__dirname + '/migrations/*{.ts,.js}'], + extra: { + charset: 'utf8mb4_unicode_ci', + }, + migrationsRun: true, + logging: true, }), }), TypeOrmModule.forRootAsync({ diff --git a/backend/src/drep/drep.controller.ts b/backend/src/drep/drep.controller.ts index 1ebb6fb..d048b0a 100644 --- a/backend/src/drep/drep.controller.ts +++ b/backend/src/drep/drep.controller.ts @@ -14,7 +14,6 @@ import { createDrepDto, ValidateMetadataDTO } from 'src/dto'; import { DrepService } from './drep.service'; import { VoterService } from 'src/voter/voter.service'; import { Delegation, StakeKeys } from 'src/common/types'; -import { Response } from 'express'; @Controller('dreps') export class DrepController { @@ -33,6 +32,7 @@ export class DrepController { @Query('order') order?: string, @Query('onChainStatus') onChainStatus?: 'active' | 'inactive', @Query('campaignStatus') campaignStatus?: 'claimed' | 'unclaimed', + @Query('includeRetired') includeRetired?: 'true' | 'undefined', @Query('type') type?: 'has_script', // add more types if needed ) { return this.drepService.getAllDReps( @@ -43,7 +43,8 @@ export class DrepController { order, onChainStatus, campaignStatus, - type + Boolean(includeRetired), + type, ); } @Get('epochs/latest/parameters') @@ -82,7 +83,7 @@ export class DrepController { if (drepId) { drep = await this.drepService.getSingleDrepViaID(drepId); - drepVoterId = drep.signature_drepVoterId; + drepVoterId = drep.signature_voterId; } else if (drepVoterId) { drep = await this.drepService.getSingleDrepViaVoterID(drepVoterId); } @@ -92,15 +93,15 @@ export class DrepController { await this.voterService.getAdaHolderCurrentDelegation(stakeKey); } - const drepTimeline = await this.drepService.getDrepTimeline( + const drepTimeline = await this.drepService.getDrepTimeline({ drep, drepVoterId, stakeKeyBech32, delegation, - startTimeCursor, - endTimeCursor, + beforeDate: startTimeCursor, + tillDate: endTimeCursor, filterValues, - ); + }); return drepTimeline; } @@ -113,31 +114,25 @@ export class DrepController { updateDetails(@Param('id') drepId: number, @Body() drep: createDrepDto) { return this.drepService.updateDrepInfo(drepId, drep); } + + @Get(':voterId/metadata') + getMetadata(@Param('voterId') voterId: string) { + return this.drepService.getMetadata(voterId); + } + @Get('/metadata/external') getExternalMetadata(@Query('metadataUrl') metadataUrl: string) { return this.drepService.getMetadataFromExternalLink(metadataUrl); } - @Get(':drepId/metadata/:hash') - getMetadata( - @Param('drepId') drepId: number, - @Param('hash') hash: string, - @Res() res: Response, - ) { - return this.drepService.getMetadata(drepId, hash, res); - } + @Post('metadata/validate') validateMetadata(@Body() metadataBody: ValidateMetadataDTO) { return this.drepService.validateMetadata(metadataBody); } @Post('metadata/save') - saveMetadata( - @Body('metadata') metadata: any, - @Body('hash') hash: string, - @Body('drepId') drepId: number, - @Body('name') name: string, - ) { - return this.drepService.saveMetadata(metadata, hash, drepId, name); + saveMetadata(@Body('metadata') metadata: any) { + return this.drepService.saveMetadata(metadata); } @Get(':voterId/stats') getStats(@Param('voterId') voterId: string) { @@ -148,4 +143,23 @@ export class DrepController { isRegistered(@Param('voterId') voterId: string) { return this.drepService.isDrepRegistered(voterId); } + + @Get(':voterId/delegators') + getDrepDelegators( + @Param('voterId') voterId: string, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) + page: number, + @Query('perPage', new DefaultValuePipe(24), ParseIntPipe) + perPage: number, + @Query('sort') sort?: string, + @Query('order') order?: string, + ) { + return this.drepService.getDrepDelegatorsWithVotingPower( + voterId, + page, + perPage, + sort, + order, + ); + } } diff --git a/backend/src/drep/drep.module.ts b/backend/src/drep/drep.module.ts index ccca566..bc243ca 100644 --- a/backend/src/drep/drep.module.ts +++ b/backend/src/drep/drep.module.ts @@ -11,14 +11,14 @@ import { ReactionsService } from 'src/reactions/reactions.service'; import { VoterService } from 'src/voter/voter.service'; import { AuthService } from 'src/auth/auth.service'; import { HttpModule } from '@nestjs/axios'; -import { Metadata } from 'src/entities/metadata.entity'; +import { BlockfrostService } from 'src/blockfrost/blockfrost.service'; @Module({ imports: [ HttpModule.register({ maxRedirects: 5, }), - TypeOrmModule.forFeature([Drep, Attachment, Note, Metadata], 'default'), + TypeOrmModule.forFeature([Drep, Attachment, Note], 'default'), TypeOrmModule.forFeature([], 'dbsync'), ], controllers: [DrepController], @@ -29,6 +29,7 @@ import { Metadata } from 'src/entities/metadata.entity'; ReactionsService, VoterService, AuthService, + BlockfrostService, ], }) export class DrepModule {} diff --git a/backend/src/drep/drep.service.ts b/backend/src/drep/drep.service.ts index e0947ed..f058a8f 100644 --- a/backend/src/drep/drep.service.ts +++ b/backend/src/drep/drep.service.ts @@ -10,21 +10,36 @@ import { faker } from '@faker-js/faker'; import * as blake from 'blakejs'; import { HttpService } from '@nestjs/axios'; import { AttachmentService } from 'src/attachment/attachment.service'; -import { Observable } from 'rxjs'; +import { + catchError, + firstValueFrom, + Observable, + from, + of, + forkJoin, + lastValueFrom, + timeout +} from 'rxjs'; import { AxiosResponse } from 'axios'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; -import { ConfigService } from '@nestjs/config'; -import axios from 'axios'; import { ReactionsService } from 'src/reactions/reactions.service'; import { CommentsService } from 'src/comments/comments.service'; import { Delegation, + DRepDelegatorsHistoryResponse, + DRepRegistrationData, + DRepTimelineParams, + EpochActivityResponse, IPFSResponse, LoggerMessage, MetadataStandard, MetadataValidationStatus, + TimelineEntry, + TimelineFilters, ValidateMetadataResult, + VoterNoteResponse, + VotingActivityHistory, } from 'src/common/types'; import { AuthService } from 'src/auth/auth.service'; import { getAllDRepsQuery, getTotalResultsQuery } from 'src/queries/getDReps'; @@ -33,14 +48,18 @@ import { getDRepVotesCountQuery, getDRepVotingPowerQuery, } from 'src/queries/drepStats'; -import { catchError, firstValueFrom } from 'rxjs'; -import { Metadata } from 'src/entities/metadata.entity'; import { getEpochParams } from 'src/queries/getEpochParams'; import { getDRepDelegatorsHistory } from 'src/queries/drepDelegatorsHistory'; import { JsonLd } from 'jsonld/jsonld-spec'; import { Response } from 'express'; import { getDrepCexplorerDetailsQuery } from 'src/queries/drepCexplorerDetails'; -import { getDrepDelegatorsWithVotingPowerQuery } from 'src/queries/drepDelegatorsWithVotingPower'; +import { + getDrepDelegatorsCountQuery, + getDrepDelegatorsWithVotingPowerQuery, +} from 'src/queries/drepDelegatorsWithVotingPower'; +import { BlockfrostService } from 'src/blockfrost/blockfrost.service'; +import { drepRegistrationQuery } from 'src/queries/drepRegistration'; +import { getDRepMetadataQuery } from 'src/queries/drepMetadata'; @Injectable() export class DrepService { @@ -50,11 +69,11 @@ export class DrepService { @InjectDataSource('dbsync') private cexplorerService: DataSource, private attachmentService: AttachmentService, - private configService: ConfigService, private reactionsService: ReactionsService, private commentsService: CommentsService, private authService: AuthService, private readonly httpService: HttpService, + private blockfrostService: BlockfrostService, ) {} async getAllDReps( query?: string, @@ -64,6 +83,7 @@ export class DrepService { order?: string, onChainStatus?: 'active' | 'inactive', campaignStatus?: 'claimed' | 'unclaimed', + includeRetired?: true | false, type?: 'has_script', ) { let nameFilteredDRepViews: string[]; @@ -72,7 +92,7 @@ export class DrepService { // if (query) { // const nameFilteredDReps = query ? await this.getDRepsByName(query) : []; // nameFilteredDRepViews = nameFilteredDReps.map( - // (drep) => drep.signature_drepVoterId, + // (drep) => drep.signature_voterId, // ); // } @@ -89,7 +109,7 @@ export class DrepService { if (campaignStatus) { const voltaireDReps = (await this.getAllDRepsVoltaire()) ?? []; - dRepViews = voltaireDReps.map((drep) => drep.signature_drepVoterId); + dRepViews = voltaireDReps.map((drep) => drep.signature_voterId); } const drepList = await this.getAllDRepsCexplorer( @@ -101,6 +121,7 @@ export class DrepService { sortOrder, onChainStatus, campaignStatus, + includeRetired, dRepViews, type, ); @@ -113,7 +134,7 @@ export class DrepService { const mergedDRepsData = drepList.data.map((drep) => { const voltaireDrep = voltaireDReps.find( - (voltaireDrep) => voltaireDrep.signature_drepVoterId === drep.view, + (voltaireDrep) => voltaireDrep.signature_voterId === drep.view, ); //account for voting options if ( @@ -151,6 +172,7 @@ export class DrepService { sortOrder?: string, onChainStatus?: 'active' | 'inactive', campaignStatus?: 'claimed' | 'unclaimed', + includeRetired?: true | false, dRepViews?: string[], type?: 'has_script', ) { @@ -175,6 +197,9 @@ export class DrepService { chainStatusCondition = `AND (DRepActivity.epoch_no - coalesce(block.epoch_no, block_first_register.epoch_no)) > DRepActivity.drep_activity`; } + if (!includeRetired) { + chainStatusCondition += ` AND (dr_voting_anchor.deposit IS NULL OR dr_voting_anchor.deposit >= 0) `; + } let campaignStatusCondition = ''; if (dRepViews && dRepViews.length > 0) { @@ -225,6 +250,7 @@ export class DrepService { const totalResults = await this.cexplorerService.manager.query( getTotalResultsQuery( sanitizedSearchCondition, + nameFilteredDRepCondition, campaignStatusCondition, chainStatusCondition, typeCondition, @@ -240,7 +266,10 @@ export class DrepService { entry.voting_power != null ? (entry.voting_power / 1000000).toFixed(1) : null, - live_stake: (entry.live_stake / 1000000).toFixed(1), + live_stake: + entry.live_stake != null + ? (entry.live_stake / 1000000).toFixed(1) + : null, }; }), totalItems: parseInt(totalResults[0].total, 10), @@ -261,7 +290,7 @@ export class DrepService { .getRepository('Drep') .createQueryBuilder('drep') .leftJoinAndSelect('drep.signatures', 'signature') - .where('signature.drepVoterId IN (:...views)', { views }) + .where('signature.voterId IN (:...views)', { views }) .getRawMany(); } @@ -281,15 +310,12 @@ export class DrepService { .where('drep.id = :drepId', { drepId }) .getRawMany(); let drepVoterId; - if (drep.length > 0) drepVoterId = drep[0].signature_drepVoterId; + if (drep.length > 0) drepVoterId = drep[0].signature_voterId; const drepCexplorer = await this.getDrepCexplorerDetails(drepVoterId); - const drepDelegators = - await this.getDrepDelegatorsWithVotingPower(drepVoterId); const combinedResult = { ...drep[0], - cexplorerDetails: drepCexplorer, - delegators: drepDelegators, + ...drepCexplorer, }; if ( (!drep || drep.length === 0) && @@ -299,8 +325,8 @@ export class DrepService { } //account for voting options if ( - combinedResult.cexplorerDetails.view.includes('drep_always_abstain') || - combinedResult.cexplorerDetails.view.includes('drep_always_no_confidence') + combinedResult?.view.includes('drep_always_abstain') || + combinedResult?.view.includes('drep_always_no_confidence') ) { combinedResult['type'] = 'voting_option'; } else { @@ -314,15 +340,12 @@ export class DrepService { .getRepository('Drep') .createQueryBuilder('drep') .leftJoinAndSelect('signature', 'signature', 'signature.drepId = drep.id') - .where('signature.drepVoterId = :drepVoterId', { drepVoterId }) + .where('signature.voterId = :drepVoterId', { drepVoterId }) .getRawMany(); const drepCexplorer = await this.getDrepCexplorerDetails(drepVoterId); - const drepDelegators = - await this.getDrepDelegatorsWithVotingPower(drepVoterId); const combinedResult = { ...drep[0], - cexplorerDetails: drepCexplorer, - delegators: drepDelegators, + ...drepCexplorer, }; if ( (!drep || drep.length === 0) && @@ -332,11 +355,11 @@ export class DrepService { } //account for voting options if ( - combinedResult.cexplorerDetails.view.includes('drep_always_abstain') || - combinedResult.cexplorerDetails.view.includes('drep_always_no_confidence') + combinedResult?.view.includes('drep_always_abstain') || + combinedResult?.view.includes('drep_always_no_confidence') ) { combinedResult['type'] = 'voting_option'; - } else if (!!combinedResult.cexplorerDetails.has_script) { + } else if (!!combinedResult.has_script) { combinedResult['type'] = 'scripted'; } else { combinedResult['type'] = 'drep'; @@ -354,7 +377,9 @@ export class DrepService { return drepCexplorer[0]; } - async getDrepDateofRegistration(drepVoterId: string) { + async getDrepDateofRegistration( + drepVoterId: string, + ): Promise { const drepRegistrationData = await this.cexplorerService.manager.query( `SELECT dh.id AS drep_hash_id, @@ -375,130 +400,133 @@ export class DrepService { ); return drepRegistrationData[0]; } - async getDrepTimeline( - drep: any, - drepVoterId: string, - stakeKeyBech32?: string, - delegation?: Delegation, - beforeDate?: number, - tillDate?: number, - filterValues?: string[] | undefined, - ) { - const includeVotingActivity = !filterValues || filterValues.includes('va'); - const includeDelegations = !filterValues || filterValues.includes('d'); - const includeNotes = !filterValues || filterValues.includes('n'); - const includeClaimedProfile = !filterValues || filterValues.includes('cp'); - const includeRegistration = !filterValues || filterValues.includes('r'); - const drepId = drep?.drep_id; + private getFilters(filterValues?: string[]): TimelineFilters { + return { + includeVotingActivity: !filterValues || filterValues.includes('va'), + includeDelegations: !filterValues || filterValues.includes('d'), + includeNotes: !filterValues || filterValues.includes('n'), + includeClaimedProfile: !filterValues || filterValues.includes('cp'), + includeRegistration: !filterValues || filterValues.includes('r'), + }; + } + + private getTimeRange(beforeDate?: number, tillDate?: number): { startingTime: Date; endingTime: Date } { const startingTime = beforeDate ? new Date(Number(beforeDate)) : new Date(); const endingTime = tillDate ? new Date(Number(tillDate)) - : new Date(new Date(startingTime).getTime() - 432000000); // 5 days ago - - const epochs = await this.getEpochs(startingTime, endingTime); + : new Date(startingTime.getTime() - 432000000); // 5 days ago + return { startingTime, endingTime }; + } - let drepRegData = null; - let regDate = null; - if (includeRegistration) { - drepRegData = await this.getDrepDateofRegistration(drepVoterId); - regDate = new Date(drepRegData?.date_of_registration).getTime(); - } + private createTimelineEntries( + data: T[], + type: string, + timestampField: keyof T + ): TimelineEntry[] { + return data.map(item => ({ + ...item, + type, + timestamp: item[timestampField], + })); + } - let claimDate = null; - if (includeClaimedProfile) { - claimDate = new Date(drep?.drep_createdAt).getTime(); - } + private isWithinTimeRange(timestamp: string | Date, startTime: Date, endTime: Date): boolean { + const time = new Date(timestamp).getTime(); + return startTime.getTime() > time && endTime.getTime() < time; + } - let drepVotingHistory = []; - if (includeVotingActivity) { - drepVotingHistory = await this.getDrepVotingActivity( - drepVoterId, - startingTime, - endingTime, - ); - } + async getDrepTimeline({ + drep, + drepVoterId, + stakeKeyBech32, + delegation, + beforeDate, + tillDate, + filterValues, + }: DRepTimelineParams): Promise { + const filters = this.getFilters(filterValues); + const { startingTime, endingTime } = this.getTimeRange(beforeDate, tillDate); + const drepId = drep?.drep_id; - let drepDelegatorsHistory = []; - if (includeDelegations) { - drepDelegatorsHistory = await this.getDrepDelegators( - drepVoterId, - startingTime, - endingTime, - ); - } + // Setting up observables for parallel data fetching + const queries: Record> = { + epochs: from(this.getEpochs(startingTime, endingTime)), + regData: filters.includeRegistration + ? from(this.getDrepDateofRegistration(drepVoterId)) + : of(null), + votingHistory: filters.includeVotingActivity + ? from(this.getDrepVotingActivity(drepVoterId, startingTime, endingTime)) + : of([]), + delegatorsHistory: filters.includeDelegations + ? from(this.getDrepDelegators(drepVoterId, startingTime, endingTime)) + : of([]), + notes: filters.includeNotes && drepId + ? from(this.getDRepNotes(drepId, startingTime, endingTime, stakeKeyBech32, delegation)) + : of([]), + }; - let drepNotes = []; - if (includeNotes && drepId) { - drepNotes = await this.getDRepNotes( - drepId, - startingTime, - endingTime, - stakeKeyBech32, - delegation, + try { + const results = await lastValueFrom( + forkJoin(queries).pipe( + timeout(100000), // 100 second timeout for mainnet data(may be heavy) + catchError(error => { + console.error('Error fetching DRep timeline data:', error); + throw new Error('Failed to fetch DRep timeline data'); + }) + ) ); - } - const drepActivity = [ - ...epochs.map((epoch) => ({ - ...epoch, - type: 'epoch', - timestamp: epoch.start_time, - })), - ...drepVotingHistory.map((vote) => ({ - ...vote, - type: 'voting_activity', - timestamp: vote.time_voted, - })), - ...drepNotes.map((note) => ({ - ...note, - type: 'note', - timestamp: note.note_createdAt, - })), - ...drepDelegatorsHistory, - ]; + // Combining all timeline entries + let timelineEntries: TimelineEntry[] = [ + ...this.createTimelineEntries(results.epochs, 'epoch', 'start_time'), + ...this.createTimelineEntries(results.votingHistory, 'voting_activity', 'time_voted'), + ...this.createTimelineEntries(results.notes, 'note', 'note_updatedAt'), + ...results.delegatorsHistory, + ]; + if ( + filters.includeClaimedProfile && + drepId && + drep?.drep_createdAt && + this.isWithinTimeRange(drep.drep_createdAt, startingTime, endingTime) + ) { + timelineEntries.push({ + type: 'claimed_profile', + timestamp: drep.drep_createdAt, + claimingId: drepId, + claimedDRepId: drepVoterId, + }); + } + const regDate = results.regData?.date_of_registration; + if ( + filters.includeRegistration && + regDate && + this.isWithinTimeRange(regDate, startingTime, endingTime) + ) { + timelineEntries.push({ + type: 'registration', + timestamp: regDate, + tx_hash: results.regData.reg_tx_hash, + epoch_no: results.regData.epoch_of_registration, + }); + } - // Add claimed event if drepId is present and falls within the time range - if ( - includeClaimedProfile && - drepId && - claimDate && - startingTime.getTime() > claimDate && - endingTime.getTime() < claimDate - ) { - drepActivity.push({ - type: 'claimed_profile', - timestamp: drep.drep_createdAt, - claimingId: drepId, - claimedDRepId: drepVoterId, + // Sort timeline entries by timestamp (latest first) + timelineEntries.sort((a, b) => { + return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(); }); - } - // Add the registration event if it falls within the time range - if ( - includeRegistration && - regDate && - startingTime.getTime() > regDate && - endingTime.getTime() < regDate - ) { - drepActivity.push({ - type: 'registration', - timestamp: drepRegData.date_of_registration, - tx_hash: drepRegData.reg_tx_hash, - epoch_no: drepRegData.epoch_of_registration, - }); + return timelineEntries; + } catch (error) { + console.error('Error processing DRep timeline:', error); + throw error; } - - // Sort the combined array by timestamp from latest to earliest - drepActivity.sort( - (a, b) => - new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), - ); - - return drepActivity; } - async getEpochs(beforeDate: Date, tillDate: Date) { + async getEpochs( + beforeDate: Date, + tillDate: Date, + ): Promise { const epochs = (await this.cexplorerService.manager.query( `SELECT start_time, end_time, no @@ -519,20 +547,22 @@ export class DrepService { drepVoterId: string, beforeDate: Date, tillDate: Date, - ) { - const viewParam = drepVoterId; - + ): Promise { // Convert the start and end times from seconds to timestamps const drepVotingHistory = (await this.cexplorerService.manager.query( `SELECT dh.view, SUBSTRING(CAST(prop_creation_tx.hash AS TEXT) FROM 3) AS gov_action_proposal_id, prop_creation_bk.time AS prop_inception, + gp.type, gp.description, - vp.vote, + gp.voting_anchor_id, + vp.vote::text, + ocvd.json AS metadata, bk.time AS time_voted, prop_creation_bk.epoch_no AS proposal_epoch, - bk.epoch_no AS voting_epoch + bk.epoch_no AS voting_epoch, + va.url FROM drep_hash AS dh JOIN @@ -547,12 +577,16 @@ export class DrepService { block AS bk ON tx.block_id = bk.id LEFT JOIN block AS prop_creation_bk ON prop_creation_tx.block_id = prop_creation_bk.id + LEFT JOIN + voting_anchor as va ON gp.voting_anchor_id = va.id + LEFT JOIN + off_chain_vote_data AS ocvd ON ocvd.voting_anchor_id = va.id WHERE dh.view = $1 AND bk.time::DATE BETWEEN $3::DATE AND $2::DATE ORDER BY bk.epoch_no`, - [viewParam, beforeDate, tillDate], + [drepVoterId, beforeDate, tillDate], )) as any[]; return drepVotingHistory.map((item) => { @@ -562,19 +596,20 @@ export class DrepService { }; }); } + async getDRepNotes( drepId: number, beforeDate: Date, tillDate: Date, stakeKeyBech32?: string, delegation?: any, - ) { + ): Promise { const queryBuilder = await this.voltaireService .getRepository('Note') .createQueryBuilder('note') - .leftJoinAndSelect('note.voter', 'drep') + .leftJoinAndSelect('note.drep', 'drep') .leftJoin('drep.signatures', 'signature') - .where('note.voterId = :drepId', { drepId }) + .where('note.drep = :drepId', { drepId }) .andWhere( 'note."createdAt"::DATE BETWEEN :tillDate::DATE AND :beforeDate::DATE', { @@ -584,7 +619,7 @@ export class DrepService { ); // Prepare visibility conditions - const visibilityConditions = ['note.note_visibility = :everyone']; + const visibilityConditions = ['note.visibility = :everyone']; const visibilityParams: { everyone: string; @@ -599,7 +634,7 @@ export class DrepService { // 'delegators' visibility if (delegation) { visibilityConditions.push( - 'note.note_visibility = :delegators AND signature.drepVoterId = :drepVoterId', + 'note.visibility = :delegators AND signature.voterId = :drepVoterId', ); visibilityParams.delegators = 'delegators'; visibilityParams.drepVoterId = delegation.drep_view; @@ -608,7 +643,7 @@ export class DrepService { // 'myself' visibility if (stakeKeyBech32) { visibilityConditions.push( - 'note.note_visibility = :myself AND signature.drepStakeKey = :stakeKeyBech32', + 'note.visibility = :myself AND signature.stakeKey = :stakeKeyBech32', ); visibilityParams.myself = 'myself'; visibilityParams.stakeKeyBech32 = stakeKeyBech32; @@ -664,37 +699,23 @@ export class DrepService { .getRepository('Drep') .insert(drepDto); const signatureDto = { - drep: insertedDrep.identifiers[0].id, - drepVoterId: drepDto?.voter_id, - drepStakeKey: drepDto?.stake_addr, - drepSignatureKey: drepDto?.key, - drepSignature: drepDto?.signature, + drepId: insertedDrep.identifiers[0].id, + voterId: drepDto?.voter_id, + stakeKey: drepDto?.stake_addr, + key: drepDto?.key, + signature: drepDto?.signature, }; - const insertedSig = await this.voltaireService - .getRepository('Signature') - .insert(signatureDto); - const { token } = await this.authService.login( - { signature: drepDto?.signature, key: drepDto.key }, + const { token, insertedSig } = await this.authService.login( + signatureDto, 10000, ); return { insertedDrep, insertedSig, token }; } async getEpochParams() { try { - const APIURL = `${this.configService.get( - 'BLOCKFROST_NETWORK_URL', - )}/api/v0/epochs/latest/parameters`; - const response = await axios.get(APIURL, { - headers: { - project_id: this.configService.get( - 'BLOCKFROST_NETWORK_PROJECT_ID', - ), - }, - }); - return response.data; + return await this.blockfrostService.getEpochParameters(); } catch (error) { console.error('Blockfrost API call failed:', error); - try { // Fallback to cexplorerService const fallbackResponse = @@ -715,17 +736,56 @@ export class DrepService { } } - async getDrepDelegatorsWithVotingPower(drepVoterId: string) { + async getDrepDelegatorsWithVotingPower( + drepVoterId: string, + currentPage: number, + itemsPerPage: number, + sort?: string, + order?: string, + ) { + const offset = (currentPage - 1) * itemsPerPage; + + const sortColumns = { + power: 'voting_power', + epoch: 'epoch_no', + }; + + const sortColumn = sortColumns[sort] || null; + const sortOrder = order?.toUpperCase(); + + const orderByClause = + sortColumn && ['ASC', 'DESC'].includes(sortOrder) + ? `ORDER BY ${sortColumn} ${sortOrder} NULLS ${sortOrder === 'DESC' ? 'LAST' : 'FIRST'}` + : ''; + const delegatorsWithVotingPower = await this.cexplorerService.manager.query( - getDrepDelegatorsWithVotingPowerQuery, + getDrepDelegatorsWithVotingPowerQuery( + itemsPerPage, + offset, + orderByClause, + ), [drepVoterId], ); - return delegatorsWithVotingPower.map((delegator) => ({ - stakeAddress: delegator?.stake_address, - delegationEpoch: delegator?.delegation_epoch, - votingPower: delegator?.voting_power, - })); + const totalResults = await this.cexplorerService.manager.query( + getDrepDelegatorsCountQuery(), + [drepVoterId], + ); + + const totalItems = parseInt(totalResults[0].total, 10); + const totalPages = Math.ceil(totalItems / itemsPerPage); + + return { + data: delegatorsWithVotingPower.map((delegator) => ({ + stakeAddress: delegator?.stake_address, + delegationEpoch: delegator?.delegation_epoch, + votingPower: delegator?.voting_power, + })), + totalItems, + currentPage, + itemsPerPage, + totalPages, + }; } async updateDrepInfo(drepId: number, drep: createDrepDto) { @@ -743,7 +803,7 @@ export class DrepService { .getRepository('Signature') .update( { drep: foundDrep[0].drep_id }, - { drepSignatureKey: drep.key, drepSignature: drep.signature }, + { signatureKey: drep.key, signature: drep.signature }, ); delete drep.signature; delete drep.key; @@ -764,18 +824,7 @@ export class DrepService { .getRepository('Drep') .update(drepId, updatedDrep); } - async getMetadata(drepId: number, hash: string, res: Response) { - if (!drepId || !hash) throw new Error('Inadequate parameters'); - const foundMetadata = await this.voltaireService - .getRepository('Metadata') - .createQueryBuilder('metadata') - .where('metadata.drep = :drepId', { drepId }) - .andWhere('metadata.hash = :hash', { hash }) - .getOne(); - if (!foundMetadata) throw new NotFoundException('Metadata not found'); - const cid = foundMetadata.content; - return await this.getMetadataFromIPFS(cid, res); - } + async getMetadataFromExternalLink(metadataUrl: string) { if (!metadataUrl) throw new Error('Inadequate parameters'); const { data } = await firstValueFrom( @@ -830,41 +879,11 @@ export class DrepService { return { status, valid: !Boolean(status), metadata } as any; } - async saveMetadata( - metadata: any, - hash: string, - drepId: number, - fileName: string, - ) { - const metadataRepo = await this.voltaireService.getRepository('Metadata'); - - // Check if a record with the same drepId already exists - const existingMetadata = await metadataRepo - .createQueryBuilder('metadata') - .where('metadata.drep = :drepId', { drepId }) - .andWhere('metadata.hash = :hash', { hash: hash }) - .getOne(); - if (existingMetadata) { - //check pinned status - const content = existingMetadata?.content; - if (content) { - const { state } = await this.attachmentService.checkPinStatus(content); - return { ...existingMetadata, state }; - } - return existingMetadata; - } + async saveMetadata(metadata: any) { // Create a new metadata record in IPFS const { ipfs_hash, state } = await this.saveMetadataToIPFS(metadata); - const newMetadata = { - name: fileName + '.jsonld', - hash: hash, - content: ipfs_hash, - drep: drepId, - }; - const createdMetadata = metadataRepo.create(newMetadata); - const res = (await metadataRepo.save(createdMetadata)) as Metadata; - return { ...res, state }; + return { content: ipfs_hash, state }; } async saveMetadataToIPFS(metadata: JsonLd): Promise { try { @@ -930,7 +949,7 @@ export class DrepService { drepVoterId: string, beforeDate: Date, tillDate: Date, - ) { + ): Promise { const drepHashQuery = ` SELECT id, view FROM drep_hash WHERE view = $1 `; @@ -960,8 +979,28 @@ export class DrepService { } async isDrepRegistered(voterId: string) { - const registration = await this.getDrepDateofRegistration(voterId); + const latestRegistration = await this.cexplorerService.manager.query( + drepRegistrationQuery, + [voterId], + ); + + const regDeposit = latestRegistration[0]?.deposit; + + return regDeposit === null || regDeposit > 0; + } + + async getMetadata(voterId: string) { + const metadataRes = await this.cexplorerService.manager.query( + getDRepMetadataQuery, + [voterId], + ); + + if (!metadataRes || !metadataRes?.[0].metadata) { + throw new NotFoundException( + `Metadata not found for voter ID: ${voterId}`, + ); + } - return !!registration ? true : false; + return metadataRes?.[0]; } } diff --git a/backend/src/dto/createNoteDto.ts b/backend/src/dto/createNoteDto.ts index 08b773a..b9264de 100644 --- a/backend/src/dto/createNoteDto.ts +++ b/backend/src/dto/createNoteDto.ts @@ -3,17 +3,17 @@ import {Type} from 'class-transformer' export class createNoteDto { @IsNotEmpty() - note_title: string; + title: string; @IsOptional() note_tag: string[]; @IsNotEmpty() - note_content: string; + content: string; @IsNotEmpty() stake_addr: string; @IsNotEmpty() - voter: string; + drep: string; @IsNotEmpty() - note_visibility: string; + visibility: string; @IsOptional() attachments: string[]; } diff --git a/backend/src/entities/attachment.entity.ts b/backend/src/entities/attachment.entity.ts index 1bd5c9d..0d51b97 100644 --- a/backend/src/entities/attachment.entity.ts +++ b/backend/src/entities/attachment.entity.ts @@ -1,12 +1,8 @@ -import { - Column, - Entity, - ManyToOne, -} from 'typeorm'; +import { Column, Entity, ManyToOne } from 'typeorm'; import { Note } from './note.entity'; import { Drep } from './drep.entity'; import { Comment } from './comment.entity'; -import { BaseEntity } from 'src/global'; +import { BaseEntity } from '../global'; export enum AttachmentTypeName { Link = 'link', PDF = 'pdf', @@ -24,9 +20,9 @@ export enum AttachmentParentEntityType { } @Entity() export class Attachment extends BaseEntity { - @Column({nullable: false, unique: true}) + @Column({ nullable: false, unique: true }) name: string; - + @Column({ type: 'bytea' }) url: Uint8Array; diff --git a/backend/src/entities/comment.entity.ts b/backend/src/entities/comment.entity.ts index f47afbf..e177ec2 100644 --- a/backend/src/entities/comment.entity.ts +++ b/backend/src/entities/comment.entity.ts @@ -1,12 +1,7 @@ -import { - Entity, - Column, - ManyToOne, - OneToMany, -} from 'typeorm'; +import { Entity, Column, ManyToOne, OneToMany } from 'typeorm'; import { Note } from './note.entity'; import { Reaction } from './reaction.entity'; -import { BaseEntity } from 'src/global'; +import { BaseEntity } from '../global'; export enum CommentParentEntityType { Note = 'note', Comment = 'comment', @@ -32,10 +27,11 @@ export class Comment extends BaseEntity { @ManyToOne(() => Comment, (comment) => comment.id) // Many-to-One relationship with Comment comment: Comment; - @Column({ nullable: false }) + @Column({ nullable: false }) voter: string; - - @OneToMany(() => Reaction, (reaction) => reaction.comment, { onDelete: 'CASCADE' }) + @OneToMany(() => Reaction, (reaction) => reaction.comment, { + onDelete: 'CASCADE', + }) reactions: Reaction[]; } diff --git a/backend/src/entities/drep.entity.ts b/backend/src/entities/drep.entity.ts index c1d21ef..9b0e4f4 100644 --- a/backend/src/entities/drep.entity.ts +++ b/backend/src/entities/drep.entity.ts @@ -1,17 +1,9 @@ -import { Column, Entity, OneToMany } from 'typeorm'; +import { Entity, OneToMany } from 'typeorm'; import { Signature } from './signatures.entity'; -import { BaseEntity } from 'src/global'; +import { BaseEntity } from '../global'; @Entity() export class Drep extends BaseEntity { - @Column({ type: 'json', nullable: true }) - social: Record; - @Column({ nullable: true }) - platform_statement: string; - @Column({ nullable: true }) - expertise: string; - @Column({ nullable: true }) - perspective: string; @OneToMany(() => Signature, (signature) => signature.drep) signatures: Signature[]; } diff --git a/backend/src/entities/metadata.entity.ts b/backend/src/entities/metadata.entity.ts deleted file mode 100644 index 53933b8..0000000 --- a/backend/src/entities/metadata.entity.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Column, Entity, ManyToOne } from 'typeorm'; -import { BaseEntity } from 'src/global'; -import { Drep } from './drep.entity'; - -@Entity() -export class Metadata extends BaseEntity { - @Column({ nullable: false, unique: true }) - name: string; - @Column({ nullable: false }) - hash: string; - - @Column({ nullable: false }) - content: string; - - @ManyToOne(() => Drep, (drep) => drep.id) - drep: Drep; -} diff --git a/backend/src/entities/note.entity.ts b/backend/src/entities/note.entity.ts index e0dc3a3..c135003 100644 --- a/backend/src/entities/note.entity.ts +++ b/backend/src/entities/note.entity.ts @@ -1,24 +1,28 @@ import { Entity, Column, ManyToOne, OneToMany } from 'typeorm'; import { Drep } from './drep.entity'; -import { BaseEntity } from 'src/global'; +import { BaseEntity } from '../global'; import { Reaction } from './reaction.entity'; +import { Signature } from './signatures.entity'; @Entity() export class Note extends BaseEntity { @Column({ unique: true, nullable: false }) - note_title: string; + title: string; @Column('simple-array', { nullable: true }) - note_tag: string[]; + tag: string[]; @Column({ nullable: false }) - note_content: string; + content: string; @ManyToOne(() => Drep, (drep) => drep.id) - voter: Drep; + drep: Drep; // This is the Drep/ drep page that the note belongs to/will be hosted by + + @ManyToOne(() => Signature, (signature) => signature.id) + author: Signature; // This is the Signature/ user that wrote the note @Column() - note_visibility: string; + visibility: string; @OneToMany(() => Reaction, (reaction) => reaction.note, { onDelete: 'CASCADE', diff --git a/backend/src/entities/reaction.entity.ts b/backend/src/entities/reaction.entity.ts index 81abcdd..dfa4afe 100644 --- a/backend/src/entities/reaction.entity.ts +++ b/backend/src/entities/reaction.entity.ts @@ -1,6 +1,5 @@ import { Entity, - PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, @@ -10,7 +9,7 @@ import { } from 'typeorm'; import { Note } from './note.entity'; import { Comment } from './comment.entity'; -import { BaseEntity } from 'src/global'; +import { BaseEntity } from '../global'; enum ReactionTypeName { Like = 'like', @@ -26,8 +25,6 @@ export enum ReactionParentEntityType { @Entity() @Unique(['voter', 'type', 'parentId', 'parentEntity']) // Ensures delegator can't react twice to the same parent entity export class Reaction extends BaseEntity { - - @Column({ type: 'enum', enum: ReactionTypeName, @@ -46,13 +43,13 @@ export class Reaction extends BaseEntity { @Column({ type: 'int', nullable: false }) parentId: number; - @ManyToOne(() => Comment, (comment) => comment.id ) // Many-to-One relationship with Comment + @ManyToOne(() => Comment, (comment) => comment.id) // Many-to-One relationship with Comment comment: Comment; @ManyToMany(() => Note, (note) => note.id) note: Note; - @Column({ nullable: false, }) + @Column({ nullable: false }) voter: string; //timestamps @CreateDateColumn() diff --git a/backend/src/entities/signatures.entity.ts b/backend/src/entities/signatures.entity.ts index 54dd90d..9453470 100644 --- a/backend/src/entities/signatures.entity.ts +++ b/backend/src/entities/signatures.entity.ts @@ -3,16 +3,25 @@ import { Drep } from './drep.entity'; @Entity() export class Signature { + //can belong to a Drep or voter @PrimaryGeneratedColumn() id: number; - @ManyToOne(() => Drep, (drep) => drep.id, { onDelete: 'CASCADE' }) - drep: number; - @Column() - drepVoterId: string; - @Column() - drepStakeKey: string; + + @ManyToOne(() => Drep, (drep) => drep.id, { + nullable: true, + onDelete: 'CASCADE', + }) + drep: Drep; + + @Column({ nullable: true }) + voterId: string; + + @Column({ nullable: true }) + stakeKey: string; + @Column({ nullable: true, unique: false, default: null }) - drepSignature: string; + signature: string; + @Column({ nullable: true, unique: false, default: null }) - drepSignatureKey: string; + signatureKey: string; } diff --git a/backend/src/global/base.entity.ts b/backend/src/global/base.entity.ts index 134cd68..ed350cb 100644 --- a/backend/src/global/base.entity.ts +++ b/backend/src/global/base.entity.ts @@ -1,19 +1,24 @@ -import { IsBoolean, IsDateString, IsOptional } from "class-validator"; -import { Column, CreateDateColumn, DeleteDateColumn, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm"; +import { IsDateString, IsOptional } from 'class-validator'; +import { + CreateDateColumn, + DeleteDateColumn, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; /** * Abstract base class for dynamically assigning properties. */ export abstract class Model { - constructor(input?: any) { - if (input) { - // Iterate over the key-value pairs in the input object - for (const [key, value] of Object.entries(input)) { - // Assign the value to the corresponding property in this instance - (this as any)[key] = value; - } - } - } + constructor(input?: any) { + if (input) { + // Iterate over the key-value pairs in the input object + for (const [key, value] of Object.entries(input)) { + // Assign the value to the corresponding property in this instance + (this as any)[key] = value; + } + } + } } /** @@ -21,29 +26,26 @@ export abstract class Model { * All entities that extend this class will have soft-delete capability. */ export abstract class SoftDeletableBaseEntity extends Model { - @IsOptional() - @IsDateString() - // Soft delete column that records the date/time when the entity was soft-deleted - @DeleteDateColumn() // Indicates that this column is used for soft-delete - deletedAt?: Date; + @IsOptional() + @IsDateString() + // Soft delete column that records the date/time when the entity was soft-deleted + @DeleteDateColumn() // Indicates that this column is used for soft-delete + deletedAt?: Date; } - - - /** * Abstract base entity with common fields for primary key, creation, update timestamps, soft-delete, and more. */ -export abstract class BaseEntity extends SoftDeletableBaseEntity { - // Primary key of UUID type - @PrimaryGeneratedColumn() - id?: number; - - @CreateDateColumn({ - update:false - }) // TypeORM decorator for creation date - createdAt?: Date; - - @UpdateDateColumn() // TypeORM decorator for update date - updatedAt?: Date; -} \ No newline at end of file +export abstract class BaseEntity extends SoftDeletableBaseEntity { + // Primary key of UUID type + @PrimaryGeneratedColumn() + id?: number; + + @CreateDateColumn({ + update: false, + }) // TypeORM decorator for creation date + createdAt?: Date; + + @UpdateDateColumn() // TypeORM decorator for update date + updatedAt?: Date; +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 74b0429..cd4d115 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -3,7 +3,6 @@ import { AppModule } from './app.module'; import * as bodyParser from 'body-parser'; async function bootstrap() { const app = await NestFactory.create(AppModule); - // app.setGlobalPrefix('api'); // Set global prefix to '/api' app.use(bodyParser.json({ limit: '50mb' })); app.use( bodyParser.urlencoded({ diff --git a/backend/src/migrations/1726539689889-migrations.ts b/backend/src/migrations/1726539689889-migrations.ts new file mode 100644 index 0000000..43af62e --- /dev/null +++ b/backend/src/migrations/1726539689889-migrations.ts @@ -0,0 +1,113 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class Migrations1726539689889 implements MigrationInterface { + name = 'Migrations1726539689889'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "drep" ("deletedAt" TIMESTAMP, "id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_93389e87db474ce96323b8882c8" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TABLE "signature" ("id" SERIAL NOT NULL, "voterId" character varying, "stakeKey" character varying, "signature" character varying, "signatureKey" character varying, "drepId" integer, CONSTRAINT "PK_8e62734171afc1d7c9570be27fb" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TABLE "note" ("deletedAt" TIMESTAMP, "id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "title" character varying NOT NULL, "note_tag" text, "content" character varying NOT NULL, "visibility" character varying NOT NULL, "drepId" integer, "authorId" integer, CONSTRAINT "UQ_c1872643429ea977256802b0974" UNIQUE ("title"), CONSTRAINT "PK_96d0c172a4fba276b1bbed43058" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TYPE "public"."comment_parententity_enum" AS ENUM('note', 'comment')`, + ); + await queryRunner.query( + `CREATE TABLE "comment" ("deletedAt" TIMESTAMP, "id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "content" character varying NOT NULL, "parentEntity" "public"."comment_parententity_enum" NOT NULL DEFAULT 'note', "parentId" integer NOT NULL, "voter" character varying NOT NULL, "noteId" integer, "commentId" integer, CONSTRAINT "PK_0b0e4bbc8415ec426f87f3a88e2" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TYPE "public"."reaction_type_enum" AS ENUM('like', 'thumbsup', 'thumbsdown', 'rocket')`, + ); + await queryRunner.query( + `CREATE TYPE "public"."reaction_parententity_enum" AS ENUM('note', 'comment')`, + ); + await queryRunner.query( + `CREATE TABLE "reaction" ("deletedAt" TIMESTAMP, "id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "type" "public"."reaction_type_enum" NOT NULL DEFAULT 'like', "parentEntity" "public"."reaction_parententity_enum" NOT NULL DEFAULT 'note', "parentId" integer NOT NULL, "voter" character varying NOT NULL, "commentId" integer, CONSTRAINT "UQ_994f15da3179481b7c8fc8b516b" UNIQUE ("voter", "type", "parentId", "parentEntity"), CONSTRAINT "PK_41fbb346da22da4df129f14b11e" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TYPE "public"."attachment_parententity_enum" AS ENUM('drep', 'note', 'comment')`, + ); + await queryRunner.query( + `CREATE TYPE "public"."attachment_attachmenttype_enum" AS ENUM('link', 'pdf', 'jpg', 'png', 'webp', 'gif', 'svg')`, + ); + await queryRunner.query( + `CREATE TABLE "attachment" ("deletedAt" TIMESTAMP, "id" SERIAL NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "name" character varying NOT NULL, "url" bytea NOT NULL, "parententity" "public"."attachment_parententity_enum" NOT NULL DEFAULT 'drep', "parentid" integer, "attachmentType" "public"."attachment_attachmenttype_enum" NOT NULL DEFAULT 'link', "noteId" integer, "drepId" integer, "commentId" integer, CONSTRAINT "UQ_10fe7469954f43bfc90e110d147" UNIQUE ("name"), CONSTRAINT "PK_d2a80c3a8d467f08a750ac4b420" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "signature" ADD CONSTRAINT "FK_0e364e8cdc69745eff2239269e4" FOREIGN KEY ("drepId") REFERENCES "drep"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "note" ADD CONSTRAINT "FK_270ca39118de4b28864f3de4d04" FOREIGN KEY ("drepId") REFERENCES "drep"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "note" ADD CONSTRAINT "FK_59d5801d406020527940335d902" FOREIGN KEY ("authorId") REFERENCES "signature"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "comment" ADD CONSTRAINT "FK_3c3e1e8106c7edf8da2b312cd25" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "comment" ADD CONSTRAINT "FK_1b03586f7af11eac99f4fdbf012" FOREIGN KEY ("commentId") REFERENCES "comment"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "reaction" ADD CONSTRAINT "FK_4584f851fc6471f517d9dad8966" FOREIGN KEY ("commentId") REFERENCES "comment"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "attachment" ADD CONSTRAINT "FK_76c5cd056cd033bd8a9b6117bf4" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "attachment" ADD CONSTRAINT "FK_71304a733ad09e45fad47a425d8" FOREIGN KEY ("drepId") REFERENCES "drep"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "attachment" ADD CONSTRAINT "FK_48de432a2a403db1853ab431dc2" FOREIGN KEY ("commentId") REFERENCES "comment"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "attachment" DROP CONSTRAINT "FK_48de432a2a403db1853ab431dc2"`, + ); + await queryRunner.query( + `ALTER TABLE "attachment" DROP CONSTRAINT "FK_71304a733ad09e45fad47a425d8"`, + ); + await queryRunner.query( + `ALTER TABLE "attachment" DROP CONSTRAINT "FK_76c5cd056cd033bd8a9b6117bf4"`, + ); + await queryRunner.query( + `ALTER TABLE "reaction" DROP CONSTRAINT "FK_4584f851fc6471f517d9dad8966"`, + ); + await queryRunner.query( + `ALTER TABLE "comment" DROP CONSTRAINT "FK_1b03586f7af11eac99f4fdbf012"`, + ); + await queryRunner.query( + `ALTER TABLE "comment" DROP CONSTRAINT "FK_3c3e1e8106c7edf8da2b312cd25"`, + ); + await queryRunner.query( + `ALTER TABLE "note" DROP CONSTRAINT "FK_59d5801d406020527940335d902"`, + ); + await queryRunner.query( + `ALTER TABLE "note" DROP CONSTRAINT "FK_270ca39118de4b28864f3de4d04"`, + ); + await queryRunner.query( + `ALTER TABLE "signature" DROP CONSTRAINT "FK_0e364e8cdc69745eff2239269e4"`, + ); + await queryRunner.query(`DROP TABLE "attachment"`); + await queryRunner.query( + `DROP TYPE "public"."attachment_attachmenttype_enum"`, + ); + await queryRunner.query( + `DROP TYPE "public"."attachment_parententity_enum"`, + ); + await queryRunner.query(`DROP TABLE "reaction"`); + await queryRunner.query(`DROP TYPE "public"."reaction_parententity_enum"`); + await queryRunner.query(`DROP TYPE "public"."reaction_type_enum"`); + await queryRunner.query(`DROP TABLE "comment"`); + await queryRunner.query(`DROP TYPE "public"."comment_parententity_enum"`); + await queryRunner.query(`DROP TABLE "note"`); + await queryRunner.query(`DROP TABLE "signature"`); + await queryRunner.query(`DROP TABLE "drep"`); + } +} diff --git a/backend/src/migrations/1726933864900-rename_notetag_to_note.ts b/backend/src/migrations/1726933864900-rename_notetag_to_note.ts new file mode 100644 index 0000000..da3baaf --- /dev/null +++ b/backend/src/migrations/1726933864900-rename_notetag_to_note.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class RenameNotetagToNote1726933864900 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "note" RENAME COLUMN "note_tag" TO "tag"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "note" RENAME COLUMN "tag" TO "note_tag"`, + ); + } + +} diff --git a/backend/src/miscellaneous/miscellaneous.controller.ts b/backend/src/miscellaneous/miscellaneous.controller.ts index b3ff4a4..d4a20a4 100644 --- a/backend/src/miscellaneous/miscellaneous.controller.ts +++ b/backend/src/miscellaneous/miscellaneous.controller.ts @@ -1,11 +1,21 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, Param } from '@nestjs/common'; import { MiscellaneousService } from './miscellaneous.service'; @Controller('misc') export class MiscellaneousController { constructor(private miscService: MiscellaneousService) {} + @Get('epochs/first') getFirstEpoch() { return this.miscService.getFirstEpoch(); } + + @Get('tx/:hash/exists') + getTx(@Param('hash') hash: string) { + return this.miscService.checkTxExists(hash); + } + @Get('node/status') + getNodeStatus() { + return this.miscService.getNodeStatus(); + } } diff --git a/backend/src/miscellaneous/miscellaneous.module.ts b/backend/src/miscellaneous/miscellaneous.module.ts index 5bf6d3e..8c7b555 100644 --- a/backend/src/miscellaneous/miscellaneous.module.ts +++ b/backend/src/miscellaneous/miscellaneous.module.ts @@ -1,10 +1,17 @@ import { Module } from '@nestjs/common'; import { MiscellaneousController } from './miscellaneous.controller'; import { MiscellaneousService } from './miscellaneous.service'; +import { BlockfrostService } from 'src/blockfrost/blockfrost.service'; +import { HttpModule } from '@nestjs/axios'; @Module({ - imports: [], + imports: [ + HttpModule.register({ + timeout: 5000, + maxRedirects: 5, + }), + ], controllers: [MiscellaneousController], - providers: [MiscellaneousService], + providers: [MiscellaneousService, BlockfrostService], }) export class MiscellaneousModule {} diff --git a/backend/src/miscellaneous/miscellaneous.service.ts b/backend/src/miscellaneous/miscellaneous.service.ts index 937a559..8b72745 100644 --- a/backend/src/miscellaneous/miscellaneous.service.ts +++ b/backend/src/miscellaneous/miscellaneous.service.ts @@ -1,12 +1,16 @@ -import { Injectable } from '@nestjs/common'; +import { HttpException, Injectable} from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; +import { BlockfrostService } from 'src/blockfrost/blockfrost.service'; +import { BlockfrostBlockRes, NodeBlockRes } from 'src/common/types'; +import { getLatestBlock } from 'src/queries/getLatestBlock'; import { DataSource } from 'typeorm'; @Injectable() -export class MiscellaneousService { +export class MiscellaneousService { constructor( @InjectDataSource('dbsync') private cexplorerService: DataSource, + private blockfrostService: BlockfrostService, ) {} async getFirstEpoch() { const epoch = await this.cexplorerService.manager.query( @@ -17,4 +21,32 @@ export class MiscellaneousService { ); return epoch[0]; } + + async checkTxExists(hash: string) { + const tx = await this.cexplorerService.manager.query( + `SELECT id, SUBSTRING(CAST(tx.hash AS TEXT) FROM 3) AS tx_hash + FROM "tx" + WHERE "hash" = decode($1, 'hex');`, + [hash], + ); + + return tx[0]?.tx_hash ? true : false; + } + async getNodeStatus() { + try { + const nodeLatestBlock: [NodeBlockRes] = + await this.cexplorerService.manager.query(getLatestBlock); + //compare with the latest block from a blockfrost API or any other API + const confirmationLatestBlock: BlockfrostBlockRes = + await this.blockfrostService.getLatestBlock(); + //compare the block number + return { + ...nodeLatestBlock[0], + behindBy: confirmationLatestBlock.height - nodeLatestBlock[0].block_no, + } + } catch (error) { + console.log(error); + throw new HttpException('Failed to get the node sync tip status', 500); + } + } } diff --git a/backend/src/note/note.module.ts b/backend/src/note/note.module.ts index 6f6e334..d6be39a 100644 --- a/backend/src/note/note.module.ts +++ b/backend/src/note/note.module.ts @@ -9,8 +9,8 @@ import { ReactionsService } from 'src/reactions/reactions.service'; import { CommentsService } from 'src/comments/comments.service'; import { VoterService } from 'src/voter/voter.service'; import { AuthService } from 'src/auth/auth.service'; -import { JwtService } from '@nestjs/jwt'; -import { HttpModule, HttpService } from '@nestjs/axios'; +import { HttpModule } from '@nestjs/axios'; +import { BlockfrostService } from 'src/blockfrost/blockfrost.service'; @Module({ imports: [ @@ -29,6 +29,7 @@ import { HttpModule, HttpService } from '@nestjs/axios'; CommentsService, VoterService, AuthService, + BlockfrostService, ], }) export class NoteModule {} diff --git a/backend/src/note/note.service.ts b/backend/src/note/note.service.ts index 3216625..febba4b 100644 --- a/backend/src/note/note.service.ts +++ b/backend/src/note/note.service.ts @@ -1,11 +1,11 @@ -import { Injectable, NotFoundException, OnModuleInit } from '@nestjs/common'; -import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; -import { AttachmentService } from 'src/attachment/attachment.service'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; import { CommentsService } from 'src/comments/comments.service'; -import { Delegation, StakeKeys } from 'src/common/types'; +import { Delegation } from 'src/common/types'; import { DrepService } from 'src/drep/drep.service'; import { createNoteDto } from 'src/dto'; import { ReactionsService } from 'src/reactions/reactions.service'; +import { VoterService } from 'src/voter/voter.service'; import { DataSource } from 'typeorm'; @Injectable() export class NoteService { @@ -13,9 +13,9 @@ export class NoteService { @InjectDataSource('default') private voltaireService: DataSource, private drepService: DrepService, - private attachmentService: AttachmentService, private reactionsService: ReactionsService, private commentsService: CommentsService, + private voterService: VoterService, ) {} async getAllNotes( stakeKeyBech32?: string, @@ -58,19 +58,32 @@ export class NoteService { if (!note) { throw new NotFoundException('Note not found!'); } - return note; + const reactions = await this.reactionsService.getReactions(note.id, 'note'); + const comments = await this.commentsService.getComments(note.id, 'note'); + return { ...note, reactions: reactions, comments: comments }; } async registerNote(noteDto: createNoteDto) { - const isPresent = await this.drepService.getSingleDrepViaVoterID( - noteDto.voter, - ); - if (isPresent) { - const modifiedNoteDto = { ...noteDto, voter: isPresent.drep_id }; + try { + const isDRepPresent = await this.drepService.getSingleDrepViaVoterID( + noteDto.drep, + ); + const author = await this.voltaireService + .getRepository('Signature') + .findOne({ where: { stakeKey: noteDto.stake_addr } }); + if (!author) { + return new NotFoundException('Author details not found!'); + } + const modifiedNoteDto = { + ...noteDto, + drep: isDRepPresent.drep_id, + author: author.id, + }; const res = await this.voltaireService .getRepository('Note') .insert(modifiedNoteDto); return { noteAdded: res.identifiers[0].id }; - } else { + } catch (error) { + console.log(error); return new NotFoundException('DRep associated with note not found!'); } } @@ -82,11 +95,9 @@ export class NoteService { if (!foundNote) { throw new NotFoundException('Note to be updated not found!'); } - const isPresent = await this.drepService.getSingleDrepViaVoterID( - note.voter, - ); + const isPresent = await this.drepService.getSingleDrepViaVoterID(note.drep); if (isPresent) { - const modifiedNote = { ...note, voter: isPresent.drep_id }; + const modifiedNote = { ...note, drep: isPresent.drep_id }; // Iterate through the properties of the note object Object.keys(modifiedNote).forEach((key) => { foundNote[key] = modifiedNote[key]; @@ -106,17 +117,19 @@ export class NoteService { const queryBuilder = this.voltaireService .getRepository('Note') .createQueryBuilder('note') - .leftJoinAndSelect('note.voter', 'drep') + .leftJoinAndSelect('note.drep', 'drep') .leftJoin('drep.signatures', 'signature') .orderBy('note.createdAt', 'DESC') // Order by createdAt descending .limit(20); // limit per req // Basic query for notes with visibility 'everyone' - queryBuilder.where('note.note_visibility = :everyone', { + queryBuilder.where('note.visibility = :everyone', { everyone: 'everyone', }); if (currentNote) { if (request === 'before') { - queryBuilder.where('note.id <= :currentNote', { currentNote: Number(currentNote) }); + queryBuilder.where('note.id <= :currentNote', { + currentNote: Number(currentNote), + }); } else if (request === 'after') { queryBuilder.where('note.id <= :currentNote', { currentNote: Number(currentNote) + 20, @@ -127,7 +140,7 @@ export class NoteService { // 'delegators' visibility if (delegation) { queryBuilder.orWhere( - 'note.note_visibility = :delegators AND signature.drepVoterId = :drepVoterId', + 'note.visibility = :delegators AND signature.voterId = :drepVoterId', { delegators: 'delegators', drepVoterId: delegation.drep_view, @@ -137,7 +150,7 @@ export class NoteService { // 'myself' visibility if (stakeKeyBech32) { queryBuilder.orWhere( - 'note.note_visibility = :myself AND signature.drepStakeKey = :stakeKeyBech32', + 'note.visibility = :myself AND signature.stakeKey = :stakeKeyBech32', { myself: 'myself', stakeKeyBech32: stakeKeyBech32, diff --git a/backend/src/queries/currentDelegation.ts b/backend/src/queries/currentDelegation.ts new file mode 100644 index 0000000..716060d --- /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 new file mode 100644 index 0000000..f442b4f --- /dev/null +++ b/backend/src/queries/drepAddrData.ts @@ -0,0 +1,38 @@ +export const getDrepAddrData: string = ` +WITH drep_addr_data AS ( + SELECT stake_addr + FROM ( + SELECT + dr.id AS reg_id, + sa.view AS stake_addr, + ROW_NUMBER() OVER (PARTITION BY dr.drep_hash_id ORDER BY reg_tx_bk.time DESC) AS reg_rn + FROM + drep_registration AS dr + JOIN + drep_hash AS dh ON dr.drep_hash_id = dh.id + 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 + LEFT JOIN + block AS reg_tx_bk ON reg_tx.block_id = reg_tx_bk.id + JOIN + stake_address AS sa ON tx_out.stake_address_id = sa.id + WHERE + tx_out.stake_address_id IS NOT NULL + AND dh.view = $1 + ) AS subquery + WHERE reg_rn = 1 +) +SELECT + dad.stake_addr AS stake_address, + COALESCE(SUM(txo.value), 0) AS total_stake +FROM + drep_addr_data dad +LEFT JOIN + stake_address sa ON dad.stake_addr = sa.view +LEFT JOIN + tx_out txo ON sa.id = txo.stake_address_id AND txo.consumed_by_tx_id IS NULL +GROUP BY + dad.stake_addr; + `; diff --git a/backend/src/queries/drepCexplorerDetails.ts b/backend/src/queries/drepCexplorerDetails.ts index 2498427..08cdb6f 100644 --- a/backend/src/queries/drepCexplorerDetails.ts +++ b/backend/src/queries/drepCexplorerDetails.ts @@ -15,28 +15,45 @@ export const getDrepCexplorerDetailsQuery: string = ` tx AS reg_tx ON dr.tx_id = reg_tx.id LEFT JOIN block AS reg_tx_bk ON reg_tx.block_id = reg_tx_bk.id - ) - , RankedRows AS ( - WITH latest_delegations AS ( + ), + LatestDelegation AS ( SELECT dv.addr_id, - MAX(b.time) as latest_time + dv.drep_hash_id, + ROW_NUMBER() OVER (PARTITION BY dv.addr_id ORDER BY b.time DESC) AS row_num FROM delegation_vote dv JOIN tx ON dv.tx_id = tx.id JOIN block b ON tx.block_id = b.id + ), + DRepDelegationData AS ( + SELECT + dh.id AS drep_hash_id, + COUNT(DISTINCT ld.addr_id) AS vote_count, + SUM(tx_out.value) AS live_stake + FROM + drep_hash dh + JOIN + LatestDelegation ld ON dh.id = ld.drep_hash_id AND ld.row_num = 1 + JOIN + stake_address sa ON ld.addr_id = sa.id + JOIN + tx_out ON sa.id = tx_out.stake_address_id + WHERE + tx_out.consumed_by_tx_id IS NULL GROUP BY - dv.addr_id - ) + dh.id + ), + RankedRows AS ( SELECT dh.id AS drep_hash_id, dh.raw, dh.view, dh.has_script, dd.id AS drep_distr_id, - dd.amount, + COALESCE(dd.amount, null) AS voting_power, dd.epoch_no, dd.active_until, lr.deposit, @@ -44,14 +61,9 @@ export const getDrepCexplorerDetailsQuery: string = ` lr.voting_anchor_id AS reg_voting_anchor_id, lr.metadata_url, sa.view AS stake_address, - ( - SELECT COUNT(DISTINCT dv_inner.addr_id) - FROM delegation_vote dv_inner - JOIN latest_delegations ld ON dv_inner.addr_id = ld.addr_id - JOIN tx ON dv_inner.tx_id = tx.id - JOIN block b ON tx.block_id = b.id AND b.time = ld.latest_time - WHERE dv_inner.drep_hash_id = dh.id - ) AS delegation_vote_count, + (lr.deposit iS NOT NULL AND lr.deposit < 0) AS retired, + COALESCE(dd_data.vote_count, 0) AS delegation_vote_count, + COALESCE(dd_data.live_stake, null) AS live_stake, ROW_NUMBER() OVER (PARTITION BY dh.id ORDER BY dd.epoch_no DESC) AS RowNum FROM drep_hash AS dh @@ -62,7 +74,9 @@ export const getDrepCexplorerDetailsQuery: string = ` LEFT JOIN delegation_vote AS dv ON dh.id = dv.drep_hash_id LEFT JOIN - stake_address AS sa ON dv.addr_id = sa.id + stake_address AS sa ON dv.addr_id = sa.id + LEFT JOIN + DRepDelegationData dd_data ON dd_data.drep_hash_id = dh.id WHERE dh.view = $1 ) @@ -71,8 +85,10 @@ export const getDrepCexplorerDetailsQuery: string = ` view, delegation_vote_count, stake_address, - amount, + voting_power, + live_stake, epoch_no, + retired, active_until, deposit, metadata_url, diff --git a/backend/src/queries/drepDelegatorsHistory.ts b/backend/src/queries/drepDelegatorsHistory.ts index 298530c..f0fbc59 100644 --- a/backend/src/queries/drepDelegatorsHistory.ts +++ b/backend/src/queries/drepDelegatorsHistory.ts @@ -24,18 +24,15 @@ export const getDRepDelegatorsHistory = (addrIds: []) => { SELECT COALESCE(SUM(txo.value), 0) FROM tx_out txo LEFT JOIN tx ON txo.tx_id = tx.id - LEFT JOIN block b2 ON tx.block_id = b2.id - LEFT JOIN tx_in txi ON (txo.tx_id = txi.tx_out_id AND txo.index = txi.tx_out_index) - WHERE txi IS NULL + WHERE txo.consumed_by_tx_id IS NULL AND txo.stake_address_id = sa.id - AND b2.time::DATE <= b.time::DATE + ) + COALESCE( ( SELECT SUM(amount) FROM reward WHERE addr_id = sa.id - AND earned_epoch <= b.epoch_no AND type <> 'refund' ), 0 ) @@ -44,7 +41,6 @@ export const getDRepDelegatorsHistory = (addrIds: []) => { SELECT SUM(amount) FROM reward_rest WHERE addr_id = sa.id - AND earned_epoch <= b.epoch_no ), 0 ) + COALESCE( @@ -52,7 +48,6 @@ export const getDRepDelegatorsHistory = (addrIds: []) => { SELECT SUM(amount) FROM reward WHERE addr_id = sa.id - AND earned_epoch <= b.epoch_no AND type = 'refund' ), 0 ) @@ -61,9 +56,7 @@ export const getDRepDelegatorsHistory = (addrIds: []) => { SELECT SUM(amount) FROM withdrawal LEFT JOIN tx tx_w ON withdrawal.tx_id = tx_w.id - LEFT JOIN block b2 ON tx_w.block_id = b2.id WHERE addr_id = sa.id - AND b2.epoch_no <= b.epoch_no ), 0 ) )::TEXT AS total_stake, diff --git a/backend/src/queries/drepDelegatorsWithVotingPower.ts b/backend/src/queries/drepDelegatorsWithVotingPower.ts index b018b7f..091faae 100644 --- a/backend/src/queries/drepDelegatorsWithVotingPower.ts +++ b/backend/src/queries/drepDelegatorsWithVotingPower.ts @@ -1,44 +1,8 @@ -// export const getDrepDelegatorsWithVotingPowerQuery: string = ` -// WITH latest_delegations AS ( -// SELECT -// dv.addr_id, -// MAX(b.time) as latest_time -// FROM -// delegation_vote dv -// JOIN -// tx ON dv.tx_id = tx.id -// JOIN -// block b ON tx.block_id = b.id -// GROUP BY -// dv.addr_id -// ) -// SELECT -// sa.view AS stake_address, -// b.epoch_no AS delegation_epoch, -// COALESCE(SUM(uv.value), 0) AS voting_power -// FROM -// drep_hash AS dh -// JOIN -// delegation_vote AS dv ON dh.id = dv.drep_hash_id -// JOIN -// stake_address sa ON dv.addr_id = sa.id -// JOIN -// tx ON dv.tx_id = tx.id -// JOIN -// block b ON tx.block_id = b.id -// JOIN -// latest_delegations ld ON dv.addr_id = ld.addr_id AND b.time = ld.latest_time -// LEFT JOIN -// utxo_view uv ON sa.id = uv.stake_address_id -// WHERE -// dh.view = $1 -// GROUP BY -// sa.view, b.epoch_no -// ORDER BY -// voting_power DESC -// `; - -export const getDrepDelegatorsWithVotingPowerQuery: string = ` +export const getDrepDelegatorsWithVotingPowerQuery = ( + itemsPerPage: number, + offset?: number, + orderByClause?: string +) => ` WITH latest_delegations AS ( SELECT delegation_vote.addr_id, MAX(block.time) AS latest_time FROM drep_hash @@ -59,9 +23,31 @@ export const getDrepDelegatorsWithVotingPowerQuery: string = ` JOIN block b ON tx.block_id = b.id JOIN latest_delegations ld ON dv.addr_id = ld.addr_id AND b.time = ld.latest_time - JOIN tx_out txo ON sa.id = txo.stake_address_id + JOIN tx_out txo ON sa.id = txo.stake_address_id AND txo.consumed_by_tx_id IS NULL GROUP BY sa.view, b.epoch_no - ORDER BY voting_power DESC - LIMIT 24 + ${orderByClause} + LIMIT ${itemsPerPage} + OFFSET ${offset} +`; + +export const getDrepDelegatorsCountQuery = () => ` + WITH latest_delegations AS ( + SELECT delegation_vote.addr_id, MAX(block.time) AS latest_time + FROM drep_hash + JOIN delegation_vote ON delegation_vote.drep_hash_id = drep_hash.id + JOIN stake_address ON delegation_vote.addr_id = stake_address.id + JOIN tx ON delegation_vote.tx_id = tx.id + JOIN block ON tx.block_id = block.id + WHERE drep_hash.view = $1 + GROUP BY delegation_vote.addr_id + ) + SELECT COUNT(DISTINCT sa.id) AS total + FROM drep_hash AS dh + JOIN delegation_vote AS dv ON dh.id = dv.drep_hash_id + JOIN stake_address sa ON dv.addr_id = sa.id + JOIN tx ON dv.tx_id = tx.id + JOIN block b ON tx.block_id = b.id + JOIN latest_delegations ld ON dv.addr_id = ld.addr_id + AND b.time = ld.latest_time `; diff --git a/backend/src/queries/drepMetadata.ts b/backend/src/queries/drepMetadata.ts new file mode 100644 index 0000000..db1eeb8 --- /dev/null +++ b/backend/src/queries/drepMetadata.ts @@ -0,0 +1,16 @@ +export const getDRepMetadataQuery = ` + SELECT dh.view, + vd.json AS metadata + FROM drep_hash dh + LEFT JOIN ( + SELECT dr.id, + dr.drep_hash_id, + dr.voting_anchor_id, + ROW_NUMBER() OVER (PARTITION BY dr.drep_hash_id ORDER BY dr.tx_id DESC) AS rn, + tx.hash AS tx_hash + FROM drep_registration dr + JOIN tx ON tx.id = dr.tx_id + ) AS dr_voting_anchor ON dr_voting_anchor.drep_hash_id = dh.id AND dr_voting_anchor.rn = 1 + LEFT JOIN voting_anchor va ON va.id = dr_voting_anchor.voting_anchor_id + LEFT JOIN off_chain_vote_data vd ON vd.voting_anchor_id = va.id + WHERE dh.view = $1`; diff --git a/backend/src/queries/drepRegistration.ts b/backend/src/queries/drepRegistration.ts new file mode 100644 index 0000000..2e97287 --- /dev/null +++ b/backend/src/queries/drepRegistration.ts @@ -0,0 +1,7 @@ +export const drepRegistrationQuery = ` + SELECT dh.view,dr.deposit,dr.tx_id, + ROW_NUMBER() OVER (PARTITION BY drep_hash_id ORDER BY tx_id DESC) AS rn + FROM drep_hash dh + JOIN drep_registration dr ON dh.id = dr.drep_hash_id + where dh.view = $1 + limit 1`; diff --git a/backend/src/queries/getAddrData.ts b/backend/src/queries/getAddrData.ts new file mode 100644 index 0000000..fb37b4c --- /dev/null +++ b/backend/src/queries/getAddrData.ts @@ -0,0 +1,24 @@ +export const getAddrDataQuery: string = ` +WITH address_stake AS ( + SELECT + stake_address_id + FROM + tx_out + WHERE + address = $1 + LIMIT 1 +) +SELECT + sa.view AS stake_address, + COALESCE(SUM(tx_out.value), 0) AS total_stake +FROM + tx_out +JOIN + address_stake ON tx_out.stake_address_id = address_stake.stake_address_id +JOIN + stake_address AS sa ON sa.id = tx_out.stake_address_id +WHERE + tx_out.consumed_by_tx_id IS NULL +GROUP BY + sa.view; + `; diff --git a/backend/src/queries/getDReps.ts b/backend/src/queries/getDReps.ts index 721b4bc..7390106 100644 --- a/backend/src/queries/getDReps.ts +++ b/backend/src/queries/getDReps.ts @@ -1,160 +1,193 @@ export const getAllDRepsQuery = ( - sanitizedSearchCondition: string, - nameFilteredDRepCondition: string, - campaignStatusCondition: string, - chainStatusCondition: string, - orderByClause: string, - itemsPerPage: number, - offset: number, - typeCondition: string, -) => ` - WITH DRepDistr AS (SELECT *, - ROW_NUMBER() OVER (PARTITION BY drep_hash.id ORDER BY drep_distr.epoch_no DESC) AS rn - FROM drep_distr - JOIN drep_hash ON drep_hash.id = drep_distr.hash_id), - DRepDelegationVoteCount AS (SELECT dh.id AS drep_hash_id, - COUNT(DISTINCT dv.addr_id) AS vote_count - FROM drep_hash dh - JOIN delegation_vote dv ON dh.id = dv.drep_hash_id - JOIN stake_address sa ON dv.addr_id = sa.id - JOIN tx ON dv.tx_id = tx.id - JOIN block b ON tx.block_id = b.id - WHERE b.time = (SELECT MAX(b2.time) - FROM delegation_vote dv2 - JOIN tx tx2 ON dv2.tx_id = tx2.id - JOIN block b2 ON tx2.block_id = b2.id - WHERE dv2.addr_id = dv.addr_id - AND dv2.drep_hash_id = dv.drep_hash_id) - GROUP BY dh.id), - DRepActivity AS (SELECT drep_activity AS drep_activity, - epoch_no AS epoch_no - FROM epoch_param - WHERE epoch_no IS NOT NULL - ORDER BY epoch_no DESC - LIMIT 1 + sanitizedSearchCondition: string, + nameFilteredDRepCondition: string, + campaignStatusCondition: string, + chainStatusCondition: string, + orderByClause: string, + itemsPerPage: number, + offset: number, + typeCondition: string, + ) => ` + WITH DRepDistr AS ( + SELECT *, + ROW_NUMBER() OVER (PARTITION BY drep_hash.id ORDER BY drep_distr.epoch_no DESC) AS rn + FROM drep_distr + JOIN drep_hash ON drep_hash.id = drep_distr.hash_id + ), + DRepActivity AS ( + SELECT drep_activity AS drep_activity, + epoch_no AS epoch_no + FROM epoch_param + WHERE epoch_no IS NOT NULL + ORDER BY epoch_no DESC + LIMIT 1 + ), + DRepRegistrationData AS ( + SELECT + dr.id, + dr.tx_id, + dr.drep_hash_id, + dr.deposit, + dr.voting_anchor_id, + tx.hash AS tx_hash, + block.time AS register_time, + ROW_NUMBER() OVER (PARTITION BY dr.drep_hash_id ORDER BY dr.tx_id ASC) AS first_register_rn, + ROW_NUMBER() OVER (PARTITION BY dr.drep_hash_id ORDER BY dr.tx_id DESC) AS newest_register_rn, + CASE + WHEN dr.deposit IS NOT NULL AND dr.deposit >= 0 THEN ROW_NUMBER() OVER (PARTITION BY dr.drep_hash_id ORDER BY dr.tx_id DESC) + ELSE NULL + END AS non_deregister_voting_anchor_rn + FROM drep_registration dr + JOIN tx ON tx.id = dr.tx_id + JOIN block ON block.id = tx.block_id + ) + + SELECT + encode(dh.raw, 'hex'), + dh.view, + va.url, + DRepDistr.amount AS voting_power, + dh.has_script, + (DRepActivity.epoch_no - MAX(COALESCE(block.epoch_no, block_first_register.epoch_no))) <= DRepActivity.drep_activity AS active, + CASE + WHEN dr_voting_anchor.deposit = -500000000 THEN TRUE + ELSE FALSE + END AS retired, + encode(dr_voting_anchor.tx_hash, 'hex') AS tx_hash, + dr_voting_anchor.register_time AS last_register_time, + off_chain_vote_drep_data.given_name, + off_chain_vote_drep_data.image_url, + COALESCE(dd.vote_count, 0) AS delegation_vote_count, + COALESCE(dd.live_stake, null) AS live_stake + FROM drep_hash dh + LEFT JOIN DRepRegistrationData AS dr_voting_anchor + ON dr_voting_anchor.drep_hash_id = dh.id AND dr_voting_anchor.newest_register_rn = 1 + LEFT JOIN DRepRegistrationData AS dr_non_deregister_voting_anchor + ON dr_non_deregister_voting_anchor.drep_hash_id = dh.id AND dr_non_deregister_voting_anchor.non_deregister_voting_anchor_rn = 1 + LEFT JOIN DRepRegistrationData AS second_to_newest_drep_registration + ON second_to_newest_drep_registration.drep_hash_id = dh.id AND second_to_newest_drep_registration.newest_register_rn = 2 + LEFT JOIN DRepDistr + ON DRepDistr.hash_id = dh.id AND DRepDistr.rn = 1 + LEFT JOIN voting_anchor va + ON va.id = dr_voting_anchor.voting_anchor_id + LEFT JOIN voting_anchor non_deregister_voting_anchor + ON non_deregister_voting_anchor.id = dr_non_deregister_voting_anchor.voting_anchor_id + LEFT JOIN off_chain_vote_data + ON off_chain_vote_data.voting_anchor_id = va.id + LEFT JOIN off_chain_vote_drep_data + ON off_chain_vote_drep_data.off_chain_vote_data_id = off_chain_vote_data.id + CROSS JOIN DRepActivity + LEFT JOIN voting_procedure AS voting_procedure + ON voting_procedure.drep_voter = dh.id + LEFT JOIN tx AS tx + ON tx.id = voting_procedure.tx_id + LEFT JOIN block AS block + ON block.id = tx.block_id + LEFT JOIN DRepRegistrationData AS dr_first_register + ON dr_first_register.drep_hash_id = dh.id AND dr_first_register.first_register_rn = 1 + LEFT JOIN tx AS tx_first_register + ON tx_first_register.id = dr_first_register.tx_id + LEFT JOIN block AS block_first_register + ON block_first_register.id = tx_first_register.block_id + LEFT JOIN drepdelegationsummary dd ON dd.drep_hash_id = dh.id + WHERE 1=1 ${chainStatusCondition} ${sanitizedSearchCondition} ${nameFilteredDRepCondition} ${campaignStatusCondition} ${typeCondition} + GROUP BY + dh.raw, + second_to_newest_drep_registration.voting_anchor_id, + dh.view, + va.url, + voting_power, + dh.has_script, + DRepActivity.epoch_no, + DRepActivity.drep_activity, + dr_voting_anchor.tx_hash, + dr_voting_anchor.register_time, + dr_voting_anchor.deposit, + off_chain_vote_drep_data.given_name, + off_chain_vote_drep_data.image_url, + dd.vote_count, + dd.live_stake + + ${orderByClause} + LIMIT ${itemsPerPage} + OFFSET ${offset} + `; + export const getTotalResultsQuery = ( + sanitizedSearchCondition: string, + nameFilteredDRepCondition: string, + campaignStatusCondition: string, + chainStatusCondition: string, + typeCondition: string, + ) => ` + WITH DRepDistr AS ( + SELECT drep_distr.*, + ROW_NUMBER() OVER (PARTITION BY drep_hash.id ORDER BY drep_distr.epoch_no DESC) AS rn + FROM drep_distr + JOIN drep_hash ON drep_hash.id = drep_distr.hash_id + ), + DRepActivity AS ( + SELECT drep_activity, + epoch_no + FROM epoch_param + WHERE epoch_no IS NOT NULL + ORDER BY epoch_no DESC + LIMIT 1 + ), + DRepRegistrationData AS ( + SELECT + dr.id, + dr.tx_id, + dr.drep_hash_id, + dr.deposit, + dr.voting_anchor_id, + tx.hash AS tx_hash, + block.time AS register_time, + ROW_NUMBER() OVER (PARTITION BY dr.drep_hash_id ORDER BY dr.tx_id ASC) AS first_register_rn, + ROW_NUMBER() OVER (PARTITION BY dr.drep_hash_id ORDER BY dr.tx_id DESC) AS newest_register_rn, + CASE + WHEN dr.deposit IS NOT NULL AND dr.deposit >= 0 THEN ROW_NUMBER() OVER (PARTITION BY dr.drep_hash_id ORDER BY dr.tx_id DESC) + ELSE NULL + END AS non_deregister_voting_anchor_rn + FROM drep_registration dr + JOIN tx ON tx.id = dr.tx_id + JOIN block ON block.id = tx.block_id ) - SELECT encode(dh.raw, 'hex'), - dh.view, - va.url, - DRepDistr.amount As voting_power, - dh.has_script, - (DRepActivity.epoch_no - Max(coalesce(block.epoch_no, block_first_register.epoch_no))) <= - DRepActivity.drep_activity AS active, - encode(dr_voting_anchor.tx_hash, 'hex') AS tx_hash, - newestRegister.time AS last_register_time, - off_chain_vote_drep_data.given_name, - off_chain_vote_drep_data.image_url, - COALESCE(dvc.vote_count, 0) AS delegation_vote_count - FROM drep_hash dh - JOIN (SELECT dr.id, - dr.drep_hash_id, - dr.deposit, - ROW_NUMBER() OVER (PARTITION BY dr.drep_hash_id ORDER BY dr.tx_id DESC) AS rn - FROM drep_registration dr - WHERE dr.deposit IS NOT NULL) AS dr_deposit ON dr_deposit.drep_hash_id = dh.id - AND dr_deposit.rn = 1 - JOIN (SELECT dr.id, - dr.drep_hash_id, - dr.deposit, - ROW_NUMBER() OVER (PARTITION BY dr.drep_hash_id ORDER BY dr.tx_id DESC) AS rn - FROM drep_registration dr) AS latestDeposit ON latestDeposit.drep_hash_id = dh.id - AND latestDeposit.rn = 1 - LEFT JOIN (SELECT dr.id, - dr.drep_hash_id, - dr.voting_anchor_id, - ROW_NUMBER() OVER (PARTITION BY dr.drep_hash_id ORDER BY dr.tx_id DESC) AS rn, tx.hash AS tx_hash - FROM drep_registration dr - JOIN tx ON tx.id = dr.tx_id) AS dr_voting_anchor - ON dr_voting_anchor.drep_hash_id = dh.id - AND dr_voting_anchor.rn = 1 - LEFT JOIN (SELECT dr.id, - dr.drep_hash_id, - dr.voting_anchor_id, - ROW_NUMBER() OVER (PARTITION BY dr.drep_hash_id ORDER BY dr.tx_id DESC) AS rn, tx.hash AS tx_hash - FROM drep_registration dr - JOIN tx ON tx.id = dr.tx_id - WHERE dr.deposit is not null - AND dr.deposit >= 0) AS dr_non_deregister_voting_anchor - ON dr_non_deregister_voting_anchor.drep_hash_id = dh.id - AND dr_non_deregister_voting_anchor.rn = 1 - LEFT JOIN (SELECT dr.id, - dr.drep_hash_id, - dr.voting_anchor_id, - ROW_NUMBER() OVER (PARTITION BY dr.drep_hash_id ORDER BY dr.tx_id DESC) AS rn - FROM drep_registration dr) AS second_to_newest_drep_registration - ON second_to_newest_drep_registration.drep_hash_id = dh.id - AND second_to_newest_drep_registration.rn = 2 - LEFT JOIN DRepDistr ON DRepDistr.hash_id = dh.id - AND DRepDistr.rn = 1 - LEFT JOIN voting_anchor va ON va.id = dr_voting_anchor.voting_anchor_id - LEFT JOIN voting_anchor non_deregister_voting_anchor - on non_deregister_voting_anchor.id = dr_non_deregister_voting_anchor.voting_anchor_id - LEFT JOIN off_chain_vote_data ON off_chain_vote_data.voting_anchor_id = va.id - LEFT JOIN off_chain_vote_drep_data - on off_chain_vote_drep_data.off_chain_vote_data_id = off_chain_vote_data.id - CROSS JOIN DRepActivity - LEFT JOIN voting_procedure AS voting_procedure ON voting_procedure.drep_voter = dh.id - LEFT JOIN tx AS tx ON tx.id = voting_procedure.tx_id - LEFT JOIN block AS block ON block.id = tx.block_id - LEFT JOIN (SELECT block.time, - dr.drep_hash_id, - ROW_NUMBER() OVER (PARTITION BY dr.drep_hash_id ORDER BY dr.tx_id DESC) AS rn - FROM drep_registration dr - JOIN tx ON tx.id = dr.tx_id - JOIN block ON block.id = tx.block_id - WHERE NOT (dr.deposit < 0)) AS newestRegister ON newestRegister.drep_hash_id = dh.id - AND newestRegister.rn = 1 - LEFT JOIN (SELECT dr.tx_id, - dr.drep_hash_id, - ROW_NUMBER() OVER (PARTITION BY dr.drep_hash_id ORDER BY dr.tx_id ASC) AS rn - FROM drep_registration dr) AS dr_first_register ON dr_first_register.drep_hash_id = dh.id - AND dr_first_register.rn = 1 - LEFT JOIN tx AS tx_first_register ON tx_first_register.id = dr_first_register.tx_id - LEFT JOIN block AS block_first_register ON block_first_register.id = tx_first_register.block_id - LEFT JOIN DRepDelegationVoteCount dvc ON dvc.drep_hash_id = dh.id - WHERE 1=1 ${chainStatusCondition} ${sanitizedSearchCondition} ${nameFilteredDRepCondition} ${campaignStatusCondition} ${typeCondition} - GROUP BY - dh.raw, - second_to_newest_drep_registration.voting_anchor_id, - dh.view, - va.url, - voting_power, - dh.has_script, - DRepActivity.epoch_no, - DRepActivity.drep_activity, - dr_voting_anchor.tx_hash, - newestRegister.time, - latestDeposit.deposit, - off_chain_vote_drep_data.given_name, - off_chain_vote_drep_data.image_url, - dvc.vote_count ${orderByClause} - LIMIT ${itemsPerPage} - OFFSET ${offset} -`; - -export const getTotalResultsQuery = ( - sanitizedSearchCondition: string, - campaignStatusCondition: string, - chainStatusCondition: string, - typeCondition: string, -) => ` - WITH LatestEpoch AS (SELECT MAX(no) AS latest_epoch_no - FROM epoch) - SELECT COUNT(DISTINCT dh.id) AS total - FROM drep_hash AS dh - LEFT JOIN drep_distr AS dd ON dh.id = dd.hash_id - LEFT JOIN (SELECT dr.id, - dr.drep_hash_id, - dr.voting_anchor_id, - ROW_NUMBER() OVER (PARTITION BY dr.drep_hash_id ORDER BY dr.tx_id DESC) AS rn, tx.hash AS tx_hash - FROM drep_registration dr - JOIN tx ON tx.id = dr.tx_id) AS dr_voting_anchor - ON dr_voting_anchor.drep_hash_id = dh.id - AND dr_voting_anchor.rn = 1 - LEFT JOIN voting_anchor va ON va.id = dr_voting_anchor.voting_anchor_id - LEFT JOIN off_chain_vote_data ON off_chain_vote_data.voting_anchor_id = va.id - LEFT JOIN off_chain_vote_drep_data - on off_chain_vote_drep_data.off_chain_vote_data_id = off_chain_vote_data.id - CROSS JOIN LatestEpoch le - WHERE 1=1 ${sanitizedSearchCondition} ${campaignStatusCondition} ${typeCondition} -`; + SELECT COUNT(DISTINCT dh.id) AS total + FROM drep_hash dh + LEFT JOIN DRepRegistrationData AS dr_voting_anchor + ON dr_voting_anchor.drep_hash_id = dh.id AND dr_voting_anchor.newest_register_rn = 1 + LEFT JOIN DRepRegistrationData AS dr_non_deregister_voting_anchor + ON dr_non_deregister_voting_anchor.drep_hash_id = dh.id AND dr_non_deregister_voting_anchor.non_deregister_voting_anchor_rn = 1 + LEFT JOIN DRepDistr + ON DRepDistr.hash_id = dh.id AND DRepDistr.rn = 1 + LEFT JOIN voting_anchor va + ON va.id = dr_voting_anchor.voting_anchor_id + LEFT JOIN voting_anchor non_deregister_voting_anchor + ON non_deregister_voting_anchor.id = dr_non_deregister_voting_anchor.voting_anchor_id + LEFT JOIN off_chain_vote_data + ON off_chain_vote_data.voting_anchor_id = va.id + LEFT JOIN off_chain_vote_drep_data + ON off_chain_vote_drep_data.off_chain_vote_data_id = off_chain_vote_data.id + CROSS JOIN DRepActivity + LEFT JOIN voting_procedure AS voting_procedure + ON voting_procedure.drep_voter = dh.id + LEFT JOIN tx AS tx + ON tx.id = voting_procedure.tx_id + LEFT JOIN block AS block + ON block.id = tx.block_id + LEFT JOIN DRepRegistrationData AS dr_first_register + ON dr_first_register.drep_hash_id = dh.id AND dr_first_register.first_register_rn = 1 + LEFT JOIN tx AS tx_first_register + ON tx_first_register.id = dr_first_register.tx_id + LEFT JOIN block AS block_first_register + ON block_first_register.id = tx_first_register.block_id + LEFT JOIN drepdelegationsummary dd + ON dd.drep_hash_id = dh.id + WHERE 1=1 + ${chainStatusCondition} + ${sanitizedSearchCondition} + ${nameFilteredDRepCondition} + ${campaignStatusCondition} + ${typeCondition} + `; + \ No newline at end of file diff --git a/backend/src/queries/getLatestBlock.ts b/backend/src/queries/getLatestBlock.ts new file mode 100644 index 0000000..72d7ead --- /dev/null +++ b/backend/src/queries/getLatestBlock.ts @@ -0,0 +1,22 @@ +export const getLatestBlock = ` +SELECT + SUBSTRING(CAST(block.hash AS TEXT) FROM 3) AS hash, + epoch_no, + slot_no, + epoch_slot_no, + block_no, + previous_id, + ph.view as slot_leader, + size, + time, + tx_count, + proto_major, + proto_minor, + vrf_key, + SUBSTRING(CAST(op_cert AS TEXT) FROM 3) AS op_cert, + op_cert_counter +FROM "block" +JOIN slot_leader as sl ON block.slot_leader_id = sl.id +JOIN pool_hash as ph ON sl.pool_hash_id = ph.id +ORDER BY block."id" DESC +LIMIT 1`; diff --git a/backend/src/queries/getStakeKeyData.ts b/backend/src/queries/getStakeKeyData.ts new file mode 100644 index 0000000..b5e28bf --- /dev/null +++ b/backend/src/queries/getStakeKeyData.ts @@ -0,0 +1,8 @@ +export const getStakeKeyData: string = ` +SELECT sa.view AS stake_address, +COALESCE(SUM(txo.value), 0) AS total_stake +FROM stake_address AS sa +JOIN tx_out txo ON sa.id = txo.stake_address_id AND txo.consumed_by_tx_id IS NULL +WHERE sa.view = $1 +GROUP BY sa.view`; + diff --git a/backend/src/queries/getVoterDelegationHistory.ts b/backend/src/queries/getVoterDelegationHistory.ts new file mode 100644 index 0000000..1f14aa5 --- /dev/null +++ b/backend/src/queries/getVoterDelegationHistory.ts @@ -0,0 +1,43 @@ +export const getVoterDelegationHistory = ` +WITH LatestDrepDistr AS ( + SELECT + dh.view AS drep_id, + bk.time, + bk.epoch_no AS delegation_epoch, + dd.epoch_no AS current_epoch, + dd.amount AS voting_power, +SUBSTRING(CAST(tx.hash AS TEXT), 3) as tx_hash, + ROW_NUMBER() OVER (PARTITION BY dh.id ORDER BY dd.epoch_no DESC) AS row_num + 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 + LEFT JOIN + drep_distr AS dd ON dd.hash_id = dh.id + JOIN + tx ON tx.id = dv.tx_id + JOIN + tx_out ON tx.id = tx_out.tx_id + JOIN + block AS bk ON bk.id = tx.block_id + WHERE + sa.view = $1 + +) +SELECT +tx_hash, + drep_id, + time, + current_epoch, + delegation_epoch, + voting_power, + row_num +FROM + LatestDrepDistr +WHERE + row_num = 1 +ORDER BY + time DESC +` \ No newline at end of file diff --git a/backend/src/queries/voterGovActions.ts b/backend/src/queries/voterGovActions.ts new file mode 100644 index 0000000..811c661 --- /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 AS voting_epoch, + b.time AS time_voted, + encode(vt.hash, 'hex') AS vote_tx_hash, + dh.view + 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/typeorm.config.ts b/backend/src/typeorm.config.ts new file mode 100644 index 0000000..e6d94a2 --- /dev/null +++ b/backend/src/typeorm.config.ts @@ -0,0 +1,21 @@ +import * as dotenv from 'dotenv'; +import { DataSource } from 'typeorm/data-source/DataSource'; + +dotenv.config(); + +const datasource = new DataSource({ + type: 'postgres', + host: process.env.DATABASE_HOST, + port: parseInt(process.env.DATABASE_PORT, 10), + username: process.env.DATABASE_USERNAME, + database: process.env.DATABASE_NAME, + password: process.env.DATABASE_PASSWORD, + entities: ['src/entities/*.entity.{ts,js}'], + migrations: ['src/migrations/**/*.{ts,js}'], + extra: { + charset: 'utf8mb4_unicode_ci', + }, + logging: true, +}); +datasource.initialize().then(); +export default datasource; diff --git a/backend/src/voter/voter.controller.ts b/backend/src/voter/voter.controller.ts index 808553b..c0dcbfa 100644 --- a/backend/src/voter/voter.controller.ts +++ b/backend/src/voter/voter.controller.ts @@ -1,13 +1,34 @@ -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') + getVoter(@Param('voterIdentity') voterIdentity: string) { + return this.voterService.getVoter(voterIdentity); + } @Get(':stakeKey/delegation') 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 c1c09f3..d62363d 100644 --- a/backend/src/voter/voter.service.ts +++ b/backend/src/voter/voter.service.ts @@ -1,5 +1,15 @@ import { Injectable } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; +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() @@ -8,33 +18,96 @@ export class VoterService { @InjectDataSource('dbsync') private cexplorerService: DataSource, ) {} - async getAdaHolderCurrentDelegation(stakeKey: string) { + async getVoter(voterIdentity: string): Promise { + let voterData; + let delegationHistory; + switch (true) { + case voterIdentity.includes('drep'): + voterData = await this.cexplorerService.manager.query( + getDrepAddrData, + [voterIdentity], + ); + delegationHistory = await this.cexplorerService.manager.query( + getVoterDelegationHistory, + [voterData[0].stake_address], + ); + break; + case voterIdentity.includes('stake'): + voterData = await this.cexplorerService.manager.query(getStakeKeyData, [ + voterIdentity, + ]); + delegationHistory = await this.cexplorerService.manager.query( + getVoterDelegationHistory, + [voterIdentity], // stakeKey + ); + break; + default: + voterData = await this.cexplorerService.manager.query( + getAddrDataQuery, + [voterIdentity], + ); + delegationHistory = await this.cexplorerService.manager.query( + getVoterDelegationHistory, + [voterData[0].stake_address], + ); + break; + } + + return Array.isArray(voterData) + ? { + ...voterData[0], + delegationHistory, + isDelegated: delegationHistory.length > 0, + } + : null; + } + async getAdaHolderCurrentDelegation(stakeKey: string): Promise { 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/chart/values.postgresql.yaml b/chart/values.postgresql.yaml index f2a8a9e..aa4701b 100644 --- a/chart/values.postgresql.yaml +++ b/chart/values.postgresql.yaml @@ -292,9 +292,9 @@ primary: max_connections=500 max_prepared_transactions=500 max_locks_per_transaction = 256 - shared_buffers = 16GB - effective_cache_size = 36GB - maintenance_work_mem = 2GB + shared_buffers = 8GB + effective_cache_size = 24GB + maintenance_work_mem = 1GB checkpoint_completion_target = 0.9 checkpoint_timeout = 1h synchronous_commit = off @@ -305,7 +305,7 @@ primary: effective_io_concurrency = 400 work_mem = 24MB min_wal_size = 1GB - max_wal_size = 4GB + max_wal_size = 2GB max_parallel_workers_per_gather = 8 max_parallel_workers = 8 max_parallel_maintenance_workers = 4 @@ -446,7 +446,7 @@ primary: memory: 65536Mi cpu: 12 requests: - memory: 16384Mi + memory: 1024Mi cpu: 900m ## Pod Security Context ## ref: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ diff --git a/docker-compose.yaml b/docker-compose.yaml index 7774ed3..cf72637 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -59,14 +59,16 @@ services: web_db: container_name: voltaire_db # Name of the database container platform: linux/amd64 # Specify the platform (important for ARM64 architectures) - image: postgres:16.3-alpine3.19 # Use PostgreSQL 12 on Alpine for smaller size + image: bitnami/postgresql:15.4.0-debian-11-r30 restart: unless-stopped ports: - "5434:5432" # Expose port 5432 to the host environment: # PostgreSQL user, password, and database name - POSTGRES_USER: voltaire + POSTGRESQL_USERNAME: voltaire POSTGRES_PASSWORD: postgres - POSTGRES_DB: 1694 + POSTGRES_POSTGRES_PASSWORD: postgres_password + POSTGRESQL_DATABASE: 1694 + POSTGRES_LOGGING: true volumes: - db-data:/var/lib/postgresql/data/ # Persistent storage for the database networks: diff --git a/frontend/.env.example b/frontend/.env.example index f16ea08..67fe374 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -4,4 +4,5 @@ NEXT_PUBLIC_BASE_URL_EXPLORER='https://sancho.cexplorer.io' NEXT_PUBLIC_SPROUT_ENVIRONMENT_ID='your-sprout-environment-id' NEXT_PUBLIC_FATHOM_ENVIRONMENT_ID='your-fathom-environment-id' NEXT_PUBLIC_NETWORK_ID=0 +NEXT_PUBLIC_NETWORK_MODE='testnet' NEXT_PUBLIC_IPFS_GATEWAY='https://ipfs.io' \ No newline at end of file diff --git a/frontend/cypress/e2e/NewNote/newNote.cy.ts b/frontend/cypress/e2e/NewNote/newNote.cy.ts index 627d86e..18e2831 100644 --- a/frontend/cypress/e2e/NewNote/newNote.cy.ts +++ b/frontend/cypress/e2e/NewNote/newNote.cy.ts @@ -87,19 +87,19 @@ describe('Create new note if wallet is connected', () => { expect(response.body).to.have.property('id'); expect(response.body.id).to.be.a('number'); - expect(response.body).to.have.property('note_title'); - expect(response.body.note_title).to.contain('Update Title'); + expect(response.body).to.have.property('title'); + expect(response.body.title).to.contain('Update Title'); expect(response.body).to.have.property('note_tag'); expect(response.body.note_tag).to.contain('update tag'); - expect(response.body).to.have.property('note_content'); - expect(response.body.note_content).to.contain( + expect(response.body).to.have.property('content'); + expect(response.body.content).to.contain( '

This is a update note.

', ); - expect(response.body).to.have.property('note_visibility'); - expect(response.body.note_visibility).to.contain('myself'); + expect(response.body).to.have.property('visibility'); + expect(response.body.visibility).to.contain('myself'); }); }); }); diff --git a/frontend/cypress/fixtures/newnoteexample.json b/frontend/cypress/fixtures/newnoteexample.json index fa39552..ee38db2 100644 --- a/frontend/cypress/fixtures/newnoteexample.json +++ b/frontend/cypress/fixtures/newnoteexample.json @@ -1,10 +1,10 @@ [ { - "note_title": "Sample Note Title", + "title": "Sample Note Title", "note_tag": "Sample Tag", - "note_content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", "stake_addr": "sample_stake_address", "voter": "drep18xwllqkz4zhh6gvgxc63ygxrpe6hecc4v2jax96se2mljh2lxwr", - "note_visibility": "everyone" + "visibility": "everyone" } ] diff --git a/frontend/package.json b/frontend/package.json index 001a370..7b95cce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,7 @@ "@emotion/cache": "^11.11.0", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.0", - "@emurgo/cardano-serialization-lib-asmjs": "12.0.0-beta.1", + "@emurgo/cardano-serialization-lib-asmjs": "^12.1.0", "@fontsource/poppins": "^5.0.13", "@hookform/resolvers": "^3.3.4", "@mdxeditor/editor": "^3.8.0", diff --git a/frontend/public/img/logos/govtool-white.png b/frontend/public/img/logos/govtool-white.png new file mode 100644 index 0000000..9918832 Binary files /dev/null and b/frontend/public/img/logos/govtool-white.png differ diff --git a/frontend/public/img/logos/mainnet-black.png b/frontend/public/img/logos/mainnet-black.png new file mode 100644 index 0000000..e036853 Binary files /dev/null and b/frontend/public/img/logos/mainnet-black.png differ diff --git a/frontend/public/img/logos/preview-black.png b/frontend/public/img/logos/preview-black.png new file mode 100644 index 0000000..870d797 Binary files /dev/null and b/frontend/public/img/logos/preview-black.png differ diff --git a/frontend/public/img/logos/sancho-black.png b/frontend/public/img/logos/sancho-black.png new file mode 100644 index 0000000..271f518 Binary files /dev/null and b/frontend/public/img/logos/sancho-black.png differ diff --git a/frontend/public/img/logos/voltaire-black.png b/frontend/public/img/logos/voltaire-black.png new file mode 100644 index 0000000..8a63cb5 Binary files /dev/null and b/frontend/public/img/logos/voltaire-black.png differ diff --git a/frontend/public/svgs/success.svg b/frontend/public/svgs/success.svg new file mode 100644 index 0000000..7355867 --- /dev/null +++ b/frontend/public/svgs/success.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/app/[locale]/[...not-found]/page.tsx b/frontend/src/app/[locale]/[...not-found]/page.tsx new file mode 100644 index 0000000..0d20dbd --- /dev/null +++ b/frontend/src/app/[locale]/[...not-found]/page.tsx @@ -0,0 +1,8 @@ +'use client' +import { notFound } from 'next/navigation'; + +function NotFound() { + notFound(); +} + +export default NotFound; diff --git a/frontend/src/app/[locale]/dreps/[drepid]/delegators/page.tsx b/frontend/src/app/[locale]/dreps/[drepid]/delegators/page.tsx index 2b108ef..cbbf51e 100644 --- a/frontend/src/app/[locale]/dreps/[drepid]/delegators/page.tsx +++ b/frontend/src/app/[locale]/dreps/[drepid]/delegators/page.tsx @@ -1,19 +1,14 @@ 'use client'; -import Loading from '@/app/[locale]/loading'; import DrepProfileMetrics from '@/components/molecules/DrepProfileMetrics'; -import { useGetSingleDRepQuery } from '@/hooks/useGetSingleDRepQuery'; import { useParams } from 'next/navigation'; -import { Suspense } from 'react'; - -const DelegatorsPage = () => { - const { drepIdd } = useParams(); - const { dRep } = useGetSingleDRepQuery(drepIdd); +const page = () => { + const { drepid } = useParams(); return ( - }> - ; - +
+ +
); }; -export default DelegatorsPage; +export default page; diff --git a/frontend/src/app/[locale]/dreps/[drepid]/layout.tsx b/frontend/src/app/[locale]/dreps/[drepid]/layout.tsx index d96f7db..dc8cb17 100644 --- a/frontend/src/app/[locale]/dreps/[drepid]/layout.tsx +++ b/frontend/src/app/[locale]/dreps/[drepid]/layout.tsx @@ -2,19 +2,29 @@ import DRepProfileBar from '@/components/atoms/DrepProfileBar'; import DrepTabGroup from '@/components/atoms/DrepTabGroup'; import { useState } from 'react'; -import { IconButton } from '@mui/material'; +import { Grow, IconButton } from '@mui/material'; import { useCardano } from '@/context/walletContext'; import { useGetSingleDRepQuery } from '@/hooks/useGetSingleDRepQuery'; import { useParams } from 'next/navigation'; +import NotFound from './not-found'; export default function Layout({ children }: { children: React.ReactNode }) { const { dRepIDBech32 } = useCardano(); const [isOpen, setIsOpen] = useState(false); const { drepid } = useParams(); - const { dRep } = useGetSingleDRepQuery(drepid); + const { dRep, isDRepLoading, fetchError } = useGetSingleDRepQuery(drepid); - const currentUserIsDrep = - dRep?.drep_id && dRep?.cexplorerDetails?.view == dRepIDBech32; + const currentUserIsDrep = dRep?.drep_id && dRep?.view == dRepIDBech32; + + if (!isDRepLoading && fetchError?.response?.status === 404) { + return ( + +
+ +
+
+ ); + } return (
{/* If current user is a drep, the drawer will be available for use */} @@ -23,9 +33,11 @@ export default function Layout({ children }: { children: React.ReactNode }) { )}
-
+
{currentUserIsDrep && ( -
+
{ @@ -43,7 +55,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
diff --git a/frontend/src/app/[locale]/dreps/[drepid]/loading.tsx b/frontend/src/app/[locale]/dreps/[drepid]/loading.tsx new file mode 100644 index 0000000..fd18549 --- /dev/null +++ b/frontend/src/app/[locale]/dreps/[drepid]/loading.tsx @@ -0,0 +1,14 @@ +import { Background } from '@/components/atoms/Background'; +import React from 'react'; + +const Loading = () => { + return ( +
+ +
+
+
+ ); +}; + +export default Loading; diff --git a/frontend/src/app/[locale]/dreps/[drepid]/not-found.tsx b/frontend/src/app/[locale]/dreps/[drepid]/not-found.tsx new file mode 100644 index 0000000..a693261 --- /dev/null +++ b/frontend/src/app/[locale]/dreps/[drepid]/not-found.tsx @@ -0,0 +1,26 @@ +import { usePathname } from 'next/navigation'; +import React from 'react'; + +function NotFound() { + const pathname = usePathname(); + return ( +
+
+

Oops!

+
+

+ 404 +

+

+ This DRep could not be found. +

+
+

+ URL: {pathname} +

+
+
+ ); +} + +export default NotFound; diff --git a/frontend/src/app/[locale]/dreps/[drepid]/page.tsx b/frontend/src/app/[locale]/dreps/[drepid]/page.tsx index 0d5cc84..ed06490 100644 --- a/frontend/src/app/[locale]/dreps/[drepid]/page.tsx +++ b/frontend/src/app/[locale]/dreps/[drepid]/page.tsx @@ -5,14 +5,12 @@ import DrepTimeline from '@/components/molecules/DrepTimeline'; import { useGetSingleDRepQuery } from '@/hooks/useGetSingleDRepQuery'; import { useParams } from 'next/navigation'; import DrepClaimProfileCard from '@/components/atoms/DrepClaimProfileCard'; -import Loading from '../../loading'; const page = () => { const { drepid } = useParams(); const { dRep, isDRepLoading } = useGetSingleDRepQuery(drepid); return ( - }>
{dRep?.drep_id ? ( @@ -23,11 +21,10 @@ const page = () => {
- +
-
); }; diff --git a/frontend/src/app/[locale]/dreps/list/page.tsx b/frontend/src/app/[locale]/dreps/list/page.tsx index e47e126..dbaad2a 100644 --- a/frontend/src/app/[locale]/dreps/list/page.tsx +++ b/frontend/src/app/[locale]/dreps/list/page.tsx @@ -9,6 +9,7 @@ type PageProps = { sort?: string; order?: string; on_chain?: string; + include_retired?: string; campaign?: string; type?: string; }; @@ -19,6 +20,7 @@ const page = ({ searchParams }: PageProps) => { const sort = searchParams?.sort || null; const order = searchParams?.order || null; const onChainStatus = searchParams?.on_chain || null; + const includeRetired = searchParams?.include_retired || null; const campaignStatus = searchParams?.campaign || null; const type = searchParams?.type || null; @@ -39,6 +41,7 @@ const page = ({ searchParams }: PageProps) => { order={order} onChainStatus={onChainStatus} campaignStatus={campaignStatus} + includeRetired={includeRetired} type={type} /> diff --git a/frontend/src/app/[locale]/dreps/loading.tsx b/frontend/src/app/[locale]/dreps/loading.tsx new file mode 100644 index 0000000..fd18549 --- /dev/null +++ b/frontend/src/app/[locale]/dreps/loading.tsx @@ -0,0 +1,14 @@ +import { Background } from '@/components/atoms/Background'; +import React from 'react'; + +const Loading = () => { + return ( +
+ +
+
+
+ ); +}; + +export default Loading; diff --git a/frontend/src/app/[locale]/dreps/workflow/notes/[noteid]/update/page.tsx b/frontend/src/app/[locale]/dreps/workflow/notes/[noteid]/update/page.tsx index 6e83349..c63211b 100644 --- a/frontend/src/app/[locale]/dreps/workflow/notes/[noteid]/update/page.tsx +++ b/frontend/src/app/[locale]/dreps/workflow/notes/[noteid]/update/page.tsx @@ -9,21 +9,25 @@ import React, { useEffect, useState } from 'react'; const page = (params: { params: { noteid: number } }) => { const { isEnabled } = useCardano(); const [initialValues, setInitialValues] = useState(null); - const { setIsWalletListModalOpen } = useDRepContext(); + const { setIsWalletListModalOpen, setHideCloseButtonOnWalletListModal } = useDRepContext(); //displays or hides modal only if in form page useEffect(() => { - const fetchNoteandCheckLogin = async () => { + const fetchNoteAndCheckLogin = async () => { try { - if (!isEnabled) setIsWalletListModalOpen(true); + if (!isEnabled) { + setIsWalletListModalOpen(true) + setHideCloseButtonOnWalletListModal(true); + } const note = await getSingleNote(params.params.noteid); setInitialValues(note); } catch (error) { console.log(error); } }; - fetchNoteandCheckLogin(); + fetchNoteAndCheckLogin(); return () => { setIsWalletListModalOpen(false); + setHideCloseButtonOnWalletListModal(false); }; }, []); return ( @@ -34,7 +38,7 @@ const page = (params: { params: { noteid: number } }) => {

Update Note

-
+
diff --git a/frontend/src/app/[locale]/dreps/workflow/notes/new/page.tsx b/frontend/src/app/[locale]/dreps/workflow/notes/new/page.tsx index abe98b7..acf2e28 100644 --- a/frontend/src/app/[locale]/dreps/workflow/notes/new/page.tsx +++ b/frontend/src/app/[locale]/dreps/workflow/notes/new/page.tsx @@ -3,21 +3,40 @@ import ViewDraftsButton from '@/components/molecules/ViewDraftsButton'; import NewNoteForm from '@/components/organisms/NewNoteForm'; import { useDRepContext } from '@/context/drepContext'; import { useCardano } from '@/context/walletContext'; -import React, { useEffect } from 'react'; +import { usePathname } from 'next/navigation'; +import React, { useCallback, useEffect } from 'react'; const page = () => { const { isEnabled } = useCardano(); - const { setIsWalletListModalOpen } = useDRepContext(); - //displays or hides modal only if in form page + const pathname = usePathname(); + const { + setIsWalletListModalOpen, + isLoggedIn, + setLoginModalOpen, + isWalletListModalOpen, + loginModalOpen, + currentLocale, + setHideCloseButtonOnWalletListModal, + setHideCloseButtonOnLoginModal + } = useDRepContext(); + + const checkAccess = useCallback(() => { + if (!pathname.includes(`/${currentLocale}/dreps/workflow/notes/new`)) { + return; + } + if (!isEnabled) { + setIsWalletListModalOpen(true); + setHideCloseButtonOnWalletListModal(true); + } else if (isEnabled && !isLoggedIn) { + setLoginModalOpen(true); + setHideCloseButtonOnLoginModal(true); + } + }, [isEnabled, isLoggedIn, isWalletListModalOpen, loginModalOpen]); + useEffect(() => { - const checkLogin = () => { - if (!isEnabled) setIsWalletListModalOpen(true); - }; - checkLogin(); - return () => { - setIsWalletListModalOpen(false); - }; - }, []); + checkAccess(); + }, [checkAccess]); + return (
diff --git a/frontend/src/app/[locale]/dreps/workflow/profile/layout.tsx b/frontend/src/app/[locale]/dreps/workflow/profile/layout.tsx index 62868ae..737bff8 100644 --- a/frontend/src/app/[locale]/dreps/workflow/profile/layout.tsx +++ b/frontend/src/app/[locale]/dreps/workflow/profile/layout.tsx @@ -5,24 +5,36 @@ import { usePathname } from 'next/navigation'; import { useDRepContext } from '@/context/drepContext'; import { useGlobalNotifications } from '@/context/globalNotificationContext'; - interface Props { children?: React.ReactNode; } const Layout = ({ children }: Props) => { const pathname = usePathname(); - const { currentLocale, isLoggedIn, setLoginModalOpen, loginModalOpen, isWalletListModalOpen } = - useDRepContext(); + const { + currentLocale, + isLoggedIn, + setLoginModalOpen, + loginModalOpen, + isWalletListModalOpen, + setHideCloseButtonOnLoginModal, + setHideCloseButtonOnWalletListModal, + } = useDRepContext(); const { addWarningAlert } = useGlobalNotifications(); + useEffect(() => { if ( !isLoggedIn && !isWalletListModalOpen && pathname.includes(`/${currentLocale}/dreps/workflow/profile/update`) ) { setLoginModalOpen(true); + setHideCloseButtonOnLoginModal(true); + } + if ((isLoggedIn && loginModalOpen) || isWalletListModalOpen) { + setLoginModalOpen(false); + setHideCloseButtonOnWalletListModal(true); + setHideCloseButtonOnLoginModal(false); } - if (isLoggedIn && loginModalOpen) setLoginModalOpen(false); }, [loginModalOpen, isLoggedIn, isWalletListModalOpen]); useEffect(() => { diff --git a/frontend/src/app/[locale]/dreps/workflow/profile/loading.tsx b/frontend/src/app/[locale]/dreps/workflow/profile/loading.tsx new file mode 100644 index 0000000..fd18549 --- /dev/null +++ b/frontend/src/app/[locale]/dreps/workflow/profile/loading.tsx @@ -0,0 +1,14 @@ +import { Background } from '@/components/atoms/Background'; +import React from 'react'; + +const Loading = () => { + return ( +
+ +
+
+
+ ); +}; + +export default Loading; diff --git a/frontend/src/app/[locale]/dreps/workflow/profile/new/page.tsx b/frontend/src/app/[locale]/dreps/workflow/profile/new/page.tsx index 814e038..ec2e317 100644 --- a/frontend/src/app/[locale]/dreps/workflow/profile/new/page.tsx +++ b/frontend/src/app/[locale]/dreps/workflow/profile/new/page.tsx @@ -1,5 +1,4 @@ 'use client'; -import SetupProgressBar from '@/components/atoms/SetupProgressBar'; import NewProfile from '@/components/organisms/NewProfile'; import { useDRepContext } from '@/context/drepContext'; import { useCardano } from '@/context/walletContext'; @@ -8,13 +7,14 @@ import { useRouter } from 'next/navigation'; import React, { useEffect } from 'react'; const page = () => { - const { setIsWalletListModalOpen, setStep1Status, setNewDrepId } = + const { setIsWalletListModalOpen, setStep1Status, setNewDrepId, setHideCloseButtonOnWalletListModal } = useDRepContext(); const { isEnabled, dRepIDBech32 } = useCardano(); const router = useRouter(); useEffect(() => { if (!isEnabled) { setIsWalletListModalOpen(true); + setHideCloseButtonOnWalletListModal(true) } else if (dRepIDBech32) { const checkIfExistingDRep = async () => { try { @@ -35,6 +35,10 @@ const page = () => { }; checkIfExistingDRep(); } + return () => { + setIsWalletListModalOpen(false); + setHideCloseButtonOnWalletListModal(false); + }; }, [isEnabled, dRepIDBech32]); return ; }; diff --git a/frontend/src/app/[locale]/dreps/workflow/profile/success/page.tsx b/frontend/src/app/[locale]/dreps/workflow/profile/success/page.tsx index 6dc1245..cda5717 100644 --- a/frontend/src/app/[locale]/dreps/workflow/profile/success/page.tsx +++ b/frontend/src/app/[locale]/dreps/workflow/profile/success/page.tsx @@ -1,23 +1,102 @@ 'use client'; import Button from '@/components/atoms/Button'; +import TimerCountDown from '@/components/atoms/TimerCountDown'; +import { urls } from '@/constants'; import { useDRepContext } from '@/context/drepContext'; +import { useGlobalNotifications } from '@/context/globalNotificationContext'; import { useCardano } from '@/context/walletContext'; +import { checkTxExists } from '@/services/requests/checkTxExists'; +import { Grow } from '@mui/material'; import Link from 'next/link'; -import React from 'react'; +import { useSearchParams } from 'next/navigation'; + +import React, { useEffect, useState } from 'react'; const page = () => { + const [isTxSynced, setIsTxSynced] = useState(false); const { setStep1Status } = useDRepContext(); const { dRepIDBech32 } = useCardano(); + const { addErrorAlert } = useGlobalNotifications(); + + const searchParams = useSearchParams(); + const params = new URLSearchParams(searchParams); + + const txHash = params.get('hash'); + + useEffect(() => { + if (!txHash) return; + + const checkTxInterval = setInterval(() => { + let isTxAvailable = checkTxExists(txHash); + + console.log(isTxAvailable); + if (!!isTxAvailable) { + setIsTxSynced(true); + clearInterval(checkTxInterval); + } + }, 10 * 1000); + + return () => clearInterval(checkTxInterval); + }, []); + + useEffect(() => { + if (!txHash) return; + + const checkTxInterval = setInterval(async () => { + try { + const isTxAvailable = await checkTxExists(txHash); + console.log(isTxAvailable); + if (isTxAvailable) { + setIsTxSynced(true); + clearInterval(checkTxInterval); + } + } catch (error) { + addErrorAlert('Error checking transaction existence'); + } + }, 10 * 1000); + + return () => clearInterval(checkTxInterval); + }, [txHash]); + return ( -
+

Profile Created Successfully!

-
-

Your DRep Profile has been created successfully

-
-
+ {!isTxSynced && ( +
+

+ Your metadata update is being processed and may take a few minutes + to propagate +

+
+ +
+
+ )} + {!!txHash && ( + +
+

{txHash}

+
+ +

View Tx

+
+
+ + )} + {isTxSynced && ( + +
+

+ New metadata successfully propagated +

+ success svg +
+
+ )} +
- +
); diff --git a/frontend/src/components/atoms/DisplayParsedContent.tsx b/frontend/src/components/atoms/DisplayParsedContent.tsx new file mode 100644 index 0000000..2f55b57 --- /dev/null +++ b/frontend/src/components/atoms/DisplayParsedContent.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import * as marked from 'marked'; + +const DisplayParsedContent = ({ content }) => { + return ( +
+ {content.map((item, index) => { + if (typeof item === 'string') { + return ( +

+ ); + } else if (React.isValidElement(item)) { + return React.cloneElement(item, { key: index }); + } + return ""; + })} +
+ ); +}; + +export default DisplayParsedContent; diff --git a/frontend/src/components/atoms/DotsLoader.tsx b/frontend/src/components/atoms/DotsLoader.tsx new file mode 100644 index 0000000..f465c66 --- /dev/null +++ b/frontend/src/components/atoms/DotsLoader.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +const DotsLoader = ({ size = 35, shadowOffset = 60 }) => { + const keyframesStyle = ` + @keyframes l5 { + 0% {box-shadow: ${shadowOffset}px 0 white, -${shadowOffset}px 0 #4340ff; background: white; } + 33% {box-shadow: ${shadowOffset}px 0 white, -${shadowOffset}px 0 #4340ff; background: #4340ff;} + 66% {box-shadow: ${shadowOffset}px 0 #4340ff, -${shadowOffset}px 0 white; background: #4340ff;} + 100%{box-shadow: ${shadowOffset}px 0 #4340ff, -${shadowOffset}px 0 white; background: white; } + } + `; + + return ( + <> + +
+ + ); +}; + +export default DotsLoader; diff --git a/frontend/src/components/atoms/DrepClaimProfileCard.tsx b/frontend/src/components/atoms/DrepClaimProfileCard.tsx index e03764d..4951956 100644 --- a/frontend/src/components/atoms/DrepClaimProfileCard.tsx +++ b/frontend/src/components/atoms/DrepClaimProfileCard.tsx @@ -12,6 +12,7 @@ import DRepSocialLinks from './DRepSocialLinks'; import DRepAvatarCard from './DRepAvatarCard'; import { useCardano } from '@/context/walletContext'; import { useDRepContext } from '@/context/drepContext'; +import { getDRepMetadata } from '@/services/requests/getDRepMetadata'; const DrepClaimProfileCard = ({ drep, @@ -31,26 +32,29 @@ const DrepClaimProfileCard = ({ const { isLoggedIn } = useDRepContext(); useEffect(() => { const fetchData = async () => { - const metadataUrl = drep?.cexplorerDetails?.metadata_url; + if(!drep) return; + + setIsMetadataLoading(true); + setMetadataError(null); + + const metadataUrl = drep?.metadata_url; setMetadataUrl(metadataUrl); - if (!metadataUrl) return; try { - setIsMetadataLoading(true); - setMetadataError(null); - const response = await getExternalMetadata({ metadataUrl }); - const jsonLdData = response; - const imageUrl = jsonLdData?.body?.image?.contentUrl; - if (imageUrl) { - setImageSrc(imageUrl); - } - if ( - jsonLdData?.body?.references && - Array.isArray(jsonLdData?.body?.references) && - jsonLdData?.body?.references.length > 0 - ) { - setSocialLinks(jsonLdData?.body?.references); + try { + const voterId = drep?.view; + const res = await getDRepMetadata(voterId); + + setMetadataEntries(res?.metadata?.body); + setMetadata(res?.metadata); + } catch (error) { + if (error.response && error.response.status === 404) { + const response = await getExternalMetadata({ metadataUrl }); + const jsonLdData = response; + + setMetadataEntries(jsonLdData?.body); + setMetadata(jsonLdData); + } } - setMetadata(jsonLdData); } catch (error) { setMetadata(null); setMetadataError( @@ -64,8 +68,8 @@ const DrepClaimProfileCard = ({ let status; if (drep?.type !== 'voting_option') { status = isActive( - drep?.cexplorerDetails?.epoch_no, - drep?.cexplorerDetails?.active_until, + drep?.epoch_no, + drep?.active_until, ) ? 'Active' : 'Inactive'; @@ -76,10 +80,19 @@ const DrepClaimProfileCard = ({ fetchData(); }, [drep]); - const liveVotingPower = drep?.delegators.reduce( - (total, delegator) => total + Number(delegator?.votingPower), - 0, - ); + const setMetadataEntries = (metadataBody) => { + const imageUrl = metadataBody?.image?.contentUrl; + if (imageUrl) { + setImageSrc(imageUrl); + } + if ( + metadataBody?.references && + Array.isArray(metadataBody?.references) && + metadataBody?.references.length > 0 + ) { + setSocialLinks(metadataBody?.references); + } + }; return (
@@ -97,33 +110,38 @@ const DrepClaimProfileCard = ({ {drep?.type !== 'scripted' && drep?.type !== 'voting_option' && ( )} + {drep?.retired && drep?.type !== 'voting_option' && ( + + )}
-
+
+
+

Voting power

+

+ {state ? ( + + ) : drep?.voting_power != null ? ( + `₳ ${formattedAda(drep?.voting_power, 2)}` + ) : ( + '-' + )} +

+
-

Active Voting power

+

Live Stake

- ₳{' '} {state ? ( - + + ) : drep?.live_stake != null ? ( + `₳ ${formattedAda(drep?.live_stake, 2)}` ) : ( - formattedAda(drep?.cexplorerDetails?.amount, 2) || 0 + '-' )}

- {/*
*/} - {/*

Live Voting power

*/} - {/*

*/} - {/* ₳{' '}*/} - {/* {state ? (*/} - {/* */} - {/* ) : (*/} - {/* formattedAda(liveVotingPower, 2)*/} - {/* )}*/} - {/*

*/} - {/*
*/}

Total delegation

@@ -131,7 +149,7 @@ const DrepClaimProfileCard = ({ {state ? ( ) : ( - `${drep?.cexplorerDetails?.delegation_vote_count || 0} ${drep?.cexplorerDetails?.delegation_vote_count > 1 ? 'Delegators' : 'Delegator'}` + `${drep?.delegation_vote_count || 0} ${drep?.delegation_vote_count > 1 ? 'Delegators' : 'Delegator'}` )}

@@ -141,11 +159,11 @@ const DrepClaimProfileCard = ({ {state ? ( ) : ( - convertString(drep?.cexplorerDetails?.view || '', true) + convertString(drep?.view || '', true) )}

{ console.log('copied!'); }} @@ -167,8 +185,8 @@ const DrepClaimProfileCard = ({ /> )}
- {(drep?.cexplorerDetails?.view == dRepIDBech32 || - drep?.signature_drepVoterId == dRepIDBech32) && + {(drep?.view == dRepIDBech32 || + drep?.signature_voterId == dRepIDBech32) && isLoggedIn && (
diff --git a/frontend/src/components/atoms/DrepDelegatorCard.tsx b/frontend/src/components/atoms/DrepDelegatorCard.tsx index 01735a5..ac34c05 100644 --- a/frontend/src/components/atoms/DrepDelegatorCard.tsx +++ b/frontend/src/components/atoms/DrepDelegatorCard.tsx @@ -41,9 +41,17 @@ const DrepDelegatorCard = ({ item }: { item: DelegationData }) => {

{formatTotalStake(item?.total_stake, item?.added_power)} ₳

-

- {shortenAddress(item?.stake_address, addressLength)} -

+ +

+ {shortenAddress(item?.stake_address, addressLength)} +

+
{!!item.previous_drep ? ( isPreviousTargetDRep ? ( diff --git a/frontend/src/components/atoms/DrepDelegatorsList.tsx b/frontend/src/components/atoms/DrepDelegatorsList.tsx index eba93c1..162f81c 100644 --- a/frontend/src/components/atoms/DrepDelegatorsList.tsx +++ b/frontend/src/components/atoms/DrepDelegatorsList.tsx @@ -1,78 +1,198 @@ -import { useCardano } from '@/context/walletContext'; import { useScreenDimension } from '@/hooks'; import { convertString, formatAsCurrency, formattedAda, + handleCopyText, lovelaceToAda, } from '@/lib'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import HoverText from './HoverText'; -const ViewProfileAction = () => { +import { useGetDrepDelegators } from '@/hooks/useGetDrepDelegatorsQuery'; +import { Box, IconButton, Skeleton, Tooltip } from '@mui/material'; +import Pagination from '../molecules/Pagination'; +import { useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import ListSort from '../molecules/ListSort'; +import CopyToClipBoardIcon from './svgs/CopyToClipBoardIcon'; +import ArrowDownIcon from './svgs/ArrowDownIcon'; +import ArrowUpIcon from './svgs/ArrowUpIcon'; + +const ViewProfileAction = ({ toStakeKey }: { toStakeKey: string }) => { return ( -
- View Profile -

View Profile

-
+ +
+ View Profile +

View Profile

+
+ ); }; -const DrepDelegatorslist = ({ delegators }: { delegators: any[] }) => { - const { latestEpoch } = useCardano(); + +const DrepDelegatorslist = ({ voterId }: { voterId: string }) => { + const [currentPage, setCurrentPage] = useState(1); + const [sort, setSort] = useState(undefined); + const [order, setOrder] = useState(undefined); const { isMobile, screenWidth } = useScreenDimension(); - return ( -
-

Delegators

- {delegators && delegators.length > 0 ? ( - delegators.map((delegator, index) => { - return ( -
-
-
-
-

- Epoch {delegator?.delegationEpoch}{' '} - {delegator?.delegationEpoch == latestEpoch && '(actual)'} -

-

- {convertString( - delegator.stakeAddress, - isMobile || screenWidth < 1024, - )} -

-
+ const searchParams = useSearchParams(); -
-

Active Stake

-
- -
-
+ useEffect(() => { + setCurrentPage(Number(searchParams.get('page') || 1)); + setSort(searchParams.get('sort') || null); + setOrder(searchParams.get('order') || null); + }, [searchParams]); -
-

Epoch

-

{delegator.delegationEpoch}

-
+ const { Delegators, isDelegatorsLoading } = useGetDrepDelegators( + voterId, + currentPage, + null, + sort, + order, + ); -
-

Actions

-
- -
-
+ return ( +
+
+

Delegators

+
+ +
+
+
+ + + + + + + + + + + {isDelegatorsLoading && ( + + + + )} + {!isDelegatorsLoading && + Delegators?.data?.length > 0 && + Delegators?.data.map((delegator) => ( + + + + + + + ))} + +
+ Stake Address + +
+ Voting Power + {sort === 'power' && + (order === 'desc' ? ( + + ) : ( + order === 'asc' && ( + + ) + ))} +
+
+
+ Epoch + {sort === 'epoch' && + (order === 'desc' ? ( + + ) : ( + order === 'asc' && ( + + ) + ))}
-
- - - ); - }) - ) : ( -

No delegators to show

- )} +
+ Actions +
+ {Array.from({ length: 24 }).map((_, index) => ( + + ))} +
+
+ + {convertString( + delegator?.stakeAddress, + isMobile || screenWidth < 1024, + )} + +
+ + + handleCopyText(delegator?.stakeAddress) + } + > + + + +
+
+
+ + + {' '} +

{delegator?.delegationEpoch}

+
+ +
+
+ {!isDelegatorsLoading && + Delegators?.data && + Delegators?.data.length > 0 && ( + + + + )}
); }; diff --git a/frontend/src/components/atoms/DrepGovActionSubmitCard.tsx b/frontend/src/components/atoms/DrepGovActionSubmitCard.tsx index a0aa4a8..13bc0a2 100644 --- a/frontend/src/components/atoms/DrepGovActionSubmitCard.tsx +++ b/frontend/src/components/atoms/DrepGovActionSubmitCard.tsx @@ -7,7 +7,7 @@ const SubmittedChip = ({ date }: { date: string }) => { return (
-

Submitted a Governance Action

+

Governance Action

{date @@ -71,7 +71,7 @@ const DrepGovActionSubmitCard = ({ let style: any = { borderColor: 'border-[#D19471]', - bgColor: 'bg-[#D19471]', + bgcolor: 'bg-[#D19471]', imgSrc: '/svgs/exchange.svg', actionName: '', }; @@ -80,7 +80,7 @@ const DrepGovActionSubmitCard = ({ case actionType.includes('protocolparameter'): style = { borderColor: 'border-[#D19471]', - bgColor: 'bg-[#D19471]', + bgcolor: 'bg-[#D19471]', imgSrc: '/svgs/exchange.svg', actionName: 'Protocol Parameter Changes', }; @@ -88,7 +88,7 @@ const DrepGovActionSubmitCard = ({ case actionType.includes('info'): style = { borderColor: 'border-[#BB7AEE]', - bgColor: 'bg-[#BB7AEE]', + bgcolor: 'bg-[#BB7AEE]', imgSrc: '/svgs/info-circle.svg', actionName: 'Info', }; @@ -96,7 +96,7 @@ const DrepGovActionSubmitCard = ({ case actionType.includes('hardfork'): style = { borderColor: 'border-[#A3D96C]', - bgColor: 'bg-[#A3D96C]', + bgcolor: 'bg-[#A3D96C]', imgSrc: '/svgs/status-change.svg', actionName: 'Hard-Fork Initiation', }; @@ -104,7 +104,7 @@ const DrepGovActionSubmitCard = ({ case actionType.includes('newconstitution'): style = { borderColor: 'border-[#D96CAE]', - bgColor: 'bg-[#D96CAE]', + bgcolor: 'bg-[#D96CAE]', imgSrc: '/svgs/notebook.svg', actionName: 'New Constitution or Guardrails Script', }; @@ -112,7 +112,7 @@ const DrepGovActionSubmitCard = ({ case actionType.includes('updatecommittee'): style = { borderColor: 'border-[#6FDF8E]', - bgColor: 'bg-[#6FDF8E]', + bgcolor: 'bg-[#6FDF8E]', imgSrc: '/svgs/users-group.svg', actionName: 'Update committee and/or threshold and/or terms', }; @@ -120,7 +120,7 @@ const DrepGovActionSubmitCard = ({ default: style = { borderColor: 'border-[#6FDF8E]', - bgColor: 'bg-[#6FDF8E]', + bgcolor: 'bg-[#6FDF8E]', imgSrc: '/svgs/users-group.svg', actionName: capitalizeFirstLetter(actionType), }; @@ -138,17 +138,17 @@ const DrepGovActionSubmitCard = ({ return (


-
+

{style.actionName}

- +
); }; diff --git a/frontend/src/components/atoms/DrepInfoCard.tsx b/frontend/src/components/atoms/DrepInfoCard.tsx index f5b0f7e..958332a 100644 --- a/frontend/src/components/atoms/DrepInfoCard.tsx +++ b/frontend/src/components/atoms/DrepInfoCard.tsx @@ -2,6 +2,7 @@ import React from 'react'; import Button from '@/components/atoms/Button'; import Link from 'next/link'; + interface DrepInfoCardProps { img: string; title: string; @@ -11,6 +12,8 @@ interface DrepInfoCardProps { href: string; target?: '_blank' | '_self'; }; + clicked?: () => void; + disabled?: boolean } const DrepInfoCard = ({ @@ -18,6 +21,8 @@ const DrepInfoCard = ({ title, description, action = null, + clicked, + disabled }: DrepInfoCardProps) => { return (
@@ -27,7 +32,7 @@ const DrepInfoCard = ({ {!!action && (
-
-
+
+
+ Voting power +

+ {state ? ( + + ) : drep?.voting_power != null ? ( + `₳ ${formattedAda(drep?.voting_power, 2)}` + ) : ( + '-' + )} +

+
- Active Voting power + Live Stake

- ₳{' '} {state ? ( + ) : drep?.live_stake != null ? ( + `₳ ${formattedAda(drep?.live_stake, 2)}` ) : ( - formattedAda(drep?.cexplorerDetails?.amount, 2) || 0 + '-' )}

- {/*
*/} - {/* Live Voting power*/} - {/*

*/} - {/* ₳{' '}*/} - {/* {state ? (*/} - {/* */} - {/* ) : (*/} - {/* formattedAda(liveVotingPower, 2)*/} - {/* )}*/} - {/*

*/} - {/*
*/}
Total delegation @@ -282,7 +306,7 @@ const DrepProfileCard = ({ drep, state }: { drep: any; state: boolean }) => { {state ? ( ) : ( - `${drep?.cexplorerDetails?.delegation_vote_count || 0} ${drep?.cexplorerDetails?.delegation_vote_count > 1 ? 'Delegators' : 'Delegator'}` + `${drep?.delegation_vote_count || 0} ${drep?.delegation_vote_count > 1 ? 'Delegators' : 'Delegator'}` )}

@@ -292,12 +316,12 @@ const DrepProfileCard = ({ drep, state }: { drep: any; state: boolean }) => { {state ? ( ) : ( - drep?.cexplorerDetails?.view && - convertString(drep?.cexplorerDetails?.view, true) + drep?.view && + convertString(drep?.view, true) )}

{ console.log('copied!'); }} @@ -342,12 +366,12 @@ const DrepProfileCard = ({ drep, state }: { drep: any; state: boolean }) => { }} /> )} - {(drep?.cexplorerDetails?.view == dRepIDBech32 || - drep?.signature_drepVoterId == dRepIDBech32) && + {(drep?.view == dRepIDBech32 || + drep?.signature_voterId == dRepIDBech32) && renderUnsavedChanges()}
- {(drep?.cexplorerDetails?.view == dRepIDBech32 || - drep?.signature_drepVoterId == dRepIDBech32) && ( + {(drep?.view == dRepIDBech32 || + drep?.signature_voterId == dRepIDBech32) && (
diff --git a/frontend/src/components/atoms/DrepProfileWalletStats.tsx b/frontend/src/components/atoms/DrepProfileWalletStats.tsx deleted file mode 100644 index 885e7ad..0000000 --- a/frontend/src/components/atoms/DrepProfileWalletStats.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import StatusChip from './StatusChip'; - -const DrepProfileWalletStats = () => { - return ( -
-
-

Current wallet balance for wallet:

-

4rfuysad8828iswisikkskad8u8276pñsñsakdja

-

₳ 2,263,47

-
-
-

Status

- -
-
- ); -}; - -export default DrepProfileWalletStats; \ No newline at end of file diff --git a/frontend/src/components/atoms/DrepVoteTimelineCard.tsx b/frontend/src/components/atoms/DrepVoteTimelineCard.tsx index fe6fa3d..8ff3deb 100644 --- a/frontend/src/components/atoms/DrepVoteTimelineCard.tsx +++ b/frontend/src/components/atoms/DrepVoteTimelineCard.tsx @@ -1,36 +1,120 @@ import { urls } from '@/constants'; import { convertString } from '@/lib'; import Link from 'next/link'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { CopyToClipboard } from 'react-copy-to-clipboard'; -const VoteStatusChip = ({ date }: { date: string }) => { +import { Box } from '@mui/material'; +import axios from 'axios'; + +const VoteStatusChip = ({ date, vote }: { date: string; vote: string }) => { + const [bgcolor, setBgColor] = useState('complementary-100'); + + useEffect(() => { + if (vote === 'No') setBgColor('bg-red-100'); + else if (vote === 'Yes') setBgColor('bg-green-100'); + else if (vote === 'Abstain') setBgColor('bg-complementary-100'); + }, []); return (
-
- -

Voted

+
+ Vote icon +

{vote}

-

{new Date(date).toLocaleDateString('en-GB')}

+

{new Date(date).toLocaleDateString('en-GB')}

); }; const DrepVoteTimelineCard = ({ item }: { item: any }) => { + const [govActionName, setGovActionName] = useState(null); + + useEffect(() => { + const title = item?.metadata?.body?.title; + if (title) { + setGovActionName(title); + return; + } + axios + .get(item.url) + .then((response) => { + setGovActionName(response?.data?.body?.title); + }) + .catch((error) => { + console.log(error); + }); + }, [govActionName]); + + let actionDetais: { imgSrc: string; actionName: string } = { + imgSrc: '/svgs/exchange.svg', + actionName: '', + }; + + switch (true) { + case item?.description?.tag.includes('ParameterChange'): + actionDetais = { + imgSrc: '/svgs/exchange.svg', + actionName: 'Protocol Parameter Changes', + }; + break; + case item?.description?.tag.includes('InfoAction'): + actionDetais = { + imgSrc: '/svgs/info-circle.svg', + actionName: 'Info', + }; + break; + case item?.description?.tag.includes('HardForkInitiation'): + actionDetais = { + imgSrc: '/svgs/status-change.svg', + actionName: 'Hard-Fork Initiation', + }; + break; + case item?.description?.tag.includes('newconstitution'): + actionDetais = { + imgSrc: '/svgs/notebook.svg', + actionName: 'New Constitution or Guardrails Script', + }; + break; + case item?.description?.tag.includes('updatecommittee'): + actionDetais = { + imgSrc: '/svgs/users-group.svg', + actionName: 'Update committee and/or threshold and/or terms', + }; + break; + } + return ( -
- +
-
-

- For {item?.description?.tag || null} -

-

Governance Action ID:

-
-

- {convertString(item?.gov_action_proposal_id + '#0', true) || null} + + +

+ {govActionName ? govActionName : '-'}

+ + + {actionDetais.actionName !== '' && ( + + + {`${actionDetais.actionName} +

{actionDetais.actionName}

+
+
+ )} + + +

Action ID:

+

{convertString(item?.gov_action_proposal_id, true) || null}

{ @@ -40,16 +124,28 @@ const DrepVoteTimelineCard = ({ item }: { item: any }) => { > copy -
-
- - View Governance Action - -
+ + + +

View Governance Action:

+ + + Cardano Govtool + + + Ada Status + + +
+ ); }; diff --git a/frontend/src/components/atoms/Footer.tsx b/frontend/src/components/atoms/Footer.tsx index f436e1e..2d4d105 100644 --- a/frontend/src/components/atoms/Footer.tsx +++ b/frontend/src/components/atoms/Footer.tsx @@ -9,8 +9,8 @@ const Footer = () => {
Sancho logo diff --git a/frontend/src/components/atoms/Header.tsx b/frontend/src/components/atoms/Header.tsx index 68eda1a..b3f0ed3 100644 --- a/frontend/src/components/atoms/Header.tsx +++ b/frontend/src/components/atoms/Header.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useCardano } from '@/context/walletContext'; import WalletConnectButton from '@/components/molecules/WalletConnectButton'; import { WalletInfoCard } from '@/components/molecules'; @@ -9,15 +9,36 @@ import { useScreenDimension } from '@/hooks'; import VoltaireMenu from '../molecules/VoltaireMenu'; import DRepMenu from '../molecules/DRepMenu'; import { SliderMenu } from '../organisms/SliderMenu'; -import NotificationDrawer from "@/components/molecules/NotificationDrawer"; +import NotificationDrawer from '@/components/molecules/NotificationDrawer'; +import { CONFIGURED_NETWORK_NAME } from '@/constants'; const Header = () => { const { isEnabled } = useCardano(); + const [networkName, setNetworkName] = useState(''); const { currentLocale } = useDRepContext(); const { isMobile } = useScreenDimension(); const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false); const pathname = usePathname(); const [activeLink, setActiveLink] = useState(null); + useEffect(() => { + setNetworkName(CONFIGURED_NETWORK_NAME); + }, [CONFIGURED_NETWORK_NAME]); + const renderLogoOnNetworkChange = useCallback(() => { + if (networkName) { + switch (networkName) { + case 'sanchonet': + return '/img/logos/sancho-black.png'; + case 'mainnet': + return '/img/logos/mainnet-black.png'; + case 'preview': + return '/img/logos/preview-black.png'; + default: + return '/img/logos/sancho-black.png'; + } + } + // Default to voltaire logo + return '/img/logos/voltaire-black.png'; + }, [networkName]); useEffect(() => { // Setting the active link based on the current pathname setActiveLink(pathname); @@ -28,8 +49,8 @@ const Header = () => {
Sancho logo @@ -59,9 +80,7 @@ const Header = () => { )}
- {!isMobile && ( - - )} + {!isMobile && } {isMobile && (
{ name={name} control={control} render={({ field }) => { - const parts = processNoteContent(field.value); + const parts = processContent(field.value); return (
diff --git a/frontend/src/components/atoms/MetadataEditor.tsx b/frontend/src/components/atoms/MetadataEditor.tsx index 002607d..0a95615 100644 --- a/frontend/src/components/atoms/MetadataEditor.tsx +++ b/frontend/src/components/atoms/MetadataEditor.tsx @@ -312,7 +312,7 @@ const MetadataEditor = ({ handleClick={handleAddReference} aria-valuetext="add reference" className="flex w-full items-center gap-3 rounded-xl border border-dotted shadow-md" - bgColor="transparent" + bgcolor="transparent" variant="outlined" > add diff --git a/frontend/src/components/atoms/PageBanner.tsx b/frontend/src/components/atoms/PageBanner.tsx new file mode 100644 index 0000000..ea90528 --- /dev/null +++ b/frontend/src/components/atoms/PageBanner.tsx @@ -0,0 +1,80 @@ +'use client'; +import { useDRepContext } from '@/context/drepContext'; +import { useGetNodeStatusQuery } from '@/hooks/useGetNodeStatusQuery'; +import { Box, Slide, Typography } from '@mui/material'; +import { usePathname } from 'next/navigation'; +import React, { useEffect, useState } from 'react'; + +const PageBanner = () => { + const [disablePolling, setDisablePolling] = useState(false); + const { NodeStatus, isLoading, isFetching, isError, isFetchedAfterMount } = + useGetNodeStatusQuery({ disablePolling }); + const pathname = usePathname(); + const [showBanner, setShowBanner] = useState(false); + const [nodeStats, setNodeStats] = useState(null); + const {currentLocale}=useDRepContext(); + const dbNonDependentPages = [ + `/${currentLocale}`, + `/${currentLocale}/dreps`, + `/${currentLocale}/dreps/workflow/profile/new`, + `/${currentLocale}/dreps/workflow/profile/update`, + ]; + useEffect(() => { + if (NodeStatus) { + setNodeStats(NodeStatus); + if (NodeStatus?.behindBy <= 30 && !isError) { + setDisablePolling(true); + } + } + }, [NodeStatus, isLoading, isFetching]); + useEffect(() => { + setShowBanner(renderCondition()); + }, [pathname, isFetchedAfterMount, isError, nodeStats]); + const renderCondition = () => { + return ( + isFetchedAfterMount && + !dbNonDependentPages.some((page) => pathname == page) && + (isError || (nodeStats && nodeStats?.behindBy >= 30)) + ); + }; + + const renderStatus = () => { + if (!nodeStats && !isError) return '-'; + if (nodeStats && !isError) { + return nodeStats?.behindBy >= 30 ? 'Lagging' : 'Following'; + } + if (isError) return 'Offline'; + }; + if (!showBanner) return null; + + return ( + + +
+ Epoch: + {nodeStats?.epoch_no || '-'} +
+
+ Slot: + {nodeStats?.epoch_slot_no || '-'} +
+
+ Status: + + {renderStatus()} + +
+
+ + {nodeStats && + `Last updated ${new Date(nodeStats?.time).toLocaleString('en-US')}`} + +
+
+
+ ); +}; + +export default PageBanner; diff --git a/frontend/src/components/atoms/PostSubmitArea.tsx b/frontend/src/components/atoms/PostSubmitArea.tsx index da9ee57..8bb10a4 100644 --- a/frontend/src/components/atoms/PostSubmitArea.tsx +++ b/frontend/src/components/atoms/PostSubmitArea.tsx @@ -1,20 +1,56 @@ -import React from 'react'; +'use client'; +import React, { useEffect, useState } from 'react'; import Button from './Button'; import { useCardano } from '@/context/walletContext'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; +import DotsLoader from './DotsLoader'; + +type PostSubmitAreaProps = { + isUpdating?: boolean; + showViewTimeline?: boolean; + isLoading?: boolean; + noteCreatedAt?: string; +}; + const PostSubmitArea = ({ isUpdating = false, showViewTimeline = true, -}: { - isUpdating?: boolean; - showViewTimeline?: boolean; -}) => { + isLoading, + noteCreatedAt, +}: PostSubmitAreaProps) => { + const [bgColor, setBgColor] = useState('transparent'); + const TEN_MINUTES = 10 * 60 * 1000; + const router = useRouter(); const { isEnabled, dRepIDBech32 } = useCardano(); + + const isRecentlyCreated = new Date().getTime() - new Date(noteCreatedAt).getTime() <= TEN_MINUTES; + + useEffect(() => { + if (isRecentlyCreated) { + let toggle = false; + const interval = setInterval(() => { + toggle = !toggle; + setBgColor(toggle ? '#f5d0fe' : 'transparent'); + }, 500); + + const stopTimer = setTimeout(() => { + clearInterval(interval); + setBgColor('transparent'); + }, 2000); + + return () => { + clearInterval(interval); + clearTimeout(stopTimer); + }; + } + }, [isRecentlyCreated]); + const handleCancel = () => { - router.back(); // Redirects to the previous page + router.back(); }; + return (
diff --git a/frontend/src/components/atoms/PostTextareaInput.tsx b/frontend/src/components/atoms/PostTextareaInput.tsx index e7cae59..fa0f196 100644 --- a/frontend/src/components/atoms/PostTextareaInput.tsx +++ b/frontend/src/components/atoms/PostTextareaInput.tsx @@ -149,7 +149,7 @@ const Editor: FC = ({ onChange={(content) => onChange(content)} ref={editorRef} markdown={markdown} - contentEditableClassName="prose max-w-full" + contentEditableClassName="prose w-full min-h-40" plugins={[ headingsPlugin(), listsPlugin(), diff --git a/frontend/src/components/atoms/ProfileSubmitArea.tsx b/frontend/src/components/atoms/ProfileSubmitArea.tsx index 1ed13e6..b2d2c29 100644 --- a/frontend/src/components/atoms/ProfileSubmitArea.tsx +++ b/frontend/src/components/atoms/ProfileSubmitArea.tsx @@ -63,7 +63,7 @@ const ProfileSubmitArea = ({ isUpdate, isDisabled=false }: ProfileSubmitAreaProp @@ -196,16 +246,11 @@ const Comment: React.FC = ({ )} className="flex flex-col gap-1 px-5 py-1" > - -
+ +
diff --git a/frontend/src/components/dreps/notes/NotesPageHeader.tsx b/frontend/src/components/dreps/notes/NotesPageHeader.tsx index 5fe5824..ff9bfab 100644 --- a/frontend/src/components/dreps/notes/NotesPageHeader.tsx +++ b/frontend/src/components/dreps/notes/NotesPageHeader.tsx @@ -17,7 +17,7 @@ function NotesPageHeader() { size="extraLarge" variant="outlined" width={!isMobile && '180px'} - bgColor="transparent" + bgcolor="transparent" color="primary" handleClick={handleNavigate} > diff --git a/frontend/src/components/dreps/notes/SingleNote.tsx b/frontend/src/components/dreps/notes/SingleNote.tsx index 7d7305a..85203c0 100644 --- a/frontend/src/components/dreps/notes/SingleNote.tsx +++ b/frontend/src/components/dreps/notes/SingleNote.tsx @@ -5,14 +5,16 @@ import { postAddReaction } from '@/services/requests/postAddReaction'; import { postRemoveReaction } from '@/services/requests/postRemoveReaction'; import SingleNoteResponses from './SingleNoteResponses'; import { useDRepContext } from '@/context/drepContext'; -import PostTextareaInput from '@/components/atoms/PostTextareaInput'; import { z } from 'zod'; import { SubmitHandler, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { postAddComment } from '@/services/requests/postAddComment'; -import { useGetNotesQuery } from '@/hooks/useGetNotesQuery'; -import { processNoteContent } from '@/lib/noteContentProcessor/processNoteContent'; import * as marked from 'marked'; +import { useGetSingleNoteQuery } from '@/hooks/useGetSingleNoteQuery'; +import { processContent } from '@/lib/ContentProcessor/processContent'; +import MarkdownEditor from '@/components/atoms/MarkdownEditor'; +import DisplayParsedContent from '@/components/atoms/DisplayParsedContent'; + const SingleNote = ({ note, currentVoter, @@ -25,7 +27,7 @@ const SingleNote = ({ isLoggedIn: boolean; }) => { const { setIsWalletListModalOpen, setLoginModalOpen } = useDRepContext(); - const { refetch } = useGetNotesQuery(); + // Initial reaction state from the note prop const initialReactions = { like: 0, @@ -34,15 +36,24 @@ const SingleNote = ({ rocket: 0, }; // Count initial reactions - + const [performReload, setPerformReload] = useState(false); + const [currentComments, setCurrentComments] = useState(note?.comments || []); const [reactions, setReactions] = useState(initialReactions); const [userReactions, setUserReactions] = useState({}); const [isCommenting, setIsCommenting] = useState(false); const [showResponses, setShowResponses] = useState(false); + const [latestComment, setLatestComment] = useState(null); + const { Note, isNoteLoading } = useGetSingleNoteQuery( + note?.note_id, + performReload, + ); + const FormSchema = z.object({ - comment: z.string(), + comment: z.string().min(1, 'Comment is required'), }); + type InputType = z.infer; + const { handleSubmit, reset, @@ -62,14 +73,29 @@ const SingleNote = ({ } return acc; }, {}); - const updatedReactions = note.reactions.reduce((acc, reaction) => { - acc[reaction.type] = (acc[reaction.type] || 0) + 1; - return acc; - }, initialReactions); + const updatedReactionsCount = { + like: note.reactions.filter((reaction) => reaction.type === 'like') + .length, + thumbsup: note.reactions.filter( + (reaction) => reaction.type === 'thumbsup', + ).length, + thumbsdown: note.reactions.filter( + (reaction) => reaction.type === 'thumbsdown', + ).length, + rocket: note.reactions.filter((reaction) => reaction.type === 'rocket') + .length, + }; setUserReactions(updatedUserReactions); - setReactions(updatedReactions); + setReactions(updatedReactionsCount); }, [currentVoter, note.reactions]); + + useEffect(() => { + if (Note && !isNoteLoading) { + setCurrentComments(Note.comments); + } + }, [Note, isNoteLoading]); + const startCommenting = () => { if (!isEnabled) { setIsWalletListModalOpen(true); @@ -81,6 +107,24 @@ const SingleNote = ({ } setIsCommenting(true); }; + + const countTotalComments = (comments) => { + // If there are no comments, return 0 + if (!comments || comments.length === 0) return 0; + + let totalCount = comments.length; // Counting top-level comments + + // Loop through each comment + comments.forEach((comment) => { + // Recursively count the responses (nested comments) + if (comment.comments && comment.comments.length > 0) { + totalCount += countTotalComments(comment.comments); // Add nested comment count + } + }); + + return totalCount; + }; + const handleReaction = async (type) => { //to prevent orphan reaction till say wallet is done connecting if (!isEnabled) { @@ -94,7 +138,7 @@ const SingleNote = ({ if (userReactions[type]) { // User has already reacted, so remove the reaction try { - const res = await postRemoveReaction({ + await postRemoveReaction({ type, parentId: note.note_id, parentEntity: 'note', @@ -118,7 +162,7 @@ const SingleNote = ({ } else { // User has not reacted, so add the reaction try { - const res = await postAddReaction({ + await postAddReaction({ type, parentId: note.note_id, parentEntity: 'note', @@ -142,26 +186,37 @@ const SingleNote = ({ } }; + const handleRefetch = () => { + setPerformReload(true); + setTimeout(() => { + setPerformReload(false); + }, 100); + }; + const saveComment: SubmitHandler = async (data) => { try { const { comment } = data; const res = await postAddComment({ + rootEntity: 'note', + rootEntityId: note.note_id, parentId: note.note_id, parentEntity: 'note', comment, voter: currentVoter, }); + setLatestComment(res.id as number); reset({ comment: '' }); setIsCommenting(false); - refetch(); + handleRefetch(); + setShowResponses(true); } catch (error) { console.log(error); } }; const noteContent = useMemo( - () => processNoteContent(note.note_note_content), - [note.note_note_content], + () => processContent(note.note_content), + [note.note_content], ); const reactionIcons = { @@ -181,21 +236,9 @@ const SingleNote = ({
- {note.note_note_title} + {note.note_title} - {!!noteContent && - noteContent.map((item, index) => { - if (typeof item === 'string') { - return ( - - ); - } else if (React.isValidElement(item)) { - return React.cloneElement(item, { key: index }); - } - })} + {!!noteContent && } {!!note.note_note_tag && (

Tags

@@ -213,7 +256,7 @@ const SingleNote = ({
)}
-
+

Submission Date:

{new Date(note.note_createdAt).toDateString()} @@ -227,10 +270,16 @@ const SingleNote = ({ )}

@@ -262,16 +311,11 @@ const SingleNote = ({ onSubmit={handleSubmit(saveComment, (error) => console.log(error))} className="flex flex-col gap-1 px-5 py-1" > - +
- - )} - - - - ); -} diff --git a/frontend/src/components/molecules/DRepsTable.tsx b/frontend/src/components/molecules/DRepsTable.tsx index e82d771..f75dfea 100644 --- a/frontend/src/components/molecules/DRepsTable.tsx +++ b/frontend/src/components/molecules/DRepsTable.tsx @@ -2,121 +2,92 @@ import React from 'react'; import StatusChip from '../atoms/StatusChip'; -import {useGetDRepsQuery} from '@/hooks/useGetDRepsQuery'; +import { useGetDRepsQuery } from '@/hooks/useGetDRepsQuery'; import { - convertString, - formatAsCurrency, - handleCopyText, - shortNumber, + convertString, + formatAsCurrency, + handleCopyText, + percentageDifference, + shortNumber, } from '@/lib'; -import {useScreenDimension} from '@/hooks'; -import {Box, IconButton, Skeleton, Tooltip} from '@mui/material'; +import { useScreenDimension } from '@/hooks'; +import { Box, IconButton, Skeleton, Tooltip } from '@mui/material'; import Button from '../atoms/Button'; import Link from 'next/link'; import HoverText from '../atoms/HoverText'; import Pagination from './Pagination'; -import {usePathname, useRouter, useSearchParams} from 'next/navigation'; import CopyToClipBoard from '../atoms/svgs/CopyToClipBoardIcon'; import ArrowDownIcon from '../atoms/svgs/ArrowDownIcon'; import ArrowUpIcon from '../atoms/svgs/ArrowUpIcon'; import DatabaseNullIcon from '../atoms/svgs/DatabaseNullIcon'; import CrossIcon from '../atoms/svgs/CrossIcon'; +import Typography from '@mui/material/Typography'; type DRepsTableProps = { - query?: string; - page?: number; - sort?: string; - order?: string; - onChainStatus?: string; - campaignStatus?: string; - type?: string; + query?: string; + page?: number; + sort?: string; + order?: string; + onChainStatus?: string; + campaignStatus?: string; + includeRetired?: string; + type?: string; }; export function isActive(latest_epoch_no: number, active_until: number) { - if ( - typeof latest_epoch_no !== 'number' || - typeof active_until !== 'number' - ) { - return false; - } - return active_until > latest_epoch_no; + if (typeof latest_epoch_no !== 'number' || typeof active_until !== 'number') { + return false; + } + return active_until > latest_epoch_no; } const DRepsTable = ({ - query, - page, - sort, - order, - onChainStatus, - campaignStatus, - type, - }: DRepsTableProps) => { - const searchParams = useSearchParams(); - const pathName = usePathname(); - const {replace} = useRouter(); - const {isMobile} = useScreenDimension(); + query, + page, + sort, + order, + onChainStatus, + campaignStatus, + includeRetired, + type, +}: DRepsTableProps) => { + const { isMobile } = useScreenDimension(); - const {DReps, isDRepsLoading, isError} = useGetDRepsQuery( - query, - page, - sort, - order, - onChainStatus, - campaignStatus, - type, - ); + const { DReps, isDRepsLoading, isError } = useGetDRepsQuery( + query, + page, + sort, + order, + onChainStatus, + campaignStatus, + includeRetired, + type, + ); - // Handle table pagination - function moveToPage(targetPage: number) { - const params = new URLSearchParams(searchParams); - - if (page !== targetPage) { - params.set('page', targetPage.toString()); - } - replace(`${pathName}?${params.toString()}`); - window.scrollTo({top: 0, behavior: 'smooth'}); - } - - function moveToFirstPage(firstPage: number) { - moveToPage(firstPage); - } - - function moveToLastPage(lastPage: number) { - moveToPage(lastPage); - } - - function moveToPreviousPage(previousPage: number) { - moveToPage(previousPage); - } - - function moveToNextPage(nextPage: number) { - moveToPage(nextPage); - } - - return ( -
- - - - - {/* */} - - {/* + )} + +
DRepDrep Id -
- Voting Power - {sort === 'voting_power' && - (order === 'desc' ? ( - - ) : ( - order === 'asc' && ( - - ) - ))} -
-
+ return ( +
+ + + + + {/* */} + + */} - - - - - {isDRepsLoading ? ( - - - - ) : DReps?.data && DReps?.data.length > 0 ? ( - DReps.data.map((drep) => ( - + + + + + {isDRepsLoading ? ( + + + + ) : DReps?.data && DReps?.data.length > 0 ? ( + DReps.data.map((drep) => ( + + + + + + - + + {drep.type === 'scripted' && ( + + )} + + + - {/* */} + - - - )) - ) : ( - - - + + + + + )) + ) : ( + + -
DRepDrep Id +
+ Voting Power + {sort === 'voting_power' && + (order === 'desc' ? ( + + ) : ( + order === 'asc' && ( + + ) + ))} +
+
Live Stake - {sort === 'live_power' && + {sort === 'live_stake' && (order === 'desc' ? ( ) : ( @@ -125,214 +96,234 @@ const DRepsTable = ({ ) ))}
-
-
- Delegators - {sort === 'delegators' && - (order === 'desc' ? ( - - ) : ( - order === 'asc' && ( - - ) - ))} -
-
- {Array.from({length: 24}).map((_, index) => ( - - ))} -
+
+ Delegators + {sort === 'delegators' && + (order === 'desc' ? ( + + ) : ( + order === 'asc' && ( + + ) + ))} +
+
+ {Array.from({ length: 24 }).map((_, index) => ( + + ))} +
+ + {drep?.type === 'voting_option' || + drep?.type === 'scripted' ? ( + +

+ {drep?.type === 'scripted' + ? '' + : drep?.view.replace('drep_', '')} +

+ + ) : drep?.drep_id ? ( + + + + ) : ( +
+ -
- - {drep?.type === 'voting_option' || - drep?.type === 'scripted' ? ( - -

- {drep?.type === 'scripted' - ? '' - : drep?.view.replace('drep_', '')} -

- - ) : drep?.drep_id ? ( - - - - ) : ( -
- - - -
- )} - - - - handleCopyText(drep.view)} - > - - - + + + + )} - -

- {convertString(drep.view, isMobile)} -

- -
+ {drep?.type !== 'voting_option' && ( + + + handleCopyText(drep.view)} + > + + + - - {drep.given_name !== null && - - - {drep.given_name} - - - } + +

+ {convertString(drep.view, isMobile)} +

+ +
+ )} - - - -
+ + {drep.given_name !== null && ( + + + + {drep.given_name} + + + + )} - - {drep.type === 'scripted' && ( - - )} - - -
- {drep.voting_power !== null ? ( - - ) : ( -

-

- )} -
- {drep.live_power !== null ? ( + + {drep.voting_power !== null ? ( ) : (

-

)} -
-

{drep.delegation_vote_count}

-
- {!isError && ( -
-
- - - No DReps to show for now... - -
-
- )} - {isError && ( -
-
-
-
-
-
- -
-
-

- Opps!!! -

-

- An error occurred while fetching the data. - Please refresh the page or try again later -

-
-
-
-
-
-
- )} -
+ {drep.live_stake !== null ? ( + + + + {shortNumber(drep.live_stake, 2)} + + + {drep.voting_power > 0.0 && ( + 0.0 ? 'text-success' : percentageDifference(drep.live_stake, drep.voting_power) < 0.0 ? 'text-extra_red' : ''}`} + > + {new Intl.NumberFormat('en-US', { + signDisplay: 'exceptZero', + }).format( + percentageDifference( + drep.live_stake, + drep.voting_power, + ), + )} + % + + )} + + ) : ( +

-

+ )} +
+

{drep.delegation_vote_count}

+
+ {!isError && ( +
+
+ + + No DReps to show for now... + +
+
)} -
- {!isDRepsLoading && DReps?.data && DReps?.data.length > 0 && ( - - - - )} -
- ); + {isError && ( +
+
+
+
+
+
+ +
+
+

+ Opps!!! +

+

+ An error occurred while fetching the data. + Please refresh the page or try again later +

+
+
+
+
+
+
+ )} + +
+ {!isDRepsLoading && DReps?.data && DReps?.data.length > 0 && ( + + + + )} +
+ ); }; export default DRepsTable; diff --git a/frontend/src/components/molecules/DelegatedTo.tsx b/frontend/src/components/molecules/DelegatedTo.tsx index 6e06c7a..4e7cd27 100644 --- a/frontend/src/components/molecules/DelegatedTo.tsx +++ b/frontend/src/components/molecules/DelegatedTo.tsx @@ -13,7 +13,7 @@ type DelegatedToProps = { }; export const DelegatedTo = ({ className }: DelegatedToProps) => { - const { stakeKey } = useCardano(); + const { stakeKey, stakeKeyBech32 } = useCardano(); const { currentDelegation } = useGetAdaHolderCurrentDelegationQuery(stakeKey); const { DRep } = useGetSingleDRepViaVoterIdQuery( currentDelegation?.drep_view, @@ -73,7 +73,7 @@ export const DelegatedTo = ({ className }: DelegatedToProps) => { fontWeight={600} className="overflow-hidden text-gray-300" > - ₳ {formattedAda(DRep?.cexplorerDetails?.amount, 2)} + ₳ {formattedAda(DRep?.voting_power, 2)} @@ -98,10 +98,11 @@ export const DelegatedTo = ({ className }: DelegatedToProps) => { )} - {currentDelegation && ( + - )} + ); }; diff --git a/frontend/src/components/molecules/DrepInfoCardRow.tsx b/frontend/src/components/molecules/DrepInfoCardRow.tsx index f6c9b2f..81e793f 100644 --- a/frontend/src/components/molecules/DrepInfoCardRow.tsx +++ b/frontend/src/components/molecules/DrepInfoCardRow.tsx @@ -1,18 +1,30 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import DrepInfoCard from '../atoms/DrepInfoCard'; import { urls } from '@/constants'; +import { useDRepContext } from '@/context/drepContext'; +import { useCardano } from '@/context/walletContext'; +import { useGetAdaHolderCurrentDelegationQuery } from '@/hooks/useGetAdaHolderCurrentDelegationQuery'; +import { useGetSingleDRepViaVoterIdQuery } from '@/hooks/useGetSingleDRepViaVoterIdQuery'; const DrepInfoCardRow = () => { + const {setIsWalletListModalOpen} = useDRepContext() + const {isEnabled, stakeKey} = useCardano(); + const currentDelegation = useGetAdaHolderCurrentDelegationQuery(stakeKey) + const { DRep } = useGetSingleDRepViaVoterIdQuery( + currentDelegation?.currentDelegation?.drep_view + ); + return (
{setIsWalletListModalOpen(true)}} description={ 'Like stake pools, DRep registers their intention on chain via DRep Certificates.' } @@ -22,9 +34,10 @@ const DrepInfoCardRow = () => { img={'/img/delegImg.png'} title={'Delegation'} action={{ - label: 'Create your campaign', - href: '/dreps/workflow/profile/new', + label: isEnabled ? 'Create your campaign' :"Connect Wallet", + href: isEnabled ? '/dreps/workflow/profile/new' : '', }} + clicked={isEnabled ? undefined : ()=>{setIsWalletListModalOpen(true)}} description={ 'Just like staking a pool, Ada holders can delegate their stake to a DRep with Transaction.' } @@ -34,9 +47,10 @@ const DrepInfoCardRow = () => { img={'/img/credImg.png'} title={'Voting Power'} action={{ - label: 'See Your Profile', - href: '#', + label: isEnabled ? 'See Your Profile' : "Connect Wallet", + href: isEnabled ?`/dreps/${currentDelegation?.currentDelegation?.drep_view}` : '' }} + clicked={isEnabled ? undefined : ()=>{setIsWalletListModalOpen(true)}} description={ 'DRep voting power will be the total value of staked Ada delegated to the DRep.' } @@ -46,12 +60,13 @@ const DrepInfoCardRow = () => { img={'/img/statusImg.png'} title={'Status'} action={{ - label: 'Go To Your Timeline', - href: '#', + label: isEnabled ? 'Go To Your Timeline' : "Connect Wallet", + href: isEnabled ?`/dreps/${currentDelegation?.currentDelegation?.drep_view}` : '' }} description={ 'Registered DReps will need to vote regularly to still be considered active.' } + clicked={isEnabled ? undefined : ()=>{setIsWalletListModalOpen(true)}} />
); diff --git a/frontend/src/components/molecules/DrepProfileMetrics.tsx b/frontend/src/components/molecules/DrepProfileMetrics.tsx index 8201200..ddce44f 100644 --- a/frontend/src/components/molecules/DrepProfileMetrics.tsx +++ b/frontend/src/components/molecules/DrepProfileMetrics.tsx @@ -1,10 +1,10 @@ import React from 'react'; import DrepDelegatorsList from '../atoms/DrepDelegatorsList'; -const DrepProfileMetrics = ({drepMetrics}:{drepMetrics: any}) => { +const DrepProfileMetrics = ({voterId}:{voterId: string}) => { return ( -
- +
+
); }; diff --git a/frontend/src/components/molecules/DrepTimeline.tsx b/frontend/src/components/molecules/DrepTimeline.tsx index 2e4768b..1f9aeb7 100644 --- a/frontend/src/components/molecules/DrepTimeline.tsx +++ b/frontend/src/components/molecules/DrepTimeline.tsx @@ -15,13 +15,13 @@ import { useGetDRepTimelineQuery } from '@/hooks/useGetDRepTimelineQuery'; import DRepTimelineLoader from '../Loaders/DRepTimelineLoader'; import ReloadIcon from '../atoms/svgs/ReloadIcon'; import { formatNumberTimeToReadable } from '@/lib'; -import {Box, Fade, Grow} from '@mui/material'; +import { Box, Fade, Grow } from '@mui/material'; import DRepTimeLIneFilters from './DRepTimeLineFilters'; import DatabaseNullIcon from '../atoms/svgs/DatabaseNullIcon'; import { useScreenDimension } from '@/hooks'; -import Typography from "@mui/material/Typography"; +import Typography from '@mui/material/Typography'; -const DrepTimeline = ({ cexplorerDetails }: { cexplorerDetails: any }) => { +const DrepTimeline = ({ drep }: { drep: any }) => { const { drepid } = useParams(); const [filterValues, setFilterValues] = useState(null); const { @@ -47,7 +47,7 @@ const DrepTimeline = ({ cexplorerDetails }: { cexplorerDetails: any }) => { const pathName = usePathname(); const { replace } = useRouter(); const params = new URLSearchParams(searchParams); -const {isMobile}=useScreenDimension(); + const { isMobile } = useScreenDimension(); const startTimeFormatted = formatNumberTimeToReadable(timelineStartTime); const endTimeFormatted = formatNumberTimeToReadable(timelineEndTime); @@ -162,18 +162,20 @@ const {isMobile}=useScreenDimension();
- Timeline + Timeline
- {cexplorerDetails?.view == dRepIDBech32 && ( + {drep?.view == dRepIDBech32 && drep?.drep_id && ( )} @@ -198,16 +200,30 @@ const {isMobile}=useScreenDimension(); onClick={loadNewerData} > - + Load Newer
)} {isAtLatestPoint && DRepActivity.length > 0 && ( - You're all caught up! + + You're all caught up! + )} - + Showing results from{' '} {startTimeFormatted} to{' '} {endTimeFormatted} diff --git a/frontend/src/components/molecules/ListSort.tsx b/frontend/src/components/molecules/ListSort.tsx new file mode 100644 index 0000000..0fe6f7b --- /dev/null +++ b/frontend/src/components/molecules/ListSort.tsx @@ -0,0 +1,183 @@ +'use client'; + +import React, { MouseEvent, useEffect, useState } from 'react'; +import Popover from '@mui/material/Popover'; +import { + Box, + Divider, + FormControl, + FormControlLabel, + Grow, + Radio, + RadioGroup, +} from '@mui/material'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import Button from '../atoms/Button'; +import DotIcon from '../atoms/svgs/DotIcon'; + +type SortOption = { + label: string; + value: string; +}; + +type ListSortProps = { + tableType: string; + sortOptions: { category: string; options: SortOption[] }[]; +}; + +export default function ListSort({ tableType, sortOptions }: ListSortProps) { + const [anchorEl, setAnchorEl] = useState(null); + const [isFiltering, setIsFiltering] = useState(false); + + const searchParams = useSearchParams(); + const pathName = usePathname(); + const { replace } = useRouter(); + + useEffect(() => { + const value = sortValue(); + if (value) setIsFiltering(true); + }, []); + + const setSorts = (event: React.ChangeEvent) => { + const value = event.target.value; + const params = new URLSearchParams(searchParams); + + if (value) { + const [sort, order] = value.split('-'); + params.set('sort', sort); + params.set('order', order); + params.set('page', '1'); + } else { + params.delete('sort'); + params.delete('order'); + } + setIsFiltering(true); + replace(`${pathName}?${params.toString()}`); + }; + + const handleShow = (event: MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const sortValue = () => { + let sort = searchParams.get('sort')?.toString(); + let order = searchParams.get('order')?.toString(); + + if (sort && order) { + return `${sort}-${order}`; + } else { + return null; + } + }; + + const resetSort = () => { + const params = new URLSearchParams(searchParams); + params.delete('sort'); + params.delete('order'); + setIsFiltering(false); + replace(`${pathName}?${params.toString()}`); + }; + + const open = Boolean(anchorEl); + const id = open ? 'sort-popover' : undefined; + + return ( + + + Arrows Sort + + +
+ +
+
+
+ + +

Sort {tableType} by:

+ + {sortOptions.map((category, index) => ( + + + + {category.category} + + + {category.options.map((option, idx) => ( + + } + label={option.label} + /> + ))} + + + {index < sortOptions.length - 1 && } + + ))} + + {isFiltering && ( + + + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/molecules/LoginButton.tsx b/frontend/src/components/molecules/LoginButton.tsx index c78e82a..9a511e0 100644 --- a/frontend/src/components/molecules/LoginButton.tsx +++ b/frontend/src/components/molecules/LoginButton.tsx @@ -6,7 +6,6 @@ import { CircularProgress } from '@mui/material'; import { useDRepContext } from '@/context/drepContext'; import { userLogin } from '@/services/requests/userLogin'; import { setItemToLocalStorage } from '@/lib'; -import Cookies from 'js-cookie'; const LoginButton = ({ isHardware = false, loginMode = false, @@ -20,8 +19,10 @@ const LoginButton = ({ loginSignTransaction, loginHardwareWalletTransaction, isGettingSignatures, + stakeKeyBech32, + dRepIDBech32, } = useCardano(); - const { isLoggedIn, setIsLoggedIn, setLoginModalOpen } = useDRepContext(); + const { setIsLoggedIn, setLoginModalOpen, drepId } = useDRepContext(); const { addErrorAlert } = useGlobalNotifications(); const handleLogin = async () => { let signature; @@ -41,13 +42,15 @@ const LoginButton = ({ if (signature && key && loginMode) { setIsLoggedIn(true); - const loginCredentials = { signature, key, expiry: loginPeriod }; + const loginCredentials = { + drepId, + voterId: dRepIDBech32, + stakeKey: stakeKeyBech32, + signature, + key, + expiry: loginPeriod, + }; const { token } = await userLogin(loginCredentials); - Cookies.set('token', token, { - secure: false, - sameSite: 'strict', - path: '/', - }); setItemToLocalStorage('signatures', { signature, key }); setItemToLocalStorage('token', token); setIsLoggedIn(true); diff --git a/frontend/src/components/molecules/MultipartDataForm.tsx b/frontend/src/components/molecules/MultipartDataForm.tsx index 2cc6401..f2fac36 100644 --- a/frontend/src/components/molecules/MultipartDataForm.tsx +++ b/frontend/src/components/molecules/MultipartDataForm.tsx @@ -273,7 +273,7 @@ const MultipartDataForm = ({ setFiles(null); }} variant="outlined" - bgColor="transparent" + bgcolor="transparent" >

Cancel

@@ -355,7 +355,7 @@ const MultipartDataForm = ({ setFiles(null); }} variant="outlined" - bgColor="transparent" + bgcolor="transparent" >

Cancel

diff --git a/frontend/src/components/molecules/NewNotePostForm.tsx b/frontend/src/components/molecules/NewNotePostForm.tsx index 429432c..004098b 100644 --- a/frontend/src/components/molecules/NewNotePostForm.tsx +++ b/frontend/src/components/molecules/NewNotePostForm.tsx @@ -4,7 +4,7 @@ import PostSubmitArea from '../atoms/PostSubmitArea'; import PostVisiblityInput from '../atoms/PostVisiblityInput'; import CustomAutocomplete from '../atoms/PostAutoComplete'; import MarkdownEditor from '../atoms/MarkdownEditor'; -const NewNotePostForm = ({ register, control, errors }) => { +const NewNotePostForm = ({ register, control, errors, isLoading }) => { return (
{ /> - +
); }; diff --git a/frontend/src/components/molecules/Pagination.tsx b/frontend/src/components/molecules/Pagination.tsx index d71872a..09c2f19 100644 --- a/frontend/src/components/molecules/Pagination.tsx +++ b/frontend/src/components/molecules/Pagination.tsx @@ -4,29 +4,54 @@ import ChevronsRightIcon from '../atoms/svgs/ChevronsRightIcon'; import ChevronRightIcon from '../atoms/svgs/ChevronRightIcon'; import ChevronLeftIcon from '../atoms/svgs/ChevronLeftIcon'; import ChevronsLeftIcon from '../atoms/svgs/ChevronsLeftIcon'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; type PaginationProps = { currentPage: number; totalPages: number; totalItems: number; - moveToFirstPage?: Function; - moveToPreviousPage?: Function; - moveToNextPage?: Function; - moveToLastPage?: Function; + dataType: string; }; const Pagination = ({ currentPage, totalPages, totalItems, - moveToFirstPage, - moveToPreviousPage, - moveToNextPage, - moveToLastPage, + dataType }: PaginationProps) => { + const searchParams = useSearchParams(); + const pathName = usePathname(); + const { replace } = useRouter(); + const isLastPage = currentPage === totalPages; const isFirstPage = currentPage === 1; + function moveToPage(targetPage: number) { + const params = new URLSearchParams(searchParams); + + if (currentPage !== targetPage) { + params.set('page', targetPage.toString()); + } + replace(`${pathName}?${params.toString()}`); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + + function moveToFirstPage(firstPage: number) { + moveToPage(firstPage); + } + + function moveToLastPage(lastPage: number) { + moveToPage(lastPage); + } + + function moveToPreviousPage(previousPage: number) { + moveToPage(previousPage); + } + + function moveToNextPage(nextPage: number) { + moveToPage(nextPage); + } + return ( <> {!totalItems && totalPages && currentPage ? ( @@ -92,7 +117,7 @@ const Pagination = ({
- Total DReps: {totalItems} + Total {dataType}: {totalItems} )} diff --git a/frontend/src/components/molecules/ProposalActionForm.tsx b/frontend/src/components/molecules/ProposalActionForm.tsx index 3342602..aed9016 100644 --- a/frontend/src/components/molecules/ProposalActionForm.tsx +++ b/frontend/src/components/molecules/ProposalActionForm.tsx @@ -21,6 +21,7 @@ const ProposalActionForm = ({ const [error, setError] = useState(''); const [fetchedProposals, setFetchedProposals] = useState(null); const [currentHash, setCurrentHash] = useState(''); + const [inputValue, setInputValue] = useState(currentHash); const formRef = useRef(null); const { @@ -42,10 +43,18 @@ const ProposalActionForm = ({ } }, [Proposals]); - const handleInputChange = useDebouncedCallback((value) => { + const handleDebouncedInputChange = useDebouncedCallback((value) => { + setFetchedProposals(null); + setProposals(null) setCurrentHash(value); }, 300); + const handleInputChange = (value) => { + const trimmedValue = value.endsWith('#0') ? value.slice(0, -2) : value; + setInputValue(trimmedValue); + handleDebouncedInputChange(trimmedValue); + }; + const uploadProposal = async () => { try { const markdown = `[gov_action hash='${proposals[0]}']`; @@ -91,17 +100,17 @@ const ProposalActionForm = ({
handleInputChange(e.target.value)} className={`w-full rounded-full border border-zinc-100 py-3 pl-5`} placeholder={'Paste proposal hash here...'} />
-

+

Proposals should exist on chain for addition.

-
+
{!isProposalsFetching ? ( fetchedProposals && fetchedProposals.length > 0 ? ( fetchedProposals.map((proposal, index) => ( @@ -138,7 +147,7 @@ const ProposalActionForm = ({ setProposals(null); }} variant="outlined" - bgColor="transparent" + bgcolor="transparent" >

Cancel

diff --git a/frontend/src/components/molecules/UpdateNotePostForm.tsx b/frontend/src/components/molecules/UpdateNotePostForm.tsx index 7a1beb9..08c53fe 100644 --- a/frontend/src/components/molecules/UpdateNotePostForm.tsx +++ b/frontend/src/components/molecules/UpdateNotePostForm.tsx @@ -4,7 +4,13 @@ import PostSubmitArea from '../atoms/PostSubmitArea'; import PostVisiblityInput from '../atoms/PostVisiblityInput'; import CustomAutocomplete from '../atoms/PostAutoComplete'; import MarkdownEditor from '../atoms/MarkdownEditor'; -const UpdateNotePostForm = ({ register, control, errors }) => { +const UpdateNotePostForm = ({ + register, + control, + errors, + createdAt, + isLoading, +}) => { return (
{ /> - +
); }; diff --git a/frontend/src/components/molecules/ViewDraftsButton.tsx b/frontend/src/components/molecules/ViewDraftsButton.tsx index 6aee556..9b0915d 100644 --- a/frontend/src/components/molecules/ViewDraftsButton.tsx +++ b/frontend/src/components/molecules/ViewDraftsButton.tsx @@ -5,7 +5,7 @@ const ViewDraftsButton = ({isUpdating=false}:{isUpdating?: boolean}) => { return (