diff --git a/backend/package.json b/backend/package.json index e9ca67549b15eaaf5adc1011bb4c125d853dad76..2ff304c717acea8ffe710a491418111c0d88935a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -32,6 +32,7 @@ "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.2.0", "@nestjs/platform-express": "^10.0.0", + "@nestjs/schedule": "^4.1.1", "@nestjs/typeorm": "^10.0.2", "@types/jsonld": "^1.5.15", "axios": "^1.7.2", diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index a13f04c04077ef497cb69e71137754fb50677d6b..ab8401bb36cd856dbf811054d2d86ede8f7cf226 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,14 +1,16 @@ -import { Body, Controller, Post } from "@nestjs/common"; -import { AuthService } from "./auth.service"; +import { Body, Controller, Post } from '@nestjs/common'; +import { AuthService } from './auth.service'; @Controller('auth') export class AuthController { - constructor( - private authService:AuthService - ) {} - @Post('login') - async login(@Body() payload: any) { - const {expiry, ...authPayload}=payload - return this.authService.login(authPayload, expiry); - } -} \ No newline at end of file + constructor(private authService: AuthService) {} + @Post('session') + async getSession(@Body('payload') payload: any) { + return this.authService.getSession(payload); + } + @Post('login') + async login(@Body() payload: any) { + const { expiry, ...authPayload } = payload; + return this.authService.login(authPayload, expiry); + } +} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index f2c83e3850990c63ea15a2332120a3bf9a7250b7..f1ae6c0d205cda8efac1876d14f84012d2074d00 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -30,7 +30,6 @@ export class AuthService { secret: accessSecret, }); } - //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); @@ -40,26 +39,36 @@ export class AuthService { stakeKey: payload.stakeKey, signatureKey: payload.key, signature: payload.signature, + lastSignedIn: new Date(), }; //check for existing signature const existingSig = await this.voltaireService .getRepository('Signature') .findOne({ - where: { signature: payload.signature, signatureKey:payload.key, stakeKey: payload.stakeKey, }, + where: { stakeKey: payload.stakeKey }, }); if (existingSig) { //update the signature - const updatedSig=await this.voltaireService + const updatedSig = await this.voltaireService .getRepository('Signature') - .update(existingSig.id, signatureDto) - return { token, updatedSig }; + .update(existingSig.id, signatureDto); + return { token, updatedSig, session: existingSig }; } const insertedSig = await this.voltaireService .getRepository('Signature') .insert(signatureDto); - return { token, insertedSig }; + return { token, insertedSig, session: insertedSig?.raw[0] }; } - 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. + async getSession(payload: Payload) { + const signature = await this.voltaireService + .getRepository('Signature') + .findOne({ + where: { + signature: payload.signature, + signatureKey: payload.key, + stakeKey: payload.stakeKey, + }, + }); + return signature; } } diff --git a/backend/src/comments/comments.service.ts b/backend/src/comments/comments.service.ts index 40ed2c4a4fc15021547301c74be69d3c63ff0b58..2990d1782494d3d2f430b5f3381ce364ee69706f 100644 --- a/backend/src/comments/comments.service.ts +++ b/backend/src/comments/comments.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; +import { NotificationsService } from 'src/notifications/notifications.service'; import { ReactionsService } from 'src/reactions/reactions.service'; import { DataSource } from 'typeorm'; @@ -9,6 +10,7 @@ export class CommentsService { @InjectDataSource('default') private voltaireService: DataSource, private reactionsService: ReactionsService, + private notificationsService: NotificationsService, ) {} async getComments(parentId: number, parentEntity: string) { @@ -58,7 +60,57 @@ export class CommentsService { } } - return this.voltaireService.getRepository('Comment').save(newComment); + const savedComment = await this.voltaireService + .getRepository('Comment') + .save(newComment); + // send notification to the owner of the parent entity + switch (parentEntity) { + case 'note': + const note = await this.voltaireService + .getRepository('Note') + .createQueryBuilder('note') + .leftJoinAndSelect('note.author', 'signature') + .where('note.id = :id', { id: parentId }) + .getOne(); + if (note) { + const owner = note.author?.id; + if (voter !== note?.author?.stakeKey) { + await this.notificationsService.createNotification( + this.notificationsService.newCommentOnNoteNotification( + new Date(note?.createdAt as Date).getTime(), + note?.author?.voterId, + voter, + ), + owner, + ); + } + } + break; + case 'comment': + const parentComment = await this.voltaireService + .getRepository('Comment') + .findOne({ where: { id: parentId } }); + + if (parentComment) { + const owner = parentComment.voter; + if (owner !== voter) { + const signature = await this.voltaireService + .getRepository('Signature') + .findOne({ where: { stakeKey: owner } }); + await this.notificationsService.createNotification( + this.notificationsService.newReplyToCommentNotification( + voter, + ), + signature.id, + savedComment.createdAt, + ); + } + } + break; + default: + break; + } + return savedComment; } async removeComment( diff --git a/backend/src/drep/drep.service.ts b/backend/src/drep/drep.service.ts index 3c2bf09a69885e7a6ee378fb57159e6e1bf17dfd..de2688e89d7f6215b71e932802e35eba4310318a 100644 --- a/backend/src/drep/drep.service.ts +++ b/backend/src/drep/drep.service.ts @@ -415,7 +415,6 @@ export class DrepService { tillDate, filterValues, }: DRepTimelineParams): Observable<TimelineEntry[]> { - const filters = this.getFilters(filterValues); const { startingTime, endingTime } = this.getTimeRange( beforeDate, @@ -515,13 +514,25 @@ export class DrepService { epoch_no: results.regData.epoch_of_registration, }); } - + + // Remove duplicate timeline entries based on a unique identifier (e.g., timestamp and type) + const uniqueEntries = Array.from( + new Map( + timelineEntries.map((entry) => [ + `${new Date(entry.timestamp).getTime()}-${entry.type}`, + entry, + ]), + ).values(), + ); + // Sort timeline entries by timestamp (latest first) - timelineEntries.sort((a, b) => { - return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(); + uniqueEntries.sort((a, b) => { + return ( + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ); }); - - return timelineEntries; + + return uniqueEntries; }), timeout(100000), catchError((error) => { @@ -591,10 +602,10 @@ export class DrepService { tillDate, }, ); - + // Prepare visibility conditions const visibilityConditions = ['note.visibility = :everyone']; - + const visibilityParams: { everyone: string; delegators?: string; @@ -604,7 +615,7 @@ export class DrepService { } = { everyone: 'everyone', }; - + // 'delegators' visibility if (delegation) { visibilityConditions.push( @@ -613,7 +624,7 @@ export class DrepService { visibilityParams.delegators = 'delegators'; visibilityParams.drepVoterId = delegation.drep_view; } - + // 'myself' visibility if (stakeKeyBech32) { visibilityConditions.push( @@ -622,13 +633,13 @@ export class DrepService { visibilityParams.myself = 'myself'; visibilityParams.stakeKeyBech32 = stakeKeyBech32; } - + // Combine visibility conditions with OR logic queryBuilder.andWhere( `(${visibilityConditions.join(' OR ')})`, visibilityParams, ); - + // Convert queryBuilder result to an observable return from(queryBuilder.getRawMany()).pipe( switchMap((allNotes) => { @@ -636,7 +647,7 @@ export class DrepService { // If no notes are found, return an observable emitting an empty array return of([]); } - + // Using forkJoin to run reactions and comments observables for all notes const noteObservables = allNotes.map((note) => { return forkJoin({ @@ -655,7 +666,7 @@ export class DrepService { })), ); }); - + // Return an observable that emits all notes with reactions and comments return forkJoin(noteObservables); }), @@ -664,7 +675,7 @@ export class DrepService { return of([]); // Return an empty array on error }), ); - } + } async populateFakeDRepData() { const dreps = await this.getAllDRepsCexplorer(); @@ -986,10 +997,10 @@ export class DrepService { [voterId], ); - const metadataUrl = metadata?.[0]?.metadata_url + const metadataUrl = metadata?.[0]?.metadata_url; - if(!metadataUrl){ - throw new NotFoundException("metadata url not found!") + if (!metadataUrl) { + throw new NotFoundException('metadata url not found!'); } const { data } = await firstValueFrom( @@ -1007,12 +1018,12 @@ export class DrepService { } } - async getVoltaireDRepViaVoterID(drepVoterId){ + async getVoltaireDRepViaVoterID(drepVoterId) { return await this.voltaireService - .getRepository('Drep') - .createQueryBuilder('drep') - .leftJoinAndSelect('signature', 'signature', 'signature.drepId = drep.id') - .where('signature.voterId = :drepVoterId', { drepVoterId }) - .getRawOne(); + .getRepository('Drep') + .createQueryBuilder('drep') + .leftJoinAndSelect('signature', 'signature', 'signature.drepId = drep.id') + .where('signature.voterId = :drepVoterId', { drepVoterId }) + .getRawOne(); } } diff --git a/backend/src/dto/createNoteDto.ts b/backend/src/dto/createNoteDto.ts index b9264decd877aa474247235cfd2237ef18500e60..0da784c62f6f43fba90d87e6514688b79856a527 100644 --- a/backend/src/dto/createNoteDto.ts +++ b/backend/src/dto/createNoteDto.ts @@ -1,5 +1,4 @@ import { IsNotEmpty, IsOptional } from 'class-validator'; -import {Type} from 'class-transformer' export class createNoteDto { @IsNotEmpty() diff --git a/backend/src/dto/createNotificationDto.ts b/backend/src/dto/createNotificationDto.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6da139ec7f444c39f56066642f5316047f87bf1 --- /dev/null +++ b/backend/src/dto/createNotificationDto.ts @@ -0,0 +1,14 @@ +enum NotificationType { + info = 'info', + warning = 'warning', + error = 'error', +} + +export class createNotificationDto { + title: string; + message: string; + type: keyof typeof NotificationType; + isRead?: boolean; + isArchived?: boolean; + isPersistent?: boolean; +} diff --git a/backend/src/entities/notification.entity.ts b/backend/src/entities/notification.entity.ts new file mode 100644 index 0000000000000000000000000000000000000000..0233522c00041789077fb486e63d7bde45821735 --- /dev/null +++ b/backend/src/entities/notification.entity.ts @@ -0,0 +1,41 @@ +import { BaseImmutableEntity } from 'src/global'; +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + + +//for reference to the notifications +@Entity() +export class Notification extends BaseImmutableEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column() + title: string; + + @Column() + message: string; + + @Column({ + type: 'enum', + enum: ['info', 'warning', 'error'], + default: 'info', + }) + type: string; + + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + createdAt: Date; + + @Column({ default: false }) + isRead: boolean; + + @Column({ default: false }) + isArchived: boolean; + + @Column({ nullable: true }) + deletedAt: Date; + + @Column({ default: false }) + isPersistent: boolean; + + @Column() + recipient: string; +} diff --git a/backend/src/entities/signatures.entity.ts b/backend/src/entities/signatures.entity.ts index 94534706c39f06f199139539ff97fa1903ecf03a..76b1a37bb5f92469fd59833c9f05dfe988e0251c 100644 --- a/backend/src/entities/signatures.entity.ts +++ b/backend/src/entities/signatures.entity.ts @@ -24,4 +24,7 @@ export class Signature { @Column({ nullable: true, unique: false, default: null }) signatureKey: string; + + @Column({ nullable: true, unique: false, default: null }) + lastSignedIn: Date; } diff --git a/backend/src/entities/synctime.entity.ts b/backend/src/entities/synctime.entity.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e64e53a705495044d564f4252e3a809dfb5ed6a --- /dev/null +++ b/backend/src/entities/synctime.entity.ts @@ -0,0 +1,10 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity() +export class Synctime { + @PrimaryGeneratedColumn() + id: number; + + @Column() + lastSyncTime: string; +} diff --git a/backend/src/global/base.entity.ts b/backend/src/global/base.entity.ts index ed350cb1e878e04a6b6f1d2c2ae491cb22b7fa9a..8682920d160aba2e5c7aca48dd6018ba4cab1d7e 100644 --- a/backend/src/global/base.entity.ts +++ b/backend/src/global/base.entity.ts @@ -49,3 +49,17 @@ export abstract class BaseEntity extends SoftDeletableBaseEntity { @UpdateDateColumn() // TypeORM decorator for update date updatedAt?: Date; } +/** + * Abstract base entity with common fields for primary key, creation, soft-delete, and more. + */ +export abstract class BaseImmutableEntity extends SoftDeletableBaseEntity { + // Primary key of UUID type + @PrimaryGeneratedColumn() + id?: number; + + @CreateDateColumn({ + update: false, + }) // TypeORM decorator for creation date + createdAt?: Date; + +} diff --git a/backend/src/migrations/1728984622640-migrations.ts b/backend/src/migrations/1728984622640-migrations.ts new file mode 100644 index 0000000000000000000000000000000000000000..f97cd276f77c6e7f1502b115e7d8be4b1ddabbd4 --- /dev/null +++ b/backend/src/migrations/1728984622640-migrations.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class Migrations1728984622640 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(` + CREATE TABLE "notification" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "title" varchar NOT NULL, + "message" varchar NOT NULL, + "type" varchar NOT NULL DEFAULT 'info', + "createdAt" timestamp NOT NULL DEFAULT now(), + "isRead" boolean NOT NULL DEFAULT false, + "isArchived" boolean NOT NULL DEFAULT false, + "deletedAt" timestamp, + "isPersistent" boolean NOT NULL DEFAULT false, + "recipient" integer NOT NULL + ); + `); + await queryRunner.query(` + ALTER TABLE "notification" + ADD CONSTRAINT "FK_signatureId" FOREIGN KEY ("recipient") REFERENCES "signature" ("id"); + `); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(` + ALTER TABLE "notification" + DROP CONSTRAINT "FK_signatureId"; + `); + await queryRunner.query(` + DROP TABLE "notification"; + `); + } +} diff --git a/backend/src/migrations/1729032244978-addLastSignedIn.ts b/backend/src/migrations/1729032244978-addLastSignedIn.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b98bc4bd43c4e326f2b247be57467001d703bf2 --- /dev/null +++ b/backend/src/migrations/1729032244978-addLastSignedIn.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddLastSignedIn1729032244978 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(` + ALTER TABLE "signature" + ADD COLUMN "lastSignedIn" timestamp NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(` + ALTER TABLE "signature" + DROP COLUMN "lastSignedIn"; + `); + } + +} diff --git a/backend/src/migrations/1729359985813-syncTime.ts b/backend/src/migrations/1729359985813-syncTime.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c73ea598ff1a5fdaa6547cbe54f38f8a13a3738 --- /dev/null +++ b/backend/src/migrations/1729359985813-syncTime.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class SyncTime1729359985813 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(` + CREATE TABLE "synctime" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "lastSyncTime" timestamp NOT NULL DEFAULT now() + ) + `) + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(` + DROP TABLE "synctime" + `); + } + +} diff --git a/backend/src/migrations/1729366851137-delete-duplicate-signatures.ts b/backend/src/migrations/1729366851137-delete-duplicate-signatures.ts new file mode 100644 index 0000000000000000000000000000000000000000..b53178306639817256d837018ee1ff786aa911ab --- /dev/null +++ b/backend/src/migrations/1729366851137-delete-duplicate-signatures.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class DeleteDuplicateSignatures1729366851137 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise<void> { + //delete duplicate signatures where "drepId" is NULL + await queryRunner.query(` + DELETE FROM "signature" sig + WHERE sig.id IN ( + SELECT s1.id + FROM "signature" s1 + WHERE s1."stakeKey" IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM "signature" s2 + WHERE s1."stakeKey" = s2."stakeKey" + AND (s1."drepId" IS NULL OR s1."drepId" = s2."drepId") + AND s1.id <> s2.id + AND s2."drepId" IS NOT NULL + ) + AND s1."drepId" IS NULL -- Specifically delete entries where "drepId" is NULL + ) + `); + //delete duplicate signatures where "drepId" is NOT NULL and is the same + await queryRunner.query(` + DELETE FROM "signature" sig + WHERE sig.id IN ( + SELECT id FROM ( + SELECT id, + ROW_NUMBER() OVER (PARTITION BY "stakeKey", "drepId" ORDER BY id) AS row_num + FROM "signature" + WHERE "stakeKey" IS NOT NULL + ) AS ranked + WHERE ranked.row_num > 1 + ); + `); + + } + + public async down(queryRunner: QueryRunner): Promise<void> { + // No need to rollback + } + +} diff --git a/backend/src/note/note.service.ts b/backend/src/note/note.service.ts index febba4b2bda9ee47673904f3c5eb59116f2215dd..6faf4ee4b221c9375e38693761ced7d31d5dfaa4 100644 --- a/backend/src/note/note.service.ts +++ b/backend/src/note/note.service.ts @@ -1,11 +1,11 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { HttpException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { CommentsService } from 'src/comments/comments.service'; import { Delegation } from 'src/common/types'; import { DrepService } from 'src/drep/drep.service'; import { createNoteDto } from 'src/dto'; +import { NotificationsService } from 'src/notifications/notifications.service'; import { ReactionsService } from 'src/reactions/reactions.service'; -import { VoterService } from 'src/voter/voter.service'; import { DataSource } from 'typeorm'; @Injectable() export class NoteService { @@ -15,7 +15,7 @@ export class NoteService { private drepService: DrepService, private reactionsService: ReactionsService, private commentsService: CommentsService, - private voterService: VoterService, + private notificationsService: NotificationsService, ) {} async getAllNotes( stakeKeyBech32?: string, @@ -71,7 +71,7 @@ export class NoteService { .getRepository('Signature') .findOne({ where: { stakeKey: noteDto.stake_addr } }); if (!author) { - return new NotFoundException('Author details not found!'); + throw new NotFoundException('Author details not found!'); } const modifiedNoteDto = { ...noteDto, @@ -81,10 +81,20 @@ export class NoteService { const res = await this.voltaireService .getRepository('Note') .insert(modifiedNoteDto); + // add a notification for all delegators if a note is created by the drep and visible to delegators + if (isDRepPresent && noteDto.visibility !== 'myself') { + await this.notificationsService.processNewNoteNotificationsForDelegators( + isDRepPresent.view, + new Date(), + ); + } return { noteAdded: res.identifiers[0].id }; } catch (error) { console.log(error); - return new NotFoundException('DRep associated with note not found!'); + throw new HttpException( + ((error?.code == 23505) && "Duplicate Note found") || error?.message || error?.status || 'An error occured', + ((error?.code == 23505) && 409) || 500, + ); } } async updateNoteInfo(noteId: string, note: createNoteDto) { diff --git a/backend/src/notifications/notifications.controller.ts b/backend/src/notifications/notifications.controller.ts index 39937fc9db0774ececc89acd947e63c1a56a8e70..2b938691fb76598d898a25f69a77d00179c504b4 100644 --- a/backend/src/notifications/notifications.controller.ts +++ b/backend/src/notifications/notifications.controller.ts @@ -1,11 +1,34 @@ -import {Controller, Get} from '@nestjs/common'; -import {NotificationsService} from "./notifications.service"; +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { NotificationsService } from './notifications.service'; +import { createNotificationDto } from 'src/dto/createNotificationDto'; @Controller('notifications') export class NotificationsController { constructor(private notificationService: NotificationsService) {} - @Get('/') - async getNotifications() { - return this.notificationService.getNotifications(); + @Get('/:recipientId/all') + async getNotifications(@Param('recipientId') recipientId: string) { + return this.notificationService.getNotifications(recipientId); + } + @Post('/:recipientId/new') + async createNotification( + @Param('recipientId') recipientId: string, + @Body() content: createNotificationDto, + ) { + return this.notificationService.createNotification( + content, + Number(recipientId), + ); + } + @Post('/:notificationId/read') + async markNotificationAsRead( + @Param('notificationId') notificationId: string, + ) { + return this.notificationService.markNotificationAsRead(notificationId); + } + @Post('/:notificationId/unread') + async markNotificationAsUnread( + @Param('notificationId') notificationId: string, + ) { + return this.notificationService.markNotificationAsUnread(notificationId); } } diff --git a/backend/src/notifications/notifications.module.ts b/backend/src/notifications/notifications.module.ts index a94ffc357301051af9928365cdfd6aad77fd3858..d7032e660ebcd66f834812d44216da8157c8c867 100644 --- a/backend/src/notifications/notifications.module.ts +++ b/backend/src/notifications/notifications.module.ts @@ -1,12 +1,16 @@ -import { Module } from '@nestjs/common'; +import { Global, Module } from '@nestjs/common'; +import { NotificationsService } from './notifications.service'; +import { NotificationsController } from './notifications.controller'; +import { ScheduleModule } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Reaction } from 'src/entities/reaction.entity'; -import {NotificationsService} from "./notifications.service"; -import {NotificationsController} from "./notifications.controller"; +import { Notification } from 'src/entities/notification.entity'; +import { Synctime } from 'src/entities/synctime.entity'; +@Global() @Module({ - imports: [TypeOrmModule.forFeature([Reaction])], + imports: [ScheduleModule.forRoot(), TypeOrmModule.forFeature([Notification, Synctime])], + exports: [NotificationsService], controllers: [NotificationsController], - providers: [NotificationsService] + providers: [NotificationsService], }) export class NotificationsModule {} diff --git a/backend/src/notifications/notifications.service.ts b/backend/src/notifications/notifications.service.ts index 20adbc31c20630bb7663c0fd8779b1b650cfe141..42976a833c9ca974340d55d89d284db5c8a76f89 100644 --- a/backend/src/notifications/notifications.service.ts +++ b/backend/src/notifications/notifications.service.ts @@ -1,28 +1,316 @@ -import { Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource } from 'typeorm'; +import { VotingActivityHistory } from 'src/common/types'; +import { createNotificationDto } from 'src/dto/createNotificationDto'; +import { Signature } from 'src/entities/signatures.entity'; +import { getCurrentDelegationWithStakeHexQuery } from 'src/queries/currentDelegation'; +import { getDrepVotingActivityInTimestampQuery } from 'src/queries/drepVotingActivity'; +import { DataSource, In } from 'typeorm'; +type NotificationEvent = //for reference + | 'note_creation' + | 'voting' + | 'comment_on_note' + | 'reply_to_comment' + | 'reaction_to_note' + | 'reaction_to_comment'; @Injectable() export class NotificationsService { constructor( @InjectDataSource('default') private voltaireService: DataSource, + @InjectDataSource('dbsync') + private cexplorerService: DataSource, ) {} - async getNotifications() { - return [ - // { - // label: 'dbSync Error', - // text: 'Register to become a DRep, delegate voting power to DReps, & review & vote on governance actions.', - // type: 'info' - // }, - ]; - // const notifications = await this.voltaireService - // .getRepository('Notification') - // .createQueryBuilder('Notification') - // .where('Notification.parentId = :parentId', { parentId }) - // .andWhere('Notification.parentEntity = :parentEntity', { parentEntity }) - // .getMany(); - // return notifications; + async getNotifications(ownerId: string) { + const notifications = await this.voltaireService + .getRepository('Notification') + .createQueryBuilder('notification') + .where('notification.recipient = :ownerId', { ownerId }) + .getMany(); + //sort by date(latest) and isRead + notifications.sort((a, b) => { + if (a.isRead && !b.isRead) { + return 1; + } + if (!a.isRead && b.isRead) { + return -1; + } + return b.createdAt.getTime() - a.createdAt.getTime(); + }); + return notifications; + } + async createNotification( + content: createNotificationDto, + ownerId: number, + creationTime?: Date, + ) { + //first check if the owner exists + const owner = await this.voltaireService + .getRepository('Signature') + .findOne({ where: { id: ownerId } }); + if (!owner) { + throw new HttpException('Owner not found', HttpStatus.NOT_FOUND); + } + const notification = this.voltaireService + .getRepository('Notification') + .create({ + ...content, + createdAt: creationTime || new Date(), + recipient: Number(ownerId), + }); + await this.voltaireService.getRepository('Notification').save(notification); + return notification; + } + async markNotificationAsRead(notificationId: string) { + const notification = await this.voltaireService + .getRepository('Notification') + .findOne({ where: { id: notificationId } }); + if (!notification) { + throw new HttpException('Notification not found', HttpStatus.NOT_FOUND); + } + notification.isRead = true; + return await this.voltaireService + .getRepository('Notification') + .save(notification); + } + + async markNotificationAsUnread(notificationId: string) { + const notification = await this.voltaireService + .getRepository('Notification') + .findOne({ where: { id: notificationId } }); + if (!notification) { + throw new HttpException('Notification not found', HttpStatus.NOT_FOUND); + } + notification.isRead = false; + return await this.voltaireService + .getRepository('Notification') + .save(notification); + } + + async deleteNotification(notificationId: string) { + const notification = await this.voltaireService + .getRepository('Notification') + .findOne({ where: { id: notificationId } }); + if (!notification) { + throw new HttpException('Notification not found', HttpStatus.NOT_FOUND); + } + return await this.voltaireService + .getRepository('Notification') + .delete(notification); + } + async bulkDeleteNotifications(notificationIds: string[]) { + const notifications = await this.voltaireService + .getRepository('Notification') + .findBy({ id: In(notificationIds) }); + if (!notifications) { + throw new HttpException('Notifications not found', HttpStatus.NOT_FOUND); + } + return await this.voltaireService + .getRepository('Notification') + .delete(notifications); + } + async processEntityVoteNotifications( + signature: Signature, + lastSyncTime: Date, + ) { + try { + //get all the events since the last signin + const timeSinceLastSync = lastSyncTime || signature.lastSignedIn; + //note_creation by drep delegated to + const delegatedTo = (await this.cexplorerService.query( + getCurrentDelegationWithStakeHexQuery, + [signature.stakeKey], + )) as [{ drep_view: string }]; + const votingActivity = (await this.cexplorerService.manager.query( + getDrepVotingActivityInTimestampQuery, + [delegatedTo[0].drep_view, timeSinceLastSync, new Date()], + )) as VotingActivityHistory[]; + for (const vote of votingActivity) { + //check if voter is the recipient, skip if so + if (signature.voterId === vote.view) { + continue; + } + const timeVoted = new Date(vote.time_voted).getTime(); + const notificationContent = this.newVoteOnProposalNotification( + timeVoted, + delegatedTo[0].drep_view, + vote.vote, + ); + + await this.createNotification( + notificationContent, + signature.id, + vote.time_voted, + ); + } + return 'Done'; + } catch (error) { + console.log('Error while processing vote notifications', error); + throw error; + } + } + async processNewNoteNotificationsForDelegators( + drepId: string, + note_creation_date: Date, + ) { + //get all the delegators of the drep + const drep = await this.cexplorerService.query( + `SELECT id, view FROM drep_hash WHERE view = $1`, + [drepId], + ); + const delegators = (await this.cexplorerService.query( + ` SELECT DISTINCT sa.view FROM delegation_vote as dv + JOIN stake_address as sa on sa.id = dv.addr_id + WHERE drep_hash_id = $1`, + [drep[0].id], + )) as [{ view: string }]; + for (const delegator of delegators) { + //check those who have ever signed in + const signature = await this.voltaireService + .getRepository('Signature') + .findOne({ where: { stakeKey: delegator.view } }); + if (!signature || signature?.voterId === drepId) { + continue; + } + //check if delegator has signed in recently ( 2wks) + if ( + new Date(signature.lastSignedIn).getTime() < + note_creation_date.getTime() - 14 * 24 * 60 * 60 * 1000 + ) { + //Delegator has not signed in recently. Skipping to save resources + continue; + } + //send notification to each delegator + const notificationContent = this.newNoteNotification( + note_creation_date.getTime(), + drepId, + ); + await this.createNotification(notificationContent, signature.id); + } + return 'Done'; + } + // Notification templates + newNoteNotification(note_creation_date: number, drepId: string) { + return { + title: 'New Note', + message: `The [DRep](/dreps/${drepId}) you have delegated to has created a new note. Check it out [here](/dreps/${drepId}?start=${note_creation_date - 5 * 24 * 60 * 60 * 1000}&end=${note_creation_date})`, + type: 'info' as 'info', + }; + } + newCommentOnNoteNotification( + note_creation_date: number, + drepId: string, + voterId: string, + ) { + return { + title: 'New Comment', + message: `A [voter](/voters/${voterId}) has commented on your [note](/dreps/${drepId}?start=${note_creation_date - 5 * 24 * 60 * 60 * 1000}&end=${note_creation_date})`, + type: 'info' as 'info', + }; + } + newReplyToCommentNotification(voterId: string) { + return { + title: 'New Reply', + message: `A [voter](/voters/${voterId}) has replied to your comment.`, + type: 'info' as 'info', + }; + } + newReactionToNoteNotification( + reactionType: 'like' | 'dislike' | 'love' | 'rocket', + voterId: string, + drepId:string, + note_creation_date: number, + ) { + const reactionIcons = { + thumbsup: 'ðŸ‘', + thumbsdown: '👎', + like: 'â¤ï¸', + rocket: '🚀', + }; + return { + title: 'Note Reaction', + message: `A [voter](/voters/${voterId}) has reacted ${reactionIcons[reactionType]} to your [note](/dreps/${drepId}?start=${note_creation_date - 5 * 24 * 60 * 60 * 1000}&end=${note_creation_date})`, + type: 'info' as 'info', + }; + } + newReactionForCommentNotification( + reactionType: 'like' | 'dislike' | 'love' | 'rocket', + voterId: string, + ) { + const reactionIcons = { + thumbsup: 'ðŸ‘', + thumbsdown: '👎', + like: 'â¤ï¸', + rocket: '🚀', + }; + return { + title: 'Comment Reaction', + message: `A [voter](/voters/${voterId}) has reacted ${reactionIcons[reactionType]} to your comment`, + type: 'info' as 'info', + }; + } + newVoteOnProposalNotification( + timeVoted: number, + drepId: string, + voteType: string, + ) { + return { + title: 'Proposal Vote', + message: `The [drep](/dreps/${drepId}) you have delegated to has just voted ${voteType} on this [proposal](/dreps/${drepId}?start=${timeVoted - 5 * 24 * 60 * 60 * 1000}&end=${timeVoted}).`, + type: 'info' as 'info', + }; + } + //will purge notifications older than 90 days and process vote notifications every hour + @Cron(CronExpression.EVERY_HOUR) + private async notificationProcessAndPurge() { + const synctime = await this.voltaireService + .getRepository('Synctime') + .find(); + //get all signatures + const signatures = await this.voltaireService + .getRepository('Signature') + .find({}); + if (signatures) { + for (const sig of signatures) { + //check if the signer sign in is less than 2 wks old + if ( + new Date(sig.lastSignedIn).getTime() < + new Date().getTime() - 14 * 24 * 60 * 60 * 1000 + ) { + //skip to save resources. unwise to notify inactive users + continue; + } + await this.processEntityVoteNotifications( + sig as Signature, + synctime[0]?.lastSyncTime, + ); + } + } + //get notifications + const notifications = await this.voltaireService + .getRepository('Notification') + .find({}); + if (notifications) { + const oldNotifs = notifications.filter((notification) => { + const ninetyDaysAgo = new Date(); + ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); + return notification.createdAt < ninetyDaysAgo; + }); + if (oldNotifs.length > 0) { + await this.bulkDeleteNotifications(oldNotifs.map((notif) => notif.id)); + console.log(`${oldNotifs.length} notifications purged`); + } + } + if (!synctime || synctime?.length === 0) { + await this.voltaireService + .getRepository('Synctime') + .insert({ lastSyncTime: new Date() }); + } else + await this.voltaireService + .getRepository('Synctime') + .update(synctime[0]?.id, { lastSyncTime: new Date() }); } } diff --git a/backend/src/queries/currentDelegation.ts b/backend/src/queries/currentDelegation.ts index 716060df23dad35c9e7ba007d6764e7434517df0..409abd6e1baf707769cd6aa488d8fafaded033fa 100644 --- a/backend/src/queries/currentDelegation.ts +++ b/backend/src/queries/currentDelegation.ts @@ -23,3 +23,29 @@ AND NOT EXISTS ( AND dv2.tx_id > delegation_vote.tx_id ) LIMIT 1;`; + +export const getCurrentDelegationWithStakeHexQuery: 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.view = $1 +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/drepDelegatorsWithVotingPower.ts b/backend/src/queries/drepDelegatorsWithVotingPower.ts index 091faaea4eef877331476d2206f737173ef0216b..45b49ec41657875af19b6b8757631c8eef421c82 100644 --- a/backend/src/queries/drepDelegatorsWithVotingPower.ts +++ b/backend/src/queries/drepDelegatorsWithVotingPower.ts @@ -31,6 +31,33 @@ export const getDrepDelegatorsWithVotingPowerQuery = ( OFFSET ${offset} `; +export const getDrepDelegatorsQuery = ` + 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 sa.view AS stake_address, + b.epoch_no AS delegation_epoch, + COALESCE(SUM(txo.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 + 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 + `; + + export const getDrepDelegatorsCountQuery = () => ` WITH latest_delegations AS ( SELECT delegation_vote.addr_id, MAX(block.time) AS latest_time diff --git a/backend/src/queries/drepVotingActivity.ts b/backend/src/queries/drepVotingActivity.ts index 1531cd3ef351af0a1839fc6d875a1c6615b8d43c..c59efc6d093f49a459a192749d1b075af396984c 100644 --- a/backend/src/queries/drepVotingActivity.ts +++ b/backend/src/queries/drepVotingActivity.ts @@ -35,3 +35,41 @@ export const getDrepVotingActivityQuery = ` AND bk.time::DATE BETWEEN $3::DATE AND $2::DATE ORDER BY bk.epoch_no`; +export const getDrepVotingActivityInTimestampQuery = ` + 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, + 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, + va.url + FROM + drep_hash AS dh + JOIN + voting_procedure AS vp ON dh.id = vp.drep_voter + LEFT JOIN + gov_action_proposal AS gp ON vp.gov_action_proposal_id = gp.id + LEFT JOIN + tx AS tx ON vp.tx_id = tx.id + LEFT JOIN + tx AS prop_creation_tx ON gp.tx_id = prop_creation_tx.id + LEFT JOIN + 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 + --$2 earliest range limit, $3 latest range limit + AND bk.time BETWEEN $2::timestamptz AND $3::timestamptz + ORDER BY + bk.epoch_no`; diff --git a/backend/src/reactions/reactions.service.ts b/backend/src/reactions/reactions.service.ts index acffa740f3b96dfe8864cc41e8a5ea3dcecf9e35..cfc8d2a65497497d356cd218e936e8291ba178ea 100644 --- a/backend/src/reactions/reactions.service.ts +++ b/backend/src/reactions/reactions.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; +import { NotificationsService } from 'src/notifications/notifications.service'; import { DataSource } from 'typeorm'; @Injectable() @@ -7,6 +8,7 @@ export class ReactionsService { constructor( @InjectDataSource('default') private voltaireService: DataSource, + private notificationsService: NotificationsService, ) {} async getReactions(parentId: number, parentEntity: string) { @@ -18,16 +20,76 @@ export class ReactionsService { .getMany(); return reactions; } - async insertReaction(parentId: number, parentEntity: string, type: string, voter: string) { + async insertReaction( + parentId: number, + parentEntity: string, + type: string, + voter: string, + ) { const newReaction = this.voltaireService.getRepository('Reaction').create({ parentId, parentEntity, type, voter, }); - return this.voltaireService.getRepository('Reaction').save(newReaction); + const createdRxn = await this.voltaireService + .getRepository('Reaction') + .save(newReaction); + //inform the owner of the reacted to entity + let parent; + let content; + switch (parentEntity) { + case 'note': + parent = await this.voltaireService + .getRepository(parentEntity) + .createQueryBuilder(parentEntity) + .leftJoinAndSelect('note.author', 'signature') + .where('note.id = :parentId', { parentId }) + .getOne(); + if (voter !== parent.author?.stakeKey) { + content = this.notificationsService.newReactionToNoteNotification( + createdRxn.type as any, + voter, + parent?.author?.voterId, + new Date(parent?.createdAt).getTime() + ); + await this.notificationsService.createNotification( + content, + parent.author?.id, + ); + } + break; + case 'comment': + parent = await this.voltaireService + .getRepository(parentEntity) + .findOne({ where: { id: parentId } }); + if (voter !== parent.voter) { + content = this.notificationsService.newReactionForCommentNotification( + createdRxn.type as any, + voter, + ); + const signature = await this.voltaireService + .getRepository('Signature') + .findOne({ where: { stakeKey: parent.voter } }); + if (signature) { + await this.notificationsService.createNotification( + content, + signature.id, + ); + } + } + break; + default: + break; + } + return createdRxn; } - async removeReaction(parentId: number, parentEntity: string, type: string, voter: string) { + async removeReaction( + parentId: number, + parentEntity: string, + type: string, + voter: string, + ) { const reaction = await this.voltaireService .getRepository('Reaction') .createQueryBuilder('reaction') diff --git a/backend/yarn.lock b/backend/yarn.lock index bb71370b36d2cf1d63be18e86df6e9813a2bbb61..444873a16df95b9a0b97bd8c200426d23d44b6b1 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1052,6 +1052,14 @@ multer "1.4.4-lts.1" tslib "2.6.2" +"@nestjs/schedule@^4.1.1": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@nestjs/schedule/-/schedule-4.1.1.tgz#a730cff250ad5db78fe6a1bfc7f12a30856e48df" + integrity sha512-VxAnCiU4HP0wWw8IdWAVfsGC/FGjyToNjjUtXDEQL6oj+w/N5QDd2VT9k6d7Jbr8PlZuBZNdWtDKSkH5bZ+RXQ== + dependencies: + cron "3.1.7" + uuid "10.0.0" + "@nestjs/schematics@^10.0.0", "@nestjs/schematics@^10.0.1": version "10.1.1" resolved "https://registry.yarnpkg.com/@nestjs/schematics/-/schematics-10.1.1.tgz#a67fb178a7ad6025ccc3314910b077ac454fcdf3" @@ -1333,6 +1341,11 @@ dependencies: "@types/node" "*" +"@types/luxon@~3.4.0": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-3.4.2.tgz#e4fc7214a420173cea47739c33cdf10874694db7" + integrity sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA== + "@types/methods@^1.1.4": version "1.1.4" resolved "https://registry.yarnpkg.com/@types/methods/-/methods-1.1.4.tgz#d3b7ac30ac47c91054ea951ce9eed07b1051e547" @@ -2396,6 +2409,14 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== +cron@3.1.7: + version "3.1.7" + resolved "https://registry.yarnpkg.com/cron/-/cron-3.1.7.tgz#3423d618ba625e78458fff8cb67001672d49ba0d" + integrity sha512-tlBg7ARsAMQLzgwqVxy8AZl/qlTc5nibqYwtNGoCrd+cV+ugI+tvZC1oT/8dFH8W455YrywGykx/KMmAqOr7Jw== + dependencies: + "@types/luxon" "~3.4.0" + luxon "~3.4.0" + cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -4233,6 +4254,11 @@ lru-cache@^6.0.0: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3" integrity sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q== +luxon@~3.4.0: + version "3.4.4" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.4.4.tgz#cf20dc27dc532ba41a169c43fdcc0063601577af" + integrity sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA== + magic-string@0.30.5: version "0.30.5" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.5.tgz#1994d980bd1c8835dc6e78db7cbd4ae4f24746f9" @@ -5810,6 +5836,11 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== +uuid@10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" + integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== + uuid@9.0.1, uuid@^9.0.0: version "9.0.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" diff --git a/frontend/package.json b/frontend/package.json index 0409701dc8ec1bac5c02a15fe2197247ecac4857..832a62181733e0c1b1df289b3f751dc632518138 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,6 +51,7 @@ "axios": "^1.6.7", "bech32": "^2.0.0", "blakejs": "^1.2.1", + "date-fns": "^4.1.0", "framer-motion": "^11.1.7", "idb": "^8.0.0", "js-cookie": "^3.0.5", diff --git a/frontend/src/app/[locale]/dreps/[drepid]/not-found.tsx b/frontend/src/app/[locale]/dreps/[drepid]/not-found.tsx index a69326156130e7c57d941430e9ceb1800bf0b7ab..849526dbe836285ae393c10ba8dd6b51d1bdeae5 100644 --- a/frontend/src/app/[locale]/dreps/[drepid]/not-found.tsx +++ b/frontend/src/app/[locale]/dreps/[drepid]/not-found.tsx @@ -1,3 +1,4 @@ +'use client' import { usePathname } from 'next/navigation'; import React from 'react'; diff --git a/frontend/src/assets/styles/globals.css b/frontend/src/assets/styles/globals.css index 0a16d4dcbf26823701970d40ba70690266384452..41deb2ca0a023d9ad817170eef3bd83f3770f66b 100644 --- a/frontend/src/assets/styles/globals.css +++ b/frontend/src/assets/styles/globals.css @@ -255,4 +255,4 @@ table.dreps-table .drep_always_no_confidence > td { border-right: 0; } -} +} \ No newline at end of file diff --git a/frontend/src/components/1694.io/TranslationBlock.tsx b/frontend/src/components/1694.io/TranslationBlock.tsx index 911a00be5eae91d1f086289bb7a2c275be124621..a2be79c3c73182cfe446f16c7673f4acc282dd1e 100644 --- a/frontend/src/components/1694.io/TranslationBlock.tsx +++ b/frontend/src/components/1694.io/TranslationBlock.tsx @@ -1,8 +1,5 @@ -import React, { useEffect, useState } from 'react'; -import Link from 'next/link'; -import Button from '../atoms/Button'; -import BecomeADRepButton from './BecomeADRepButton'; -import HoverChip, { HtmlTooltip } from '../atoms/HoverChip'; +import React, { useState } from 'react'; +import { HtmlTooltip } from '../atoms/HoverChip'; import { CopyToClipboard } from 'react-copy-to-clipboard'; import { Accordion, diff --git a/frontend/src/components/atoms/HoverChip.tsx b/frontend/src/components/atoms/HoverChip.tsx index 65fcebb3e4a6b5e29e2b948b1f29d8e1e27dce06..3aadbf7f6c566b0b6c18047861e63d5f1bc7f582 100644 --- a/frontend/src/components/atoms/HoverChip.tsx +++ b/frontend/src/components/atoms/HoverChip.tsx @@ -1,27 +1,19 @@ -import { useState } from 'react'; -import { Tooltip, TooltipProps, tooltipClasses } from '@mui/material'; -import { CopyToClipboard } from 'react-copy-to-clipboard'; +import { Tooltip, TooltipProps } from '@mui/material'; import styled from '@emotion/styled'; interface HoverChipProps { - icon?: string; text?: string; handleClick?: () => void; position?: 'top' | 'bottom'; - textToCopy?: string; children?: React.ReactElement; } const HoverChip = ({ - icon, text, handleClick, position = 'top', - textToCopy, children, }: HoverChipProps) => { - const [isHovered, setIsHovered] = useState(false); - return ( <Tooltip title={text} diff --git a/frontend/src/components/atoms/SingleNotification.tsx b/frontend/src/components/atoms/SingleNotification.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7f075440e0a464399963c8ed35049d7863a99f5e --- /dev/null +++ b/frontend/src/components/atoms/SingleNotification.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react'; +import { + Typography, + Box, + IconButton, + Menu, + MenuItem, + Checkbox, +} from '@mui/material'; +import { formatDistanceToNow } from 'date-fns'; +import { Notification } from '../../../types/commonTypes'; +import * as marked from 'marked'; +import HoverChip from './HoverChip'; + +interface NotificationItemProps { + notification: Notification; + onMarkAsRead: (id: number) => void; + onMarkAsUnread: (id: number) => void; +} + +const NotificationItem: React.FC<NotificationItemProps> = ({ + notification, + onMarkAsRead, + onMarkAsUnread, +}) => { + const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); + + const handleOpenMenu = (event: React.MouseEvent<HTMLElement>) => { + setAnchorEl(event.currentTarget); + }; + + const handleCloseMenu = () => { + setAnchorEl(null); + }; + + const handleAction = (action: 'read' | 'unread') => { + handleCloseMenu(); + switch (action) { + case 'read': + onMarkAsRead(notification.id); + break; + case 'unread': + onMarkAsUnread(notification.id); + break; + } + }; + + return ( + <Box + className="my-0.5 flex items-center justify-between p-2" + sx={{ + opacity: notification.isRead ? '50%' : '100%', + }} + > + <Box className="mr-2 flex flex-grow flex-col"> + <Typography variant="subtitle2" className="font-bold"> + {notification.title} + </Typography> + <p + className="parsed-content text-xs text-gray-600" + dangerouslySetInnerHTML={{ + __html: marked.parse(notification.message), + }} + ></p> + <Typography variant="caption" className="text-gray-400"> + {formatDistanceToNow(new Date(notification.createdAt), { + addSuffix: true, + })} + </Typography> + </Box> + <IconButton + className="self-start" + onClick={() => handleAction(notification.isRead ? 'unread' : 'read')} + > + <HoverChip + text={notification.isRead ? 'Mark as unread' : 'Mark as read'} + position="bottom" + > + <Checkbox + checked={notification.isRead} + /> + </HoverChip> + </IconButton> + </Box> + ); +}; + +export default NotificationItem; diff --git a/frontend/src/components/dreps/notes/SingleNote.tsx b/frontend/src/components/dreps/notes/SingleNote.tsx index 85203c0890fb864b174c4721b167b62a6583a3ba..ff7379d79b240c505f4c928f1429a827ff0cf8c1 100644 --- a/frontend/src/components/dreps/notes/SingleNote.tsx +++ b/frontend/src/components/dreps/notes/SingleNote.tsx @@ -9,7 +9,6 @@ import { z } from 'zod'; import { SubmitHandler, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { postAddComment } from '@/services/requests/postAddComment'; -import * as marked from 'marked'; import { useGetSingleNoteQuery } from '@/hooks/useGetSingleNoteQuery'; import { processContent } from '@/lib/ContentProcessor/processContent'; import MarkdownEditor from '@/components/atoms/MarkdownEditor'; diff --git a/frontend/src/components/molecules/LoginButton.tsx b/frontend/src/components/molecules/LoginButton.tsx index 9a511e0b044fb17572b8ad7bb60445b4a643ccbc..27edbd7170dfe4b61f4caa503a4b185a58f26b28 100644 --- a/frontend/src/components/molecules/LoginButton.tsx +++ b/frontend/src/components/molecules/LoginButton.tsx @@ -22,7 +22,8 @@ const LoginButton = ({ stakeKeyBech32, dRepIDBech32, } = useCardano(); - const { setIsLoggedIn, setLoginModalOpen, drepId } = useDRepContext(); + const { setIsLoggedIn, setLoginModalOpen, drepId, setSignatureId } = + useDRepContext(); const { addErrorAlert } = useGlobalNotifications(); const handleLogin = async () => { let signature; @@ -50,12 +51,14 @@ const LoginButton = ({ key, expiry: loginPeriod, }; - const { token } = await userLogin(loginCredentials); + const { token, session } = await userLogin(loginCredentials); + setSignatureId(session.id); setItemToLocalStorage('signatures', { signature, key }); - setItemToLocalStorage('token', token); + setItemToLocalStorage('token_1694', token); setIsLoggedIn(true); setLoginModalOpen(false); } + } catch (error) { console.log(error); if (error instanceof Error) { diff --git a/frontend/src/components/molecules/LoginInfoCard.tsx b/frontend/src/components/molecules/LoginInfoCard.tsx index 894dae92ce305050db5af9d9b5b444ced408ba8a..53b8c5970a7390a38679cb5fc4c938c935883f60 100644 --- a/frontend/src/components/molecules/LoginInfoCard.tsx +++ b/frontend/src/components/molecules/LoginInfoCard.tsx @@ -7,7 +7,7 @@ export const LoginInfoCard = () => { const { loginCredentials } = useCardano(); const { setIsLoggedIn } = useDRepContext(); const onClickLogout = () => { - removeItemFromLocalStorage('token'); + removeItemFromLocalStorage('token_1694'); setIsLoggedIn(false); }; diff --git a/frontend/src/components/molecules/NotificationDrawer.tsx b/frontend/src/components/molecules/NotificationDrawer.tsx index a64b1e387d970a5e2f60b431ce2c29d8a31eb728..e2cf8083b132d0985df6952d213ca8e93ae9c21b 100644 --- a/frontend/src/components/molecules/NotificationDrawer.tsx +++ b/frontend/src/components/molecules/NotificationDrawer.tsx @@ -2,13 +2,19 @@ import React, { useState } from 'react'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import Grow from '@mui/material/Grow'; -import { Box } from '@mui/material'; +import { Box, Badge, Divider } from '@mui/material'; import { useGetUserNotificationQuery } from '@/hooks/useGetUserNotificationQuery'; import Typography from '@mui/material/Typography'; +import { useDRepContext } from '@/context/drepContext'; +import NotificationItem from '../atoms/SingleNotification'; +import { + postMarkNotificationAsRead, + postMarkNotificationAsUnread, +} from '@/services/requests/postNotificationRequests'; export default function NotificationDrawer() { const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); - + const { signatureId } = useDRepContext(); const open = Boolean(anchorEl); const handleClick = (event: React.MouseEvent<HTMLElement>) => { setAnchorEl(event.currentTarget); @@ -17,11 +23,64 @@ export default function NotificationDrawer() { const handleClose = () => { setAnchorEl(null); }; - const { notifications: notificationItems = [] } = useGetUserNotificationQuery(); + + const { notifications: allNotifications = [], refetch } = + useGetUserNotificationQuery({ + recipientId: signatureId, + }); + + const inboxNotifications = allNotifications.filter( + (n) => !n.isRead && !n.isArchived, + ); + + const handleMarkAsRead = async (id: number) => { + await postMarkNotificationAsRead({ notificationId: String(id) }); + refetch(); + }; + const handleMarkAsUnread = async (id: number) => { + await postMarkNotificationAsUnread({ notificationId: String(id) }); + refetch(); + }; + + const renderNotifications = (notifications: any[]) => { + if (notifications.length === 0) { + return ( + <MenuItem disableRipple className="pointer-events-none"> + <Box className="mb-4 flex flex-col text-wrap py-2 text-complementary-500"> + {signatureId ? ( + <> + <Typography variant="subtitle2" className="font-bold"> + Mempool Clear + </Typography> + <Typography variant="body1">You're all caught up.</Typography> + </> + ) : ( + <Typography variant="body1"> + Please login to view notifications + </Typography> + )} + </Box> + </MenuItem> + ); + } + return notifications.map((item) => ( + <NotificationItem + key={item.id} + notification={item} + onMarkAsRead={handleMarkAsRead} + onMarkAsUnread={handleMarkAsUnread} + /> + )); + }; return ( <div> - <div className="cursor-pointer"> + <Badge + className="cursor-pointer" + badgeContent={inboxNotifications.length || 0} + color="warning" + max={10} + > <img src="/svgs/bell.svg" id="notification-dropdown" @@ -31,11 +90,11 @@ export default function NotificationDrawer() { onClick={handleClick} aria-expanded={open ? 'true' : undefined} /> - </div> + </Badge> <Menu id="notification-drawer" MenuListProps={{ - 'aria-labelledby': 'notification-drawer', + 'aria-labelledby': 'notification-dropdown', }} anchorEl={anchorEl} open={open} @@ -43,7 +102,7 @@ export default function NotificationDrawer() { TransitionComponent={Grow} anchorOrigin={{ vertical: 'bottom', - horizontal: 'center', + horizontal: 'left', }} transformOrigin={{ vertical: 'top', @@ -54,57 +113,22 @@ export default function NotificationDrawer() { borderRadius: '0 0 1rem 1rem', boxShadow: '1px 2px 11px 0 rgba(0, 18, 61, 0.37)', bgcolor: '#F3F5FF', + maxHeight: '80vh', + width: '20rem', + overflow: 'auto', }, - '.MuiMenu-list': { padding: 0 }, + '.MuiMenu-list': { padding: 1 }, }} > - <Box className="w-72"> - <Box className="flex w-full items-center justify-between p-2"> - <Typography variant="h4">Notifications</Typography> - </Box> - {!!notificationItems && ( - <Box className="relative flex w-full flex-col"> - {notificationItems.map((item, index) => ( - <MenuItem - key={index} - onClick={handleClose} - sx={{ - '&:hover': { - backgroundColor: '#FFC19D', - }, - }} - > - <Box className="flex max-w-60 gap-4"> - <Box className="flex flex-col text-wrap text-complementary-500"> - <p className="p-0 text-base font-normal">{item.label}</p> - <p className="text-xs font-normal leading-4"> - {item.text} - </p> - </Box> - </Box> - </MenuItem> - ))} - </Box> - )} - </Box> - {!notificationItems || - (notificationItems.length <= 0 && ( - <MenuItem - onClick={handleClose} - sx={{ - '&:hover': { - backgroundColor: '#FFC19D', - }, - }} - > - <Box className="mb-4 flex flex-col text-wrap py-2 text-complementary-500"> - <Typography variant="subtitle2" className="font-bold"> - Mempool Clear - </Typography> - <Typography variant="body1">You're all caught up.</Typography> - </Box> - </MenuItem> - ))} + {/* Header */} + <MenuItem + disabled + className="flex w-full items-center justify-between p-1" + > + <Typography variant="h6">Notifications</Typography> + </MenuItem> + <Divider /> + {renderNotifications(allNotifications)} </Menu> </div> ); diff --git a/frontend/src/components/organisms/NewNoteForm.tsx b/frontend/src/components/organisms/NewNoteForm.tsx index 8c11ade16619e0406835454fb261704a24be51b0..70c3894e9eb0ee68edf7a32daf36d3bb7bfa90ae 100644 --- a/frontend/src/components/organisms/NewNoteForm.tsx +++ b/frontend/src/components/organisms/NewNoteForm.tsx @@ -47,7 +47,7 @@ const NewNoteForm = () => { } setIsLoading(true); const stakeAddress = Address.from_bytes( - Buffer.from(stakeKey, 'hex'), + Buffer.from(stakeKey, 'hex') as any, ).to_bech32(); const { postTag, postText, postTitle, postVisibility } = data; const newNote = { @@ -63,7 +63,7 @@ const NewNoteForm = () => { addSuccessAlert('Note Created Successfully!'); setIsLoading(false); } catch (error) { - addErrorAlert('Note Creation Failed!'); + addErrorAlert(String(error?.response?.data?.message) || 'Note Creation Failed!'); console.log(error); setIsLoading(false); } diff --git a/frontend/src/components/organisms/NewProfile.tsx b/frontend/src/components/organisms/NewProfile.tsx index 8d60acbe1bf7bf2c6f089f4e7643d206245eb910..4834257be3c45734e93f9ee7a7fded07381eeac9 100644 --- a/frontend/src/components/organisms/NewProfile.tsx +++ b/frontend/src/components/organisms/NewProfile.tsx @@ -223,7 +223,7 @@ const NewProfile = () => { } const stakeAddress = Address.from_bytes( - Buffer.from(stakeKey, 'hex'), + Buffer.from(stakeKey, 'hex') as any, ).to_bech32(); const formData: drepInput = { signature, @@ -238,7 +238,7 @@ const NewProfile = () => { setNewDrepId(insertedDrep.raw[0].id); setCurrentRegistrationStep(2); addSuccessAlert('DRep Profile Created Successfully!'); - setItemToLocalStorage('token', token); + setItemToLocalStorage('token_1694', token); setItemToLocalStorage('signatures', { signature, key }); setIsLoggedIn(true); router.push(`/dreps/workflow/profile/update/step2`); diff --git a/frontend/src/context/drepContext.tsx b/frontend/src/context/drepContext.tsx index fb249d6449f84e71e130da8683d10c99fa7092e9..4bacee3203ae175952d5603b4bd2c43116311b4e 100644 --- a/frontend/src/context/drepContext.tsx +++ b/frontend/src/context/drepContext.tsx @@ -15,12 +15,12 @@ import { getItemFromLocalStorage, removeItemFromLocalStorage, } from '@/lib'; -import { processExternalMetadata } from '@/lib/metadataProcessor'; import { getSingleDRepViaVoterId } from '@/services/requests/getSingleDrepViaVoterId'; import { getItemFromIndexedDB } from '@/lib/indexedDb'; import { getDRepRegStatus } from '@/services/requests/getDRepRegStatus'; import { getDRepMetadata } from '@/services/requests/getDRepMetadata'; import { blake2bHex } from 'blakejs'; +import { getSession } from '@/services/requests/getSession'; interface DRepContext { step1Status: stepStatus['status']; @@ -29,7 +29,7 @@ interface DRepContext { step4Status: stepStatus['status']; isLoggedIn: boolean; loginModalOpen: boolean; - hideCloseButtonOnLoginModal: boolean + hideCloseButtonOnLoginModal: boolean; isWalletListModalOpen: boolean; hideCloseButtonOnWalletListModal: boolean; isNotDRepErrorModalOpen: boolean; @@ -47,7 +47,9 @@ interface DRepContext { setCurrentRegistrationStep: React.Dispatch<React.SetStateAction<number>>; setIsNotDRepErrorModalOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsWalletListModalOpen: React.Dispatch<React.SetStateAction<boolean>>; - setHideCloseButtonOnWalletListModal: React.Dispatch<React.SetStateAction<boolean>>; + setHideCloseButtonOnWalletListModal: React.Dispatch< + React.SetStateAction<boolean> + >; setCurrentLocale: React.Dispatch<React.SetStateAction<string>>; setNewDrepId: React.Dispatch<React.SetStateAction<number>>; setLoginModalOpen: React.Dispatch<React.SetStateAction<boolean>>; @@ -57,6 +59,8 @@ interface DRepContext { metadataJsonHash: any; setMetadataJsonHash: React.Dispatch<React.SetStateAction<any>>; handleRefresh: () => Promise<void>; + signatureId: any; + setSignatureId: React.Dispatch<React.SetStateAction<any>>; } interface Props { @@ -73,21 +77,31 @@ DRepContext.displayName = 'DRepContext'; function DRepProvider(props: Props) { const [isWalletListModalOpen, setIsWalletListModalOpen] = useState(false); - const [hideCloseButtonOnWalletListModal, setHideCloseButtonOnWalletListModal] = useState(false); + const [ + hideCloseButtonOnWalletListModal, + setHideCloseButtonOnWalletListModal, + ] = useState(false); const { sharedState, updateSharedState } = useSharedContext(); const [isNotDRepErrorModalOpen, setIsNotDRepErrorModalOpen] = useState(false); const [metadataJsonLd, setMetadataJsonLd] = useState(null); + const [signatureId, setSignatureId] = useState(null); const [metadataJsonHash, setMetadataJsonHash] = useState(null); const [isDRepRegistered, setIsDRepRegistered] = useState(false); - const [currentRegistrationStep, setCurrentRegistrationStep] = useState<currentRegistrationStep['step']>(1); + const [currentRegistrationStep, setCurrentRegistrationStep] = + useState<currentRegistrationStep['step']>(1); const [drepId, setNewDrepId] = useState<number | null>(null); const [isLoggedIn, setIsLoggedIn] = useState(false); const [loginModalOpen, setLoginModalOpen] = useState(false); - const [hideCloseButtonOnLoginModal, setHideCloseButtonOnLoginModal] = useState(false); - const [step1Status, setStep1Status] = useState<stepStatus['status']>('pending'); - const [step2Status, setStep2Status] = useState<stepStatus['status']>('pending'); - const [step3Status, setStep3Status] = useState<stepStatus['status']>('pending'); - const [step4Status, setStep4Status] = useState<stepStatus['status']>('pending'); + const [hideCloseButtonOnLoginModal, setHideCloseButtonOnLoginModal] = + useState(false); + const [step1Status, setStep1Status] = + useState<stepStatus['status']>('pending'); + const [step2Status, setStep2Status] = + useState<stepStatus['status']>('pending'); + const [step3Status, setStep3Status] = + useState<stepStatus['status']>('pending'); + const [step4Status, setStep4Status] = + useState<stepStatus['status']>('pending'); //will fix later const [currentLocale, setCurrentLocale] = useState<string | null>('en'); useEffect(() => { @@ -96,12 +110,18 @@ function DRepProvider(props: Props) { isNotDRepErrorModalOpen, isLoggedIn, isLoginModalOpen: loginModalOpen, - hideCloseButtonOnLoginModal: hideCloseButtonOnLoginModal + hideCloseButtonOnLoginModal: hideCloseButtonOnLoginModal, }); - }, [isWalletListModalOpen, isNotDRepErrorModalOpen, isLoggedIn, loginModalOpen, hideCloseButtonOnLoginModal]); + }, [ + isWalletListModalOpen, + isNotDRepErrorModalOpen, + isLoggedIn, + loginModalOpen, + hideCloseButtonOnLoginModal, + ]); useEffect(() => { persistLogin(); - }, []); + }, [sharedState?.stakeKey]); useEffect(() => { handleDrepProfileCreationState(); }, [sharedState?.dRepIDBech32]); @@ -143,7 +163,11 @@ function DRepProvider(props: Props) { if (res) { metadataJsonLd = res; setMetadataJsonLd(res); - const jsonHash = blake2bHex(JSON.stringify(metadataJsonLd), undefined, 32); + const jsonHash = blake2bHex( + JSON.stringify(metadataJsonLd), + undefined, + 32, + ); setMetadataJsonHash(jsonHash); } } catch (e) { @@ -171,7 +195,7 @@ function DRepProvider(props: Props) { if (!metadataJsonLd) return; const metadataBody = metadataJsonLd?.body; - + if (metadataBody?.givenName || metadataBody?.bio || metadataBody?.email) { setStep1Status('success'); } @@ -192,8 +216,8 @@ function DRepProvider(props: Props) { } }; - const persistLogin = () => { - const token = getItemFromLocalStorage('token'); + const persistLogin = async () => { + const token = getItemFromLocalStorage('token_1694'); if (token) { const { decoded: { exp, ...rest }, @@ -202,15 +226,28 @@ function DRepProvider(props: Props) { //check if token is expired if (exp < Date.now() / 1000) { setIsLoggedIn(false); - removeItemFromLocalStorage('token'); + removeItemFromLocalStorage('token_1694'); + removeItemFromLocalStorage('signatures'); + return; + } + if (!sharedState?.stakeKey) { + setIsLoggedIn(true); + updateSharedState({ loginCredentials: { signature, key } }); return; } + //get session data from backend + const sessionData = await getSession({ + payload: { signature, key, stakeKey: sharedState?.stakeKey }, + }); + setSignatureId(sessionData?.id); setIsLoggedIn(true); updateSharedState({ loginCredentials: { signature, key } }); } }; const logout = useCallback(async () => { - removeItemFromLocalStorage('token'); + removeItemFromLocalStorage('token_1694'); + removeItemFromLocalStorage('signatures'); + setSignatureId(null) setIsLoggedIn(false); }, []); @@ -220,6 +257,8 @@ function DRepProvider(props: Props) { isNotDRepErrorModalOpen, currentLocale, drepId, + signatureId, + setSignatureId, isLoggedIn, step1Status, step2Status, @@ -259,6 +298,7 @@ function DRepProvider(props: Props) { currentRegistrationStep, currentLocale, drepId, + signatureId, loginModalOpen, step1Status, step2Status, @@ -276,7 +316,9 @@ function DRepProvider(props: Props) { {props.children} {sharedState.isWalletListModalOpen && ( <div className="blur-container absolute left-0 top-0 z-50 flex h-screen w-full items-center justify-center"> - <ChooseWalletModal hideCloseButton={hideCloseButtonOnWalletListModal} /> + <ChooseWalletModal + hideCloseButton={hideCloseButtonOnWalletListModal} + /> </div> )} {sharedState.isNotDRepErrorModalOpen && ( diff --git a/frontend/src/context/sharedContext.tsx b/frontend/src/context/sharedContext.tsx index 357836a07e2cad73afec28ce1c3f1c90083d4e1c..4a4d68cbf620eabde74c825e412cbefdf854e988 100644 --- a/frontend/src/context/sharedContext.tsx +++ b/frontend/src/context/sharedContext.tsx @@ -15,6 +15,7 @@ export function SharedProvider({ children }) { key:null }, dRepIDBech32: '', + stakeKey: '', }); const updateSharedState = useCallback((newState) => { diff --git a/frontend/src/context/walletContext.tsx b/frontend/src/context/walletContext.tsx index 4325eb69cb2fee73c92397bd62463fab19a28e1e..32da25f526f2d4f8e3c4d536b4c8f3aef6b0a77e 100644 --- a/frontend/src/context/walletContext.tsx +++ b/frontend/src/context/walletContext.tsx @@ -194,7 +194,7 @@ function CardanoProvider(props: Props) { try { const raw = await enabledApi.getChangeAddress(); const changeAddress = Address.from_bytes( - Buffer.from(raw, 'hex'), + Buffer.from(raw, 'hex') as any, ).to_bech32(); setWalletState((prev) => ({ ...prev, changeAddress })); } catch (err) { @@ -205,20 +205,20 @@ function CardanoProvider(props: Props) { try { const balanceCBORHex = await enabledApi.getBalance(); const balance = Number( - Value.from_bytes(Buffer.from(balanceCBORHex, 'hex')).coin().to_str(), + Value.from_bytes(Buffer.from(balanceCBORHex, 'hex') as any) + .coin() + .to_str(), ); setWalletState((prev) => ({ ...prev, balance })); } catch (err) { console.log(err); - } - }; - + }} const getUsedAddresses = async (enabledApi: CardanoApiWallet) => { try { const raw = await enabledApi.getUsedAddresses(); const rawFirst = raw[0]; const usedAddress = Address.from_bytes( - Buffer.from(rawFirst, 'hex'), + Buffer.from(rawFirst, 'hex') as any, ).to_bech32(); setWalletState((prev) => ({ ...prev, usedAddress })); } catch (err) { @@ -236,7 +236,7 @@ function CardanoProvider(props: Props) { for (const rawUtxo of rawUtxos) { const utxo = TransactionUnspentOutput.from_bytes( - Buffer.from(rawUtxo, 'hex'), + Buffer.from(rawUtxo, 'hex') as any, ); const input = utxo.input(); const txid = Buffer.from(input.transaction_id().to_bytes()).toString( @@ -328,21 +328,25 @@ function CardanoProvider(props: Props) { .catch((e) => { throw e.info; }); - - // Get the network ID of the connected wallet - const network = await enabledApi.getNetworkId(); - const requiredNetwork = CONFIGURED_NETWORK_ID; - if (requiredNetwork !== network) { - if (requiredNetwork == 1) { - addErrorAlert('Mainnet network wallet required, please switch to the mainnet'); - } else { - addErrorAlert('Testnet network wallet required, please switch to the testnet'); + // Get the network ID of the connected wallet + const network = await enabledApi.getNetworkId(); + const requiredNetwork = CONFIGURED_NETWORK_ID; + + if (requiredNetwork !== network) { + if (requiredNetwork == 1) { + addErrorAlert( + 'Mainnet network wallet required, please switch to the mainnet', + ); + } else { + addErrorAlert( + 'Testnet network wallet required, please switch to the testnet', + ); + } + setIsEnableLoading(null); + setIsEnabling(false); + return { status: 'WRONG_NETWORK' }; } - setIsEnableLoading(null); - setIsEnabling(false); - return { status: 'WRONG_NETWORK' }; - } await getChangeAddress(enabledApi); await getUsedAddresses(enabledApi); @@ -353,7 +357,7 @@ function CardanoProvider(props: Props) { if (!enabledExtensions.some((item) => item.cip === 95)) { throw new Error('errors.walletNoCIP95FunctionsEnabled'); } - + //Check and set wallet balance await getBalance(enabledApi); // Check and set wallet address @@ -414,14 +418,14 @@ function CardanoProvider(props: Props) { if (savedStakeKey && stakeKeysList.includes(savedStakeKey)) { setStakeKey(savedStakeKey); const stakeAddress = Address.from_bytes( - Buffer.from(savedStakeKey, 'hex'), + Buffer.from(savedStakeKey, 'hex') as any, ).to_bech32(); setStakeKeyBech32(stakeAddress); stakeKeySet = true; } else if (stakeKeysList.length === 1) { setStakeKey(stakeKeysList[0]); const stakeAddress = Address.from_bytes( - Buffer.from(stakeKeysList[0], 'hex'), + Buffer.from(stakeKeysList[0], 'hex') as any, ).to_bech32(); setStakeKeyBech32(stakeAddress); setItemToLocalStorage( @@ -441,6 +445,9 @@ function CardanoProvider(props: Props) { updateSharedState({ isWalletListModalOpen: false, dRepIDBech32: dRepIDs?.dRepIDBech32 || '', + stakeKey: Address.from_bytes( + Buffer.from(stakeKeysList[0], 'hex') as any, + ).to_bech32(), }); return { status: 'ok', stakeKey: stakeKeySet }; } catch (e) { @@ -564,7 +571,7 @@ function CardanoProvider(props: Props) { true, ); txVkeyWitnesses = TransactionWitnessSet.from_bytes( - Buffer.from(txVkeyWitnesses, 'hex'), + Buffer.from(txVkeyWitnesses, 'hex') as any, ); transactionWitnessSet.set_vkeys(txVkeyWitnesses.vkeys()); const signedTx = Transaction.new(tx.body(), transactionWitnessSet); @@ -629,7 +636,7 @@ function CardanoProvider(props: Props) { true, ); txVkeyWitnesses = TransactionWitnessSet.from_bytes( - Buffer.from(txVkeyWitnesses, 'hex'), + Buffer.from(txVkeyWitnesses, 'hex') as any, ); transactionWitnessSet.set_vkeys(txVkeyWitnesses.vkeys()); const signedTx = Transaction.new(tx.body(), transactionWitnessSet); diff --git a/frontend/src/hooks/useGetUserNotificationQuery.ts b/frontend/src/hooks/useGetUserNotificationQuery.ts index 31daf8ebfcf90d1c916db9223a60d98444d65ade..954e88fc0fae8bc00f20061d301f9aba570b8874 100644 --- a/frontend/src/hooks/useGetUserNotificationQuery.ts +++ b/frontend/src/hooks/useGetUserNotificationQuery.ts @@ -1,12 +1,16 @@ import { useQuery } from 'react-query'; -import {getUserNotifications} from "@/services/requests/getUserNotifications"; +import { getUserNotifications } from '@/services/requests/getUserNotifications'; -export const useGetUserNotificationQuery = () => { - const { data, isLoading } = useQuery({ - queryKey: 'notifications', - queryFn: async () => - await getUserNotifications(), +export const useGetUserNotificationQuery = ({ + recipientId, +}: { + recipientId: string; +}) => { + const { data, isLoading, refetch } = useQuery({ + queryKey: ['notifications', recipientId], + queryFn: async () => await getUserNotifications({ recipientId }), + enabled: !!recipientId, }); - return { notifications: data, loading: isLoading }; + return { notifications: data, loading: isLoading, refetch }; }; diff --git a/frontend/src/services/requests/getSession.ts b/frontend/src/services/requests/getSession.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb5c21ae8f9a41c9440e1fe6bec6666280dff65d --- /dev/null +++ b/frontend/src/services/requests/getSession.ts @@ -0,0 +1,12 @@ +import axiosInstance from '../axiosInstance'; +type Payload = { + stakeKey: string; + signature: string; + key: string; +}; +export const getSession = async ({ payload }: { payload: Payload }) => { + const response = await axiosInstance.post('auth/session', { + payload, + }); + return response.data; +}; diff --git a/frontend/src/services/requests/getUserNotifications.ts b/frontend/src/services/requests/getUserNotifications.ts index 7cf25e96db0e1ebff2624f9d96607da123f275f6..5f12c601a842cfa5fffd63cefcee51c7bd729344 100644 --- a/frontend/src/services/requests/getUserNotifications.ts +++ b/frontend/src/services/requests/getUserNotifications.ts @@ -1,7 +1,11 @@ +import { Notification } from '../../../types/commonTypes'; import axiosInstance from '../axiosInstance'; -export const getUserNotifications = async () => { - const response = await axiosInstance.get(`/notifications` ); - - return response.data; -}; +export const getUserNotifications = async ({ + recipientId, +}: { + recipientId: string; +}) => { + const response = await axiosInstance.get(`/notifications/${recipientId}/all`); + return response.data as Notification[]; +} diff --git a/frontend/src/services/requests/postNotificationRequests.ts b/frontend/src/services/requests/postNotificationRequests.ts new file mode 100644 index 0000000000000000000000000000000000000000..23433e39d89b9e9851bab7699aca9cfb953095ed --- /dev/null +++ b/frontend/src/services/requests/postNotificationRequests.ts @@ -0,0 +1,53 @@ +import axiosInstance from '../axiosInstance'; + +export const postCreateNotification = async ({ + recipientId, + title, + message, + type, + isRead, + isArchived, + isPersistent, +}: { + recipientId: string; + title: string; + message: string; + type: string; + isRead?: boolean; + isArchived?: boolean; + isPersistent?: boolean; +}) => { + const response = await axiosInstance.post(`/notifications/${recipientId}/new`, { + title, + message, + type, + isRead, + isArchived, + isPersistent, + }); + return response.data; +}; + + +export const postMarkNotificationAsRead = async ({ + notificationId, +}: { + notificationId: string; +}) => { + const response = await axiosInstance.post( + `/notifications/${notificationId}/read`, + ); + return response.data; +}; + + +export const postMarkNotificationAsUnread = async ({ + notificationId, +}: { + notificationId: string; +}) => { + const response = await axiosInstance.post( + `/notifications/${notificationId}/unread`, + ); + return response.data; +}; diff --git a/frontend/types/commonTypes.ts b/frontend/types/commonTypes.ts index 7cc8230e831a44f4919d2dee3c4aab79e27d64a6..c4c226003141c34142cfafa2229af6b5480fa9c2 100644 --- a/frontend/types/commonTypes.ts +++ b/frontend/types/commonTypes.ts @@ -43,6 +43,19 @@ export type IPFSResponse = { ipfs_hash: string; size: number; }; +export type NotificationType = 'info' | 'warning' | 'error'; +export type Notification = { + id: number; + title: string; + message: string; + type: NotificationType; + createdAt: Date; + isRead: boolean; + isArchived: boolean; + deletedAt: Date; + isPersistent: boolean; + recipient: string; +}; export type Metrics = { totalRegisteredDReps: number; totalActiveDReps: number; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 03a7ce0a7518fb5065cc750bdb6d8ff58b9f3aba..486563663134a5aef15cd270ba800e7f64224b18 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -4752,6 +4752,11 @@ data-view-byte-offset@^1.0.0: es-errors "^1.3.0" is-data-view "^1.0.1" +date-fns@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14" + integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg== + dayjs@^1.10.4: version "1.11.10" resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz"