From aba49528514490ceca36436d0bb57fc8cad9ab2b Mon Sep 17 00:00:00 2001 From: Charity Date: Wed, 19 Aug 2026 11:47:26 +0300 Subject: [PATCH 01/10] create email registry entity and implement database migrations --- src/API/src/app.module.ts | 2 + .../email-registry/email-registry.module.ts | 8 +++ .../email-registry/email-registry.resolver.ts | 4 ++ .../email-registry/email-registry.service.ts | 4 ++ .../entities/email-registry.entity.ts | 60 +++++++++++++++++++ .../1787088548144-CreateEmailRegistry.ts | 43 +++++++++++++ .../1787127691007-RemoveBaseEntityExtended.ts | 21 +++++++ 7 files changed, 142 insertions(+) create mode 100644 src/API/src/db/email-registry/email-registry.module.ts create mode 100644 src/API/src/db/email-registry/email-registry.resolver.ts create mode 100644 src/API/src/db/email-registry/email-registry.service.ts create mode 100644 src/API/src/db/email-registry/entities/email-registry.entity.ts create mode 100644 src/API/src/db/migrations/1787088548144-CreateEmailRegistry.ts create mode 100644 src/API/src/db/migrations/1787127691007-RemoveBaseEntityExtended.ts diff --git a/src/API/src/app.module.ts b/src/API/src/app.module.ts index e7ae1147f..cbdb9281e 100644 --- a/src/API/src/app.module.ts +++ b/src/API/src/app.module.ts @@ -39,6 +39,7 @@ import { ScheduleModule } from '@nestjs/schedule'; import { BlobCleanupService } from './db/shared/blob-cleanup.service'; import { AzureBlobService } from './db/azure-blob/azure-blob.service'; import { CountryModule } from './db/country/country.module'; +import { EmailRegistryModule } from './db/email-registry/email-registry.module'; @Module({ imports: [ @@ -96,6 +97,7 @@ import { CountryModule } from './db/country/country.module'; ExportsModule, FullOccurrenceDataModule, CountryModule, + EmailRegistryModule, ], controllers: [ConfigController], providers: [AzureBlobService, BlobCleanupService], diff --git a/src/API/src/db/email-registry/email-registry.module.ts b/src/API/src/db/email-registry/email-registry.module.ts new file mode 100644 index 000000000..6d1d5c444 --- /dev/null +++ b/src/API/src/db/email-registry/email-registry.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { EmailRegistryService } from './email-registry.service'; +import { EmailRegistryResolver } from './email-registry.resolver'; + +@Module({ + providers: [EmailRegistryService, EmailRegistryResolver] +}) +export class EmailRegistryModule {} diff --git a/src/API/src/db/email-registry/email-registry.resolver.ts b/src/API/src/db/email-registry/email-registry.resolver.ts new file mode 100644 index 000000000..d4e85ebe2 --- /dev/null +++ b/src/API/src/db/email-registry/email-registry.resolver.ts @@ -0,0 +1,4 @@ +import { Resolver } from '@nestjs/graphql'; + +@Resolver() +export class EmailRegistryResolver {} diff --git a/src/API/src/db/email-registry/email-registry.service.ts b/src/API/src/db/email-registry/email-registry.service.ts new file mode 100644 index 000000000..25b515e12 --- /dev/null +++ b/src/API/src/db/email-registry/email-registry.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class EmailRegistryService {} diff --git a/src/API/src/db/email-registry/entities/email-registry.entity.ts b/src/API/src/db/email-registry/entities/email-registry.entity.ts new file mode 100644 index 000000000..a95399061 --- /dev/null +++ b/src/API/src/db/email-registry/entities/email-registry.entity.ts @@ -0,0 +1,60 @@ +import { ObjectType, Field, registerEnumType } from "@nestjs/graphql"; +// import { BaseEntityExtended } from "../../base.entity.extended"; +import { Column, Entity, PrimaryColumn } from "typeorm"; +import { IsEnum } from "class-validator"; + +export enum AccountStatus { + PENDING_VERIFICATION = 'pending_verification', + VERIFIED = 'verified', + DEACTIVATED = 'deactivated' +} +registerEnumType(AccountStatus, { + name: 'AccountStatus', + description: 'The current verification or activity state of the email registry account.', +}); + + +@Entity('email_registry') +@ObjectType({ description: 'Email Registry'}) + +//data model to store email registry information. This will be used to send emails to users for various events + +export class EmailRegistry{ + + @Field(() => String) + @PrimaryColumn() + id: string; + + @Field(() => String) + @Column() + first_name: string; + + @Field(() => String) + @Column() + last_name: string; + + @Field(() => String) + @Column({ unique: true , nullable: false}) + email: string; + + @Field(() => AccountStatus) + @Column({default: 'pending_verification'}) + @IsEnum(AccountStatus) + account_status: AccountStatus; + + @Field() + @Column({default: true}) + notifications_enabled: boolean; + + @Field() + @Column() + verification_token: string; + + @Field(() => Date) + @Column({nullable: false, type: 'timestamp'}) + token_expires_at: Date; + + +} + + diff --git a/src/API/src/db/migrations/1787088548144-CreateEmailRegistry.ts b/src/API/src/db/migrations/1787088548144-CreateEmailRegistry.ts new file mode 100644 index 000000000..d31502d11 --- /dev/null +++ b/src/API/src/db/migrations/1787088548144-CreateEmailRegistry.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateEmailRegistry1787088548144 implements MigrationInterface { + name = 'CreateEmailRegistry1787088548144' + + public async up(queryRunner: QueryRunner): Promise { + // Drop old columns from email_registry + await queryRunner.query(`ALTER TABLE "email_registry" DROP COLUMN "verification_code"`); + await queryRunner.query(`ALTER TABLE "email_registry" DROP COLUMN "code_expires_at"`); + await queryRunner.query(`ALTER TABLE "email_registry" DROP COLUMN "is_news_notification_enabled"`); + await queryRunner.query(`ALTER TABLE "email_registry" DROP COLUMN "news_last_modified_at"`); + await queryRunner.query(`ALTER TABLE "email_registry" DROP COLUMN "is_new_dataset_notification_enabled"`); + await queryRunner.query(`ALTER TABLE "email_registry" DROP COLUMN "new_dataset_last_modified_at"`); + await queryRunner.query(`ALTER TABLE "email_registry" DROP COLUMN "is_verified"`); + + // Add new columns to email_registry + await queryRunner.query(`ALTER TABLE "email_registry" ADD "first_name" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "email_registry" ADD "last_name" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "email_registry" ADD "account_status" character varying NOT NULL DEFAULT 'pending_verification'`); + await queryRunner.query(`ALTER TABLE "email_registry" ADD "notifications_enabled" boolean NOT NULL DEFAULT true`); + await queryRunner.query(`ALTER TABLE "email_registry" ADD "verification_token" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "email_registry" ADD "token_expires_at" TIMESTAMP NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + // Rollback new columns + await queryRunner.query(`ALTER TABLE "email_registry" DROP COLUMN "token_expires_at"`); + await queryRunner.query(`ALTER TABLE "email_registry" DROP COLUMN "verification_token"`); + await queryRunner.query(`ALTER TABLE "email_registry" DROP COLUMN "notifications_enabled"`); + await queryRunner.query(`ALTER TABLE "email_registry" DROP COLUMN "account_status"`); + await queryRunner.query(`ALTER TABLE "email_registry" DROP COLUMN "last_name"`); + await queryRunner.query(`ALTER TABLE "email_registry" DROP COLUMN "first_name"`); + + // Restore old columns + await queryRunner.query(`ALTER TABLE "email_registry" ADD "is_verified" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "email_registry" ADD "new_dataset_last_modified_at" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE "email_registry" ADD "is_new_dataset_notification_enabled" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "email_registry" ADD "news_last_modified_at" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE "email_registry" ADD "is_news_notification_enabled" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "email_registry" ADD "code_expires_at" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE "email_registry" ADD "verification_code" text`); + } +} diff --git a/src/API/src/db/migrations/1787127691007-RemoveBaseEntityExtended.ts b/src/API/src/db/migrations/1787127691007-RemoveBaseEntityExtended.ts new file mode 100644 index 000000000..d6906d662 --- /dev/null +++ b/src/API/src/db/migrations/1787127691007-RemoveBaseEntityExtended.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class RemoveBaseEntityExtended1787127691007 implements MigrationInterface { + name = 'RemoveBaseEntityExtended1787127691007' + + public async up(queryRunner: QueryRunner): Promise { + // Drop base entity columns from email_registry + await queryRunner.query(`ALTER TABLE "email_registry" DROP COLUMN "owner"`); + await queryRunner.query(`ALTER TABLE "email_registry" DROP COLUMN "creation"`); + await queryRunner.query(`ALTER TABLE "email_registry" DROP COLUMN "updater"`); + await queryRunner.query(`ALTER TABLE "email_registry" DROP COLUMN "modified"`); + } + + public async down(queryRunner: QueryRunner): Promise { + // Restore base entity columns to email_registry + await queryRunner.query(`ALTER TABLE "email_registry" ADD "modified" TIMESTAMP NOT NULL DEFAULT now()`); + await queryRunner.query(`ALTER TABLE "email_registry" ADD "updater" character varying`); + await queryRunner.query(`ALTER TABLE "email_registry" ADD "creation" TIMESTAMP NOT NULL DEFAULT now()`); + await queryRunner.query(`ALTER TABLE "email_registry" ADD "owner" character varying`); + } +} From fe434d02628f1e11da097fd02d5be7d67591ef91 Mon Sep 17 00:00:00 2001 From: Charity Date: Wed, 19 Aug 2026 12:10:06 +0300 Subject: [PATCH 02/10] add subsribe dto for incoming payload validation --- .../src/db/email-registry/dto/subscribe-email.dto.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/API/src/db/email-registry/dto/subscribe-email.dto.ts diff --git a/src/API/src/db/email-registry/dto/subscribe-email.dto.ts b/src/API/src/db/email-registry/dto/subscribe-email.dto.ts new file mode 100644 index 000000000..d3fef01e1 --- /dev/null +++ b/src/API/src/db/email-registry/dto/subscribe-email.dto.ts @@ -0,0 +1,11 @@ +import { IsEmail, IsString } from 'class-validator'; + +export class SubscribeEmailDto { + + @IsString() + first_name: string; + last_name: string; + + @IsEmail() + email:string; +} \ No newline at end of file From 29e95fb3c3576e4ad900cc07692dc8343e1c1450 Mon Sep 17 00:00:00 2001 From: Charity Date: Wed, 19 Aug 2026 12:20:13 +0300 Subject: [PATCH 03/10] chore: modify subscribe dto --- src/API/src/db/email-registry/dto/subscribe-email.dto.ts | 7 ++++++- src/API/src/db/email-registry/email-registry.controller.ts | 0 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 src/API/src/db/email-registry/email-registry.controller.ts diff --git a/src/API/src/db/email-registry/dto/subscribe-email.dto.ts b/src/API/src/db/email-registry/dto/subscribe-email.dto.ts index d3fef01e1..eee31fa2f 100644 --- a/src/API/src/db/email-registry/dto/subscribe-email.dto.ts +++ b/src/API/src/db/email-registry/dto/subscribe-email.dto.ts @@ -1,11 +1,16 @@ -import { IsEmail, IsString } from 'class-validator'; +import { IsEmail, IsString, IsBoolean } from 'class-validator'; export class SubscribeEmailDto { @IsString() first_name: string; + + @IsString() last_name: string; @IsEmail() email:string; + + @IsBoolean() + notifications_enabled: boolean; } \ No newline at end of file diff --git a/src/API/src/db/email-registry/email-registry.controller.ts b/src/API/src/db/email-registry/email-registry.controller.ts new file mode 100644 index 000000000..e69de29bb From 172402e3e8e93b0fca4323862b2dba1f112f7335 Mon Sep 17 00:00:00 2001 From: Charity Date: Thu, 20 Aug 2026 14:52:58 +0300 Subject: [PATCH 04/10] feat(subscriptions): add verification, subscribe, and unsubscribe logic --- .../email-registry/dto/subscribe-email.dto.ts | 18 ++- .../dto/subscription-response.dto.ts | 24 +++ .../dto/unsubscribe-email.dto.ts | 16 ++ .../db/email-registry/dto/verify-token.dto.ts | 7 + .../email-registry.controller.ts | 36 +++++ .../email-registry/email-registry.service.ts | 149 +++++++++++++++++- .../entities/email-registry.entity.ts | 6 +- .../1787225159465-AddUnsubscribeToken.ts | 17 ++ 8 files changed, 263 insertions(+), 10 deletions(-) create mode 100644 src/API/src/db/email-registry/dto/subscription-response.dto.ts create mode 100644 src/API/src/db/email-registry/dto/unsubscribe-email.dto.ts create mode 100644 src/API/src/db/email-registry/dto/verify-token.dto.ts create mode 100644 src/API/src/db/migrations/1787225159465-AddUnsubscribeToken.ts diff --git a/src/API/src/db/email-registry/dto/subscribe-email.dto.ts b/src/API/src/db/email-registry/dto/subscribe-email.dto.ts index eee31fa2f..ed8ca9315 100644 --- a/src/API/src/db/email-registry/dto/subscribe-email.dto.ts +++ b/src/API/src/db/email-registry/dto/subscribe-email.dto.ts @@ -1,16 +1,20 @@ -import { IsEmail, IsString, IsBoolean } from 'class-validator'; +import { IsEmail, IsString, IsBoolean, IsNotEmpty } from 'class-validator'; export class SubscribeEmailDto { - @IsString() + @IsString({ message: 'First name must be a text value.' }) + @IsNotEmpty({ message: 'First name cannot be left blank.' }) first_name: string; - @IsString() + @IsString({ message: 'Last name must be a text value.' }) + @IsNotEmpty({ message: 'Last name cannot be left blank.' }) last_name: string; - @IsEmail() - email:string; + @IsEmail({}, { message: 'Please enter a valid email address.' }) + @IsNotEmpty({ message: 'Email address is required.' }) + email: string; - @IsBoolean() + @IsBoolean({ message: 'Notifications enabled must be a true or false value.' }) + // Note: No @IsNotEmpty needed here, as false is a valid boolean value notifications_enabled: boolean; -} \ No newline at end of file +} diff --git a/src/API/src/db/email-registry/dto/subscription-response.dto.ts b/src/API/src/db/email-registry/dto/subscription-response.dto.ts new file mode 100644 index 000000000..c42d5295a --- /dev/null +++ b/src/API/src/db/email-registry/dto/subscription-response.dto.ts @@ -0,0 +1,24 @@ +import { Expose } from 'class-transformer'; + +export class SubscriptionResponseDto { + @Expose() + id: string; + + @Expose() + first_name: string; + + @Expose() + last_name: string; + + @Expose() + email: string; + + @Expose() + notifications_enabled: boolean; + + @Expose() + account_status: string; + + @Expose() + created_at: Date; +} diff --git a/src/API/src/db/email-registry/dto/unsubscribe-email.dto.ts b/src/API/src/db/email-registry/dto/unsubscribe-email.dto.ts new file mode 100644 index 000000000..402555464 --- /dev/null +++ b/src/API/src/db/email-registry/dto/unsubscribe-email.dto.ts @@ -0,0 +1,16 @@ +import { IsUUID, IsNotEmpty, IsString, IsOptional, MaxLength } from 'class-validator'; + +export class UnsubscribeEmailDto { + @IsNotEmpty({ message: 'The account identifier is required to unsubscribe.' }) + @IsUUID('4', { message: 'Invalid account identifier format.' }) + id: string; // Used for ultra-fast Primary Key database lookup + + @IsNotEmpty({ message: 'The security token is required to unsubscribe.' }) + @IsUUID('4', { message: 'Invalid security token format.' }) + token: string; // Used to prove ownership and prevent URL tampering + + @IsString({ message: 'The reason must be text.' }) + @IsOptional() + @MaxLength(500, { message: 'Reason cannot exceed 500 characters.' }) + reason?: string; // Kept your optional feedback field intact +} diff --git a/src/API/src/db/email-registry/dto/verify-token.dto.ts b/src/API/src/db/email-registry/dto/verify-token.dto.ts new file mode 100644 index 000000000..4f51ec150 --- /dev/null +++ b/src/API/src/db/email-registry/dto/verify-token.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class VerifyTokenDto { + @IsString({ message: 'The verification token must be a text string.' }) + @IsNotEmpty({ message: 'Verification token is missing from the URL.' }) + token: string; +} diff --git a/src/API/src/db/email-registry/email-registry.controller.ts b/src/API/src/db/email-registry/email-registry.controller.ts index e69de29bb..d8fbc3fab 100644 --- a/src/API/src/db/email-registry/email-registry.controller.ts +++ b/src/API/src/db/email-registry/email-registry.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Body, Post, Delete, UseInterceptors, ClassSerializerInterceptor, HttpStatus, HttpCode} from '@nestjs/common'; + +import { SubscribeEmailDto } from './dto/subscribe-email.dto'; +import { VerifyTokenDto } from './dto/verify-token.dto'; +import { UnsubscribeEmailDto } from './dto/unsubscribe-email.dto'; +import { SubscriptionResponseDto } from './dto/subscription-response.dto'; + +import { EmailRegistryService } from './email-registry.service'; + + +@Controller('api') +@UseInterceptors(ClassSerializerInterceptor) +export class EmailREgistryController{ + + constructor(private readonly emailRegistryService: EmailRegistryService) {} + + @Post('subscribe') + @HttpCode(HttpStatus.CREATED) + async subscribe(@Body() subscribeEmailDto: SubscribeEmailDto): Promise { + return this.emailRegistryService.subscribe(subscribeEmailDto); + } + + @Get('verify') + @HttpCode(HttpStatus.OK) + async verify(@Query() query:VerifyTokenDto): Promise { + return this.emailRegistryService.verify(query); + } + + @Delete('unsubscribe') + @HttpCode(HttpStatus.NO_CONTENT) + async unsubscribe(@Body() unsubscribeEmailDto: UnsubscribeEmailDto): Promise { + return this.emailRegistryService.unsubscribe(unsubscribeEmailDto); + } + + +} \ No newline at end of file diff --git a/src/API/src/db/email-registry/email-registry.service.ts b/src/API/src/db/email-registry/email-registry.service.ts index 25b515e12..27e955800 100644 --- a/src/API/src/db/email-registry/email-registry.service.ts +++ b/src/API/src/db/email-registry/email-registry.service.ts @@ -1,4 +1,149 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; +import { EmailService } from 'src/email/email.service'; +import { EmailRegistry } from './entities/email-registry.entity'; +import { SubscribeEmailDto } from './dto/subscribe-email.dto'; +import { VerifyTokenDto } from './dto/verify-token.dto'; +import { UnsubscribeEmailDto } from './dto/unsubscribe-email.dto'; +import { AccountStatus } from './entities/email-registry.entity'; + +/** + * Evaluates token windows against a strict 48-hour Time-To-Live validation boundary. + */ +const VERIFICATION_CODE_TTL_MS = 48 * 60 * 60 * 1000; @Injectable() -export class EmailRegistryService {} +export class EmailRegistryService { + + constructor( + @InjectRepository(EmailRegistry) + private readonly emailRegistryRepository: Repository, + private readonly emailService: EmailService + ){} + + async subscribe(payload: SubscribeEmailDto): Promise { + const email = payload.email.trim().toLowerCase(); + const verificationToken = uuidv4(); + const unsubscriptionToken = uuidv4(); // Generate early so it is immediately ready + const codeExpiresAt = new Date(Date.now() + VERIFICATION_CODE_TTL_MS); + + let entry = await this.emailRegistryRepository.findOne({ where: { email } }); + + if (entry) { + if (entry.account_status === AccountStatus.VERIFIED) { + throw new BadRequestException('This email address is already actively subscribed.'); + } + + Object.assign(entry, { + first_name: payload.first_name, + last_name: payload.last_name, + notifications_enabled: payload.notifications_enabled, + }); + } else { + entry = this.emailRegistryRepository.create({ + id: uuidv4(), + first_name: payload.first_name, + last_name: payload.last_name, + email: email, + notifications_enabled: payload.notifications_enabled, + }); + } + + entry.account_status = AccountStatus.PENDING_VERIFICATION; + entry.verification_token = verificationToken; + entry.token_expires_at = codeExpiresAt; + entry.unsubscription_token = unsubscriptionToken; // Assign the secure token + + const savedEntry = await this.emailRegistryRepository.save(entry); + + const baseUrl = + process.env.EMAIL_VERIFICATION_BASE_URL ?? + process.env.API_BASE_URL ?? + 'http://localhost:3001'; + + const verificationLink = new URL('/api/verify', baseUrl); + verificationLink.searchParams.set('token', verificationToken); + + // Architectural Fix: Route lookup by Primary ID to optimize index tree traversal + // Expose the unsubscription token as the tamper-proof access check vector + const unsubscribeLink = new URL('/api/unsubscribe', baseUrl); + unsubscribeLink.searchParams.set('id', savedEntry.id); + unsubscribeLink.searchParams.set('token', unsubscriptionToken); + + await this.emailService.sendEmail( + [savedEntry.email], + [], + 'Verify your email subscription', + ` +

Thanks for subscribing to updates, ${savedEntry.first_name}.

+

Please verify your email address by clicking this link:

+

${verificationLink.toString()}

+

This verification link will expire in 48 hours.

+
+
+

+ Received this by mistake? Unsubscribe instantly here. +

+ `, + ); + + return savedEntry; + } + + async verify(query: VerifyTokenDto): Promise { + const trimmedToken = query.token?.trim(); + if (!trimmedToken) { + throw new BadRequestException('Verification token parameter is missing from request.'); + } + + const entry = await this.emailRegistryRepository.findOne({ + where: { verification_token: trimmedToken }, + }); + + if (!entry) { + throw new NotFoundException('The verification token provided is invalid or has already been used.'); + } + + if (entry.token_expires_at && entry.token_expires_at.getTime() < Date.now()) { + throw new BadRequestException('This verification link has expired. Please request a new subscription.'); + } + + entry.account_status = AccountStatus.VERIFIED; + entry.verification_token = null; + entry.token_expires_at = null; + + return await this.emailRegistryRepository.save(entry); + } + + + async unsubscribe(payload: UnsubscribeEmailDto): Promise { + const targetId = payload.id?.trim(); + const secureToken = payload.token?.trim(); + + if (!targetId || !secureToken) { + throw new BadRequestException('Required unsubscription identifiers are missing.'); + } + + + const entry = await this.emailRegistryRepository.findOne({ + where: { id: targetId }, + }); + + + if (!entry || entry.unsubscription_token !== secureToken) { + throw new NotFoundException('The unsubscription link is invalid or has already been processed.'); + } + + + entry.account_status = AccountStatus.UNSUBSCRIBED; + entry.notifications_enabled = false; + + + entry.unsubscription_token = null; + + await this.emailRegistryRepository.save(entry); + } +} + diff --git a/src/API/src/db/email-registry/entities/email-registry.entity.ts b/src/API/src/db/email-registry/entities/email-registry.entity.ts index a95399061..b7a0cc159 100644 --- a/src/API/src/db/email-registry/entities/email-registry.entity.ts +++ b/src/API/src/db/email-registry/entities/email-registry.entity.ts @@ -6,7 +6,8 @@ import { IsEnum } from "class-validator"; export enum AccountStatus { PENDING_VERIFICATION = 'pending_verification', VERIFIED = 'verified', - DEACTIVATED = 'deactivated' + DEACTIVATED = 'deactivated', + UNSUBSCRIBED = 'unsubscribed', } registerEnumType(AccountStatus, { name: 'AccountStatus', @@ -54,6 +55,9 @@ export class EmailRegistry{ @Column({nullable: false, type: 'timestamp'}) token_expires_at: Date; + @Field() + @Column({nullable: true}) + unsubscription_token: string; } diff --git a/src/API/src/db/migrations/1787225159465-AddUnsubscribeToken.ts b/src/API/src/db/migrations/1787225159465-AddUnsubscribeToken.ts new file mode 100644 index 000000000..370f1fcf2 --- /dev/null +++ b/src/API/src/db/migrations/1787225159465-AddUnsubscribeToken.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddUnsubscribeToken1787225159465 implements MigrationInterface { + name = 'AddUnsubscribeToken1787225159465' + + public async up(queryRunner: QueryRunner): Promise { + + await queryRunner.query(`ALTER TABLE "email_registry" ADD "unsubscription_token" character varying`); + + } + + public async down(queryRunner: QueryRunner): Promise { + + await queryRunner.query(`ALTER TABLE "email_registry" DROP COLUMN "unsubscription_token"`); + + } +} From 67615b6b39c18d2277f83ae2652040067a582433 Mon Sep 17 00:00:00 2001 From: Charity Date: Thu, 20 Aug 2026 22:53:54 +0300 Subject: [PATCH 05/10] feat: background job for sending emails --- src/API/package-lock.json | 79 +++++++++- src/API/package.json | 4 +- src/API/src/email/email.module.ts | 4 + src/API/src/email/email.processor.ts | 69 +++++++++ src/API/src/email/email.service.ts | 215 ++++++++------------------- 5 files changed, 216 insertions(+), 155 deletions(-) create mode 100644 src/API/src/email/email.processor.ts diff --git a/src/API/package-lock.json b/src/API/package-lock.json index bdd7dedf9..8db688177 100644 --- a/src/API/package-lock.json +++ b/src/API/package-lock.json @@ -15,6 +15,7 @@ "@nestjs-modules/mailer": "^1.6.1", "@nestjs/apollo": "^12.2.0", "@nestjs/axios": "^3.0.2", + "@nestjs/bull": "^11.0.5", "@nestjs/bullmq": "^11.0.4", "@nestjs/class-validator": "^0.13.4", "@nestjs/common": "^10.0.0", @@ -30,6 +31,7 @@ "@types/flat": "^5.0.2", "apollo-server-express": "^3.9.0", "axios": "^1.7.2", + "bull": "^4.16.5", "bullmq": "^5.71.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", @@ -68,6 +70,7 @@ "@nestjs/cli": "^9.1.5", "@nestjs/schematics": "^9.0.3", "@nestjs/testing": "^10.3.10", + "@types/bull": "^3.15.9", "@types/express": "^4.17.13", "@types/geojson": "^7946.0.8", "@types/jest": "^27.5.0", @@ -3549,10 +3552,25 @@ "rxjs": "^6.0.0 || ^7.0.0" } }, + "node_modules/@nestjs/bull": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-11.0.5.tgz", + "integrity": "sha512-z1TKz7NHlsZv5ss27Lp32jzPe8ynw/COWoywMu32k8VXWsDSAy7mvDSBEX3Go0ekO7CzhAzqPWrl//GU3QuMFA==", + "license": "MIT", + "dependencies": { + "@nestjs/bull-shared": "^11.0.5", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "bull": "^3.3 || ^4.0.0" + } + }, "node_modules/@nestjs/bull-shared": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.4.tgz", - "integrity": "sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.5.tgz", + "integrity": "sha512-QI7GHPk4oePBwszAZn/Kz5hgB79KijB1RDoo+E/BrSt33sHUrvRZXVyO0+knZleyloRYy0LT2VwsqSP/aQvg4Q==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -3568,6 +3586,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/@nestjs/bull/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@nestjs/bullmq": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-11.0.4.tgz", @@ -4681,6 +4705,17 @@ "@types/node": "*" } }, + "node_modules/@types/bull": { + "version": "3.15.9", + "resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.9.tgz", + "integrity": "sha512-MPUcyPPQauAmynoO3ezHAmCOhbB0pWmYyijr/5ctaCqhbKWsjW0YCod38ZcLzUBprosfZ9dPqfYIcfdKjk7RNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ioredis": "*", + "@types/redis": "^2.8.0" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -4784,6 +4819,16 @@ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" }, + "node_modules/@types/ioredis": { + "version": "4.28.10", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz", + "integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -4917,6 +4962,16 @@ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, + "node_modules/@types/redis": { + "version": "2.8.32", + "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz", + "integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -6485,6 +6540,24 @@ "node": ">=0.2.0" } }, + "node_modules/bull": { + "version": "4.16.5", + "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz", + "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "^4.9.0", + "get-port": "^5.1.1", + "ioredis": "^5.3.2", + "lodash": "^4.17.21", + "msgpackr": "^1.11.2", + "semver": "^7.5.2", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/bullmq": { "version": "5.71.1", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.71.1.tgz", diff --git a/src/API/package.json b/src/API/package.json index 76870d112..77d890fae 100644 --- a/src/API/package.json +++ b/src/API/package.json @@ -32,6 +32,7 @@ "@nestjs-modules/mailer": "^1.6.1", "@nestjs/apollo": "^12.2.0", "@nestjs/axios": "^3.0.2", + "@nestjs/bull": "^11.0.5", "@nestjs/bullmq": "^11.0.4", "@nestjs/class-validator": "^0.13.4", "@nestjs/common": "^10.0.0", @@ -47,6 +48,7 @@ "@types/flat": "^5.0.2", "apollo-server-express": "^3.9.0", "axios": "^1.7.2", + "bull": "^4.16.5", "bullmq": "^5.71.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", @@ -85,6 +87,7 @@ "@nestjs/cli": "^9.1.5", "@nestjs/schematics": "^9.0.3", "@nestjs/testing": "^10.3.10", + "@types/bull": "^3.15.9", "@types/express": "^4.17.13", "@types/geojson": "^7946.0.8", "@types/jest": "^27.5.0", @@ -135,7 +138,6 @@ "src/db/migrations/*" ], "coverageThreshold": { - "global": { "branches": 70, "lines": 85 diff --git a/src/API/src/email/email.module.ts b/src/API/src/email/email.module.ts index 6d30ccc64..3979ee411 100644 --- a/src/API/src/email/email.module.ts +++ b/src/API/src/email/email.module.ts @@ -1,5 +1,6 @@ import { Logger, Module } from '@nestjs/common'; import { EmailService } from './email.service'; +import { BullModule } from '@nestjs/bull'; import { EmailController } from './email.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CommunicationLog } from '../db/communication-log/entities/communication-log.entity'; @@ -11,6 +12,9 @@ import { HttpModule } from '@nestjs/axios'; controllers: [EmailController], providers: [EmailService, CommunicationLogService, Logger], imports: [ + BullModule.registerQueue({ + name: 'email-sending', + }), HttpModule, CommunicationLogModule, TypeOrmModule.forFeature([CommunicationLog]), diff --git a/src/API/src/email/email.processor.ts b/src/API/src/email/email.processor.ts new file mode 100644 index 000000000..945bd8737 --- /dev/null +++ b/src/API/src/email/email.processor.ts @@ -0,0 +1,69 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Job } from 'bullmq'; +import * as nodemailer from 'nodemailer'; +import { CommunicationLogService } from '../db/communication-log/communication-log.service'; +import { CommunicationSentStatus } from '../../src/commonTypes'; + +@Processor('email-sending') +export class EmailProcessor extends WorkerHost { + constructor(private readonly communicationLogService: CommunicationLogService) { + super(); + } + + /** + * Asynchronous background listener method executed on each dequeued job ticket. + */ + async process(job: Job): Promise { + // Unpack data from stringified Redis payload container + const { emails, copyEmails, title, emailBody, files, commLogId } = job.data; + + try { + // Build Nodemailer network client using environment variables + const transporter = nodemailer.createTransport( + { + host: process.env.EMAIL_HOST, + port: Number(process.env.EMAIL_PORT), + secure: Boolean(Number(process.env.EMAIL_SECURE)), + auth: { + user: process.env.EMAIL_FROM, + pass: process.env.EMAIL_PASSWORD, + }, + }, + { + from: { + name: process.env.EMAIL_FROM, + address: process.env.EMAIL_FROM, + }, + }, + ); + + // Execute network SMTP mail distribution call + const res = await transporter.sendMail({ + subject: title, + html: emailBody, + attachments: files, + to: emails, + cc: copyEmails, + }); + + // Update database status log row to SENT + await this.communicationLogService.updateSentStatus( + commLogId, + CommunicationSentStatus.SENT, + res.response + ); + + return true; + } catch (err) { + // Update database status log row to FAILED with error descriptions + await this.communicationLogService.updateSentStatus( + commLogId, + CommunicationSentStatus.FAILED, + err.message + ); + + // Throwing error tells BullMQ to activate retry timers + throw err; + } + } +} diff --git a/src/API/src/email/email.service.ts b/src/API/src/email/email.service.ts index 081ca07a0..f22a041b0 100644 --- a/src/API/src/email/email.service.ts +++ b/src/API/src/email/email.service.ts @@ -1,33 +1,24 @@ import { Injectable, Logger } from '@nestjs/common'; -import { MailerService } from '@nestjs-modules/mailer'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; import { CommunicationLogService } from '../db/communication-log/communication-log.service'; import { CommunicationLog } from '../db/communication-log/entities/communication-log.entity'; -import SMTPTransport from 'nodemailer/lib/smtp-transport'; -import * as nodemailer from 'nodemailer'; -import { render } from '@react-email/render'; - -import { - CommunicationChannelType, - CommunicationSentStatus, -} from '../../src/commonTypes'; -import { - AttachmentLikeObject, - ISendMailOptions, -} from '@nestjs-modules/mailer/dist/interfaces/send-mail-options.interface'; -import { ImapFlow } from 'imapflow'; +import { CommunicationChannelType, CommunicationSentStatus } from '../../src/commonTypes'; +import { AttachmentLikeObject } from '@nestjs-modules/mailer/dist/interfaces/send-mail-options.interface'; import { existsSync, mkdirSync, writeFileSync } from 'fs'; import { join } from 'path'; -// import { Html } from '@react-email/components'; -// import Email from 'templates/email'; @Injectable() export class EmailService { constructor( - // private readonly mailerService: MailerService, + @InjectQueue('email-sending') private readonly emailQueue: Queue, private readonly communicationLogService: CommunicationLogService, private readonly logger: Logger, ) {} + /** + * Enqueues a standardized email job into Redis for background execution. + */ async sendEmail( emails: string[], copyEmails: string[], @@ -36,124 +27,62 @@ export class EmailService { files?: AttachmentLikeObject[], communicationLog?: CommunicationLog, ): Promise { - const sendViaTransport = async () => { - try { - // //send email - const transporter = nodemailer.createTransport( - { - host: process.env.EMAIL_HOST, - port: Number(process.env.EMAIL_PORT), - secure: Boolean(Number(process.env.EMAIL_SECURE)), - auth: { - user: process.env.EMAIL_FROM, - pass: process.env.EMAIL_PASSWORD, - }, - }, - { - from: { - name: process.env.EMAIL_FROM, - address: process.env.EMAIL_FROM, - }, - }, - ); - // const res = await this.mailerService.sendMail(mailOptions); - const res = await transporter.sendMail({ - subject: title, - html: emailBody, - attachments: files, - to: emails, - cc: copyEmails, - }); - // // Update sent status - this.updateSentStatus(commLog, res); - await this.appendToSent( - commLog.subject, - allRecipients, - emailBody, - ).catch(console.error); - return true; - } catch (err) { - this.logger.error(err); - console.log(err); - throw err; - } - }; + // Sanitize string inputs into formal arrays + if (typeof emails === 'string') emails = [emails]; + if (typeof copyEmails === 'string') copyEmails = [copyEmails]; - if (typeof emails === 'string') { - emails = [emails]; - } - if (typeof copyEmails === 'string') { - copyEmails = [copyEmails]; - } - const mailOptions: ISendMailOptions = { - from: process.env.EMAIL_FROM, - to: emails, - cc: copyEmails, - subject: title, - html: emailBody, - attachments: files, - }; - - // Log communication before attempting to send const allRecipients = emails.slice(); - const commLog = await this.saveLog( - communicationLog, - allRecipients, - emailBody, - ); + + // Save audit log to DB as 'PENDING' before queue routing + const commLog = await this.saveLog(communicationLog, allRecipients, title, emailBody); - await sendViaTransport(); - return true; + try { + // Add the job payload along with native BullMQ retry instructions + await this.emailQueue.add( + 'send-smtp-email', + { + emails, + copyEmails, + title, + emailBody, + files, + commLogId: commLog.id, + }, + { + attempts: 3, // Try sending up to 3 times total on failure + backoff: { + type: 'exponential', // Multiplies wait time incrementally per try + delay: 5000, // Wait 5s before attempt 2, 10s before attempt 3 + }, + removeOnComplete: true, // Automatically purge successful metadata from Redis + }, + ); + + return true; + } catch (err) { + this.logger.error('Failed to hand off email job to Redis queue storage', err); + return false; + } } /** - * Append sent emails to the sender's outbox - * @param subject - * @param recipients - * @param message + * Helper utility to flush incoming upload streams onto local hard disk + * and convert them into stable string paths before queue dispatch. */ - async appendToSent(subject: string, recipients: string[], message: string) { - return true; - /* - const client = new ImapFlow({ - host: process.env.IMAP_SERVER - port: process.env.IMAP_PORT, // 993, - secure: true, - auth: { - user: process.env.EMAIL_FROM, - pass: process.env.EMAIL_PASSWORD, - }, - }); - - const recps = recipients.join(','); - const msg = `Subject: ${subject}\r\nFrom: ${process.env.EMAIL_FROM}\r\nTo: ${recps}\r\nContent-Type: text/plain; format=flowed\r\n\r\n${message}`; - try { - await client.connect(); - const resss = await client.list(); - console.log('Outlook mail boxes: '); - resss.forEach((mailbox) => console.log(mailbox.path)); - await client.append(process.env.SENT_EMAIL_FOLDER, msg, [], new Date()); - } catch (error) { - this.logger.error(error); - console.log(error); - } finally { - await client.logout(); - }*/ - } - async sendEmailWithRawFiles( emails: string[], copyEmails: string[], title: string, emailBody: string, communicationLog?: CommunicationLog, - files?: Express.Multer.File | Express.Multer.File[], // Handles file upload + files?: Express.Multer.File | Express.Multer.File[], ) { try { const tempDir = join(__dirname, '..', 'temp'); if (!existsSync(tempDir)) { mkdirSync(tempDir, { recursive: true }); } + const finalFiles: Express.Multer.File[] = [].concat(files || []); const attachedFiles: AttachmentLikeObject[] = finalFiles.map((file) => { const tempFilePath = join(tempDir, file.originalname); @@ -171,48 +100,32 @@ export class EmailService { ); return { success: result }; } catch (error) { - this.logger.error(error); + this.logger.error('Multipart form attachment pipeline broken', error); return { success: false, message: error.message }; } } + /** + * Compiles and guarantees an initialized PENDING log entry exists inside DB tables. + */ async saveLog( - communicationLog: CommunicationLog, - recipients: Array, - message: string, + communicationLog: CommunicationLog, + recipients: string[], + title: string, + message: string ): Promise { if (communicationLog) { - await this.communicationLogService.upsert(communicationLog); - } else { - communicationLog = new CommunicationLog(); - communicationLog.channel_type = CommunicationChannelType.EMAIL; - communicationLog.recipients = recipients; - communicationLog.subject = 'General Email'; - communicationLog.message_type = 'General Email'; - communicationLog.message = message; - communicationLog.sent_status = CommunicationSentStatus.PENDING; - communicationLog.sent_date = null; - communicationLog.reference_entity_type = null; - communicationLog.reference_entity_name = null; - this.communicationLogService.upsert(communicationLog); - } - return communicationLog; - } - - async updateSentStatus( - communicationLog: CommunicationLog, - info: SMTPTransport.SentMessageInfo, - ) { - if (info.messageId) { - communicationLog.sent_status = CommunicationSentStatus.SENT; - communicationLog.sent_date = new Date(); - communicationLog.sent_response = String(info.response); - } else { - communicationLog.sent_status = CommunicationSentStatus.FAILED; - communicationLog.sent_date = new Date(); - communicationLog.sent_response = String(info.response); - communicationLog.error_description = String(info.response); + return await this.communicationLogService.upsert(communicationLog); } - this.communicationLogService.upsert(communicationLog); + + const log = new CommunicationLog(); + log.channel_type = CommunicationChannelType.EMAIL; + log.recipients = recipients; + log.subject = title || 'General Email'; + log.message_type = 'General Email'; + log.message = message; + log.sent_status = CommunicationSentStatus.PENDING; + return await this.communicationLogService.upsert(log); } } + From 99aa64e3ec4cb61695929ca6d241bde5ad634e63 Mon Sep 17 00:00:00 2001 From: Charity Date: Fri, 21 Aug 2026 11:24:34 +0300 Subject: [PATCH 06/10] resolve module dependencies, type errors, and catch blocks --- src/API/src/db/doi/doi.module.ts | 5 +- .../dto/subscription-response.dto.ts | 2 +- .../email-registry.controller.ts | 2 +- .../email-registry/email-registry.module.ts | 5 ++ src/API/src/db/shared/shared.module.ts | 7 ++- src/API/src/email/email.processor.ts | 2 +- src/API/src/email/email.service.ts | 2 +- src/API/src/schema.gql | 51 +++++++++++++++++-- 8 files changed, 66 insertions(+), 10 deletions(-) diff --git a/src/API/src/db/doi/doi.module.ts b/src/API/src/db/doi/doi.module.ts index 912177e50..02668c061 100644 --- a/src/API/src/db/doi/doi.module.ts +++ b/src/API/src/db/doi/doi.module.ts @@ -20,8 +20,10 @@ import { UserRole } from 'src/auth/user_role/user_role.entity'; import { UserRoleService } from 'src/auth/user_role/user_role.service'; import { UploadedModel } from '../uploaded-model/entities/uploaded-model.entity'; + @Module({ imports: [ + EmailModule, HttpModule, // forwardRef(() => UploadedDatasetModule), TypeOrmModule.forFeature([ @@ -31,13 +33,14 @@ import { UploadedModel } from '../uploaded-model/entities/uploaded-model.entity' UploadedDataset, UploadedModel, UserRole, + ]), ], controllers: [DoiController], providers: [ DoiResolver, DoiService, - EmailService, + //EmailService, AuthService, UserRoleService, CommunicationLogService, diff --git a/src/API/src/db/email-registry/dto/subscription-response.dto.ts b/src/API/src/db/email-registry/dto/subscription-response.dto.ts index c42d5295a..12ded560c 100644 --- a/src/API/src/db/email-registry/dto/subscription-response.dto.ts +++ b/src/API/src/db/email-registry/dto/subscription-response.dto.ts @@ -20,5 +20,5 @@ export class SubscriptionResponseDto { account_status: string; @Expose() - created_at: Date; + created_at?: Date; } diff --git a/src/API/src/db/email-registry/email-registry.controller.ts b/src/API/src/db/email-registry/email-registry.controller.ts index d8fbc3fab..68e1b1cad 100644 --- a/src/API/src/db/email-registry/email-registry.controller.ts +++ b/src/API/src/db/email-registry/email-registry.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Body, Post, Delete, UseInterceptors, ClassSerializerInterceptor, HttpStatus, HttpCode} from '@nestjs/common'; +import { Controller, Get, Body, Post, Delete, UseInterceptors, ClassSerializerInterceptor, HttpStatus, HttpCode, Query} from '@nestjs/common'; import { SubscribeEmailDto } from './dto/subscribe-email.dto'; import { VerifyTokenDto } from './dto/verify-token.dto'; diff --git a/src/API/src/db/email-registry/email-registry.module.ts b/src/API/src/db/email-registry/email-registry.module.ts index 6d1d5c444..c90c93a5c 100644 --- a/src/API/src/db/email-registry/email-registry.module.ts +++ b/src/API/src/db/email-registry/email-registry.module.ts @@ -1,8 +1,13 @@ import { Module } from '@nestjs/common'; import { EmailRegistryService } from './email-registry.service'; import { EmailRegistryResolver } from './email-registry.resolver'; +import { EmailRegistry } from './entities/email-registry.entity'; +import { TypeOrmModule } from '@nestjs/typeorm/dist/typeorm.module'; +import { EmailModule } from 'src/email/email.module'; @Module({ + imports: [TypeOrmModule.forFeature([EmailRegistry]), +EmailModule], providers: [EmailRegistryService, EmailRegistryResolver] }) export class EmailRegistryModule {} diff --git a/src/API/src/db/shared/shared.module.ts b/src/API/src/db/shared/shared.module.ts index 090509665..f5b35e173 100644 --- a/src/API/src/db/shared/shared.module.ts +++ b/src/API/src/db/shared/shared.module.ts @@ -1,5 +1,5 @@ import { TypeOrmModule } from '@nestjs/typeorm'; -import { Logger, Module } from '@nestjs/common'; +import { Logger, Module, Global } from '@nestjs/common'; import { ReferenceService } from './reference.service'; import { ReferenceResolver } from './reference.resolver'; import { Reference } from './entities/reference.entity'; @@ -17,12 +17,15 @@ import { CommunicationLog } from '../communication-log/entities/communication-lo import { RecordedSpecies } from './entities/recorded_species.entity'; import { RecordedSpeciesService } from './recordedSpecies.service'; import { RecordedSpeciesResolver } from './recordedSpecies.resolver'; +import { BullModule } from '@nestjs/bull/dist/bull.module'; +@Global() @Module({ imports: [ HttpModule, TypeOrmModule.forFeature([Reference, Dataset, RecordedSpecies]), TypeOrmModule.forFeature([UserRole, CommunicationLog]), + BullModule.registerQueue({ name: 'email-sending' }), ], providers: [ ReferenceService, @@ -37,7 +40,7 @@ import { RecordedSpeciesResolver } from './recordedSpecies.resolver'; RecordedSpeciesResolver, Logger, ], - exports: [ReferenceService, DatasetService, RecordedSpeciesService], + exports: [ReferenceService, DatasetService, RecordedSpeciesService, BullModule], controllers: [DatasetController], }) export class SharedModule {} diff --git a/src/API/src/email/email.processor.ts b/src/API/src/email/email.processor.ts index 945bd8737..7109c441d 100644 --- a/src/API/src/email/email.processor.ts +++ b/src/API/src/email/email.processor.ts @@ -54,7 +54,7 @@ export class EmailProcessor extends WorkerHost { ); return true; - } catch (err) { + } catch (err: any) { // Update database status log row to FAILED with error descriptions await this.communicationLogService.updateSentStatus( commLogId, diff --git a/src/API/src/email/email.service.ts b/src/API/src/email/email.service.ts index f22a041b0..284ea0336 100644 --- a/src/API/src/email/email.service.ts +++ b/src/API/src/email/email.service.ts @@ -99,7 +99,7 @@ export class EmailService { communicationLog, ); return { success: result }; - } catch (error) { + } catch (error: any) { this.logger.error('Multipart form attachment pipeline broken', error); return { success: false, message: error.message }; } diff --git a/src/API/src/schema.gql b/src/API/src/schema.gql index 527ccee42..4e6648567 100644 --- a/src/API/src/schema.gql +++ b/src/API/src/schema.gql @@ -63,6 +63,17 @@ input Coord { long: Float } +type Country { + alternative_names: [String!]! + creation: DateTime! + id: String! + modified: DateTime! + name: String! + owner: String + sites: [Site!] + updater: String +} + input CreateNewsInput { article: String! id: String @@ -88,8 +99,9 @@ input CreateSpeciesInformationInput { id: String link: String! name: String! + previewImage: String shortDescription: String! - speciesImage: String! + speciesImage: String } """doi""" @@ -198,6 +210,9 @@ type Mutation { deleteSpeciesInformation(id: String!): Boolean! disableNotifications(disable: Boolean!, userId: String!): Boolean! requestRoles(email: String!, requestReason: String!, rolesRequested: [String!]!): Boolean! + updateCountry(input: UpdateCountryInput!): Country! + updateRecordedSpecies(input: UpdateRecordedSpeciesInput!): RecordedSpecies! + updateReference(input: UpdateReferenceInput!, num_id: Int!): Reference! updateUserRoles(input: UserRoleInput!): UserRole! upsertNewsTranslation(input: UpsertNewsTranslationInput!): NewsTranslation! } @@ -323,16 +338,19 @@ type Query { OccurrenceData(bounds: BoundsFilter, filters: OccurrenceFilter, skip: Float = 0, take: Float = 1): PaginatedOccurrenceReturnData! allCommunicationLogs: [CommunicationLog!]! allCommunicationLogsBySentStatus(status: String!): [CommunicationLog!]! + allCountries: [Country!]! allDois: [DOI!]! allDoisByStatus(status: String!): [DOI!]! allGeoData: Bionomics! allNews: [News!]! - allReferenceData(endId: Float = null, order: String = "asc", orderBy: String = "num_id", skip: Float = 0, startId: Float = 1, take: Float = 1, textFilter: String = ""): PaginatedReferenceData! + allRecordedSpecies: [RecordedSpecies!]! + allReferenceData(endId: Float = null, filterField: String = "article_title", order: String = "asc", orderBy: String = "num_id", skip: Float = 0, startId: Float = 1, take: Float = 1, textFilter: String = ""): PaginatedReferenceData! allSpeciesInformation: [SpeciesInformation!]! allUploadedDatasets: [UploadedDataset!] allUploadedModels: [UploadedModel!] allUserRoles: [UserWithRoles!]! communicationLogById(id: String!): CommunicationLog + country(id: String!): Country datasetById(id: String!): Dataset datasets: [Dataset!]! doiById(id: String!): DOI @@ -340,6 +358,7 @@ type Query { getHomepageAnalytics(endAt: Float!, startAt: Float!, timezone: String!, unit: String!): HomepageStats! newsById(id: String!): News! postProcessModel(blobLocation: String!, displayName: String!, maxValue: Float!, modelName: String!, uploadedModelId: String!): ModelProcessingStatus! + recordedSpeciesById(id: String!): RecordedSpecies referenceData(id: String!): Reference! speciesInformationById(id: String!): SpeciesInformation! uploadedDatasetById(id: String!): UploadedDataset @@ -352,6 +371,7 @@ type Query { """recorded species data""" type RecordedSpecies { category: String + color: String display_name: String id: String! species: String! @@ -430,8 +450,33 @@ type SpeciesInformation { id: String! link: String name: String! + previewImage: String shortDescription: String! - speciesImage: String! + speciesImage: String +} + +input UpdateCountryInput { + alternative_names: [String!] + id: String! + name: String +} + +input UpdateRecordedSpeciesInput { + category: String + color: String + displayName: String + id: String! +} + +input UpdateReferenceInput { + article_title: String! + author: String! + citation: String! + journal_title: String! + published: Boolean! + report_type: String! + v_data: Boolean! + year: Float! } """uploaded dataset""" From 3a89a65a45a5bd899cfbcf06031b8cafe707d395 Mon Sep 17 00:00:00 2001 From: Charity Date: Sun, 23 Aug 2026 19:21:29 +0300 Subject: [PATCH 07/10] feat(email-registry): add subscriber system with campaigns and admin UI Backend: - Add email subscription with 48h verification tokens - Add unsubscribe flow with secure per-user tokens - Add admin GraphQL queries: paginated registry, manual add - Add campaign mutations: dataset alerts (blue) and newsletters (green) - Implement streamVerified() for memory-efficient bulk emailing - Throttle campaigns at 5 emails/sec - Add unit tests for campaign logic Frontend: - Add /subscribe page with GraphQL mutation - Add /verify-success, /verify-expired, /unsubscribed-success pages - Add /admin email registry dashboard - Update footer with subscribe link --- .gitignore | 8 + package-lock.json | 464 +++++++++++++++- package.json | 6 + src/API/package-lock.json | 61 +-- .../email-registry/dto/subscribe-email.dto.ts | 27 +- .../dto/subscription-response.dto.ts | 28 +- .../dto/unsubscribe-email.dto.ts | 28 +- .../db/email-registry/dto/verify-token.dto.ts | 6 +- .../email-registry.controller.ts | 76 ++- .../email-registry/email-registry.module.ts | 7 +- .../email-registry/email-registry.resolver.ts | 96 +++- .../email-registry.service.spec.ts | 127 +++++ .../email-registry/email-registry.service.ts | 517 ++++++++++++++---- .../entities/email-registry.entity.ts | 101 ++-- src/API/src/main.ts | 2 +- src/API/src/schema.gql | 45 ++ src/UI/components/shared/footer.tsx | 97 +++- src/UI/package.json | 2 +- src/UI/pages/_app.tsx | 3 +- src/UI/pages/admin/email-registry/index.tsx | 213 ++++++++ src/UI/pages/subscribe/index.tsx | 176 ++++++ src/UI/pages/unsubscribed-success/index.tsx | 39 ++ src/UI/pages/verify-expired/index.tsx | 39 ++ src/UI/pages/verify-success/index.tsx | 39 ++ src/UI/public/messages/en.json | 31 +- 25 files changed, 1951 insertions(+), 287 deletions(-) create mode 100644 package.json create mode 100644 src/API/src/db/email-registry/email-registry.service.spec.ts create mode 100644 src/UI/pages/admin/email-registry/index.tsx create mode 100644 src/UI/pages/subscribe/index.tsx create mode 100644 src/UI/pages/unsubscribed-success/index.tsx create mode 100644 src/UI/pages/verify-expired/index.tsx create mode 100644 src/UI/pages/verify-success/index.tsx diff --git a/.gitignore b/.gitignore index 1f257dfa4..7626aa946 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,11 @@ e2e/npm-debug.log +angie.conf + +proxy.js + +start-proxy.js + +start-proxy.sh +proxy.js diff --git a/package-lock.json b/package-lock.json index d41499c50..137430ecf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2,5 +2,467 @@ "name": "vectoratlas-software-code", "lockfileVersion": 3, "requires": true, - "packages": {} + "packages": { + "": { + "dependencies": { + "@azure/storage-blob": "^12.32.0", + "http-proxy": "^1.18.1" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.2.tgz", + "integrity": "sha512-1D2LpsU7y9xrqKjdIbsB7PlrRePw0xsVV8p+AKTlzITrWmscajryfJCdDJB/oGwvDI5HmRo04eMMADB67uwAwQ==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-http-compat": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.4.0.tgz", + "integrity": "sha512-f1P96IB399YiN2ARYHP7EpZi3Bf3wH4SN2lGzrw7JVwm7bbsVYtf2iKSBwTywD2P62NOPZGHFSZi+6jjb75JuA==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@azure/core-client": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0" + } + }, + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", + "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.24.0.tgz", + "integrity": "sha512-PpLsoDQ3AMmKZ0VU+0GrmqMxgp/sExjlVm4R+nLWngeoEGAzOIPVifaxKGU5gMv+nWELUoHfvrolWD+ZS/nFJg==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-xml": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.5.1.tgz", + "integrity": "sha512-xcNRHqCoSp4AunOALEae6A8f3qATb83gSrm31Iqb01OzblvC3/W/bfXozcq78EzIdzZzuH1bZ2NvRR0TdX709w==", + "license": "MIT", + "dependencies": { + "fast-xml-parser": "^5.5.9", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/storage-blob": { + "version": "12.32.0", + "resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.32.0.tgz", + "integrity": "sha512-80LzSNnFQye2LCCBFghAJS6jJQJ7N4bfgZ6qDMgVGRtugZ7TLDKQZ2hczMigmZH3jAcMRdma/IygsC5+0gT7Tw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.3", + "@azure/core-http-compat": "^2.2.0", + "@azure/core-lro": "^2.2.0", + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.19.1", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/core-xml": "^1.4.5", + "@azure/logger": "^1.1.4", + "@azure/storage-common": "^12.4.0", + "events": "^3.0.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/storage-common": { + "version": "12.4.1", + "resolved": "https://registry.npmjs.org/@azure/storage-common/-/storage-common-12.4.1.tgz", + "integrity": "sha512-t14unw/WofGDUi7TKJrsyXyPsN+NLgRm7hMaq0llxNmTIzt7f257+6LE6FKIJPh88zLj6M7LPvzve0fEYg/L3A==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-http-compat": "^2.2.0", + "@azure/core-rest-pipeline": "^1.24.0", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.1.4", + "events": "^3.3.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.6.tgz", + "integrity": "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.0.tgz", + "integrity": "sha512-9IGxMqvqLOnqP+Egi1nqDHKv5k8aZ7r9n558enxcucmyVGEBNPAU+MOg/8jPIS7rO7sSq4gFm1/nHtiaubMruw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/strnum": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + } + } } diff --git a/package.json b/package.json new file mode 100644 index 000000000..d8e015050 --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "@azure/storage-blob": "^12.32.0", + "http-proxy": "^1.18.1" + } +} diff --git a/src/API/package-lock.json b/src/API/package-lock.json index 8db688177..89bbc239d 100644 --- a/src/API/package-lock.json +++ b/src/API/package-lock.json @@ -1019,6 +1019,7 @@ "version": "7.24.9", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.9.tgz", "integrity": "sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==", + "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", @@ -1047,12 +1048,14 @@ "node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -1521,7 +1524,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "devOptional": true, + "dev": true, "dependencies": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -1533,7 +1536,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "devOptional": true, + "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" @@ -4627,25 +4630,25 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "devOptional": true + "dev": true }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "devOptional": true + "dev": true }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "devOptional": true + "dev": true }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "devOptional": true + "dev": true }, "node_modules/@types/accepts": { "version": "1.3.7", @@ -5475,7 +5478,7 @@ "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", - "devOptional": true, + "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -5505,7 +5508,7 @@ "version": "8.3.3", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz", "integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==", - "devOptional": true, + "dev": true, "dependencies": { "acorn": "^8.11.0" }, @@ -6095,7 +6098,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "devOptional": true + "dev": true }, "node_modules/argparse": { "version": "2.0.1", @@ -7343,7 +7346,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "devOptional": true + "dev": true }, "node_modules/cron": { "version": "4.4.0", @@ -7650,7 +7653,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "devOptional": true, + "dev": true, "engines": { "node": ">=0.3.1" } @@ -11969,7 +11972,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "devOptional": true + "dev": true }, "node_modules/makeerror": { "version": "1.0.12", @@ -14070,20 +14073,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, "node_modules/react-email": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/react-email/-/react-email-3.0.2.tgz", @@ -14733,16 +14722,6 @@ "node": ">=10" } }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0" - } - }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -15913,7 +15892,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "devOptional": true, + "dev": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -16248,7 +16227,7 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "devOptional": true, + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16403,7 +16382,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "devOptional": true + "dev": true }, "node_modules/v8-to-istanbul": { "version": "9.3.0", @@ -17086,7 +17065,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "devOptional": true, + "dev": true, "engines": { "node": ">=6" } diff --git a/src/API/src/db/email-registry/dto/subscribe-email.dto.ts b/src/API/src/db/email-registry/dto/subscribe-email.dto.ts index ed8ca9315..7c1527547 100644 --- a/src/API/src/db/email-registry/dto/subscribe-email.dto.ts +++ b/src/API/src/db/email-registry/dto/subscribe-email.dto.ts @@ -1,20 +1,21 @@ import { IsEmail, IsString, IsBoolean, IsNotEmpty } from 'class-validator'; export class SubscribeEmailDto { + @IsString({ message: 'First name must be a text value.' }) + @IsNotEmpty({ message: 'First name cannot be left blank.' }) + first_name: string; - @IsString({ message: 'First name must be a text value.' }) - @IsNotEmpty({ message: 'First name cannot be left blank.' }) - first_name: string; + @IsString({ message: 'Last name must be a text value.' }) + @IsNotEmpty({ message: 'Last name cannot be left blank.' }) + last_name: string; - @IsString({ message: 'Last name must be a text value.' }) - @IsNotEmpty({ message: 'Last name cannot be left blank.' }) - last_name: string; + @IsEmail({}, { message: 'Please enter a valid email address.' }) + @IsNotEmpty({ message: 'Email address is required.' }) + email: string; - @IsEmail({}, { message: 'Please enter a valid email address.' }) - @IsNotEmpty({ message: 'Email address is required.' }) - email: string; - - @IsBoolean({ message: 'Notifications enabled must be a true or false value.' }) - // Note: No @IsNotEmpty needed here, as false is a valid boolean value - notifications_enabled: boolean; + @IsBoolean({ + message: 'Notifications enabled must be a true or false value.', + }) + // Note: No @IsNotEmpty needed here, as false is a valid boolean value + notifications_enabled: boolean; } diff --git a/src/API/src/db/email-registry/dto/subscription-response.dto.ts b/src/API/src/db/email-registry/dto/subscription-response.dto.ts index 12ded560c..0b7672b9f 100644 --- a/src/API/src/db/email-registry/dto/subscription-response.dto.ts +++ b/src/API/src/db/email-registry/dto/subscription-response.dto.ts @@ -1,24 +1,24 @@ import { Expose } from 'class-transformer'; export class SubscriptionResponseDto { - @Expose() - id: string; + @Expose() + id: string; - @Expose() - first_name: string; + @Expose() + first_name: string; - @Expose() - last_name: string; + @Expose() + last_name: string; - @Expose() - email: string; + @Expose() + email: string; - @Expose() - notifications_enabled: boolean; + @Expose() + notifications_enabled: boolean; - @Expose() - account_status: string; + @Expose() + account_status: string; - @Expose() - created_at?: Date; + @Expose() + created_at?: Date; } diff --git a/src/API/src/db/email-registry/dto/unsubscribe-email.dto.ts b/src/API/src/db/email-registry/dto/unsubscribe-email.dto.ts index 402555464..3b02db8b8 100644 --- a/src/API/src/db/email-registry/dto/unsubscribe-email.dto.ts +++ b/src/API/src/db/email-registry/dto/unsubscribe-email.dto.ts @@ -1,16 +1,22 @@ -import { IsUUID, IsNotEmpty, IsString, IsOptional, MaxLength } from 'class-validator'; +import { + IsUUID, + IsNotEmpty, + IsString, + IsOptional, + MaxLength, +} from 'class-validator'; export class UnsubscribeEmailDto { - @IsNotEmpty({ message: 'The account identifier is required to unsubscribe.' }) - @IsUUID('4', { message: 'Invalid account identifier format.' }) - id: string; // Used for ultra-fast Primary Key database lookup + @IsNotEmpty({ message: 'The account identifier is required to unsubscribe.' }) + @IsUUID('4', { message: 'Invalid account identifier format.' }) + id: string; // Used for ultra-fast Primary Key database lookup - @IsNotEmpty({ message: 'The security token is required to unsubscribe.' }) - @IsUUID('4', { message: 'Invalid security token format.' }) - token: string; // Used to prove ownership and prevent URL tampering + @IsNotEmpty({ message: 'The security token is required to unsubscribe.' }) + @IsUUID('4', { message: 'Invalid security token format.' }) + token: string; // Used to prove ownership and prevent URL tampering - @IsString({ message: 'The reason must be text.' }) - @IsOptional() - @MaxLength(500, { message: 'Reason cannot exceed 500 characters.' }) - reason?: string; // Kept your optional feedback field intact + @IsString({ message: 'The reason must be text.' }) + @IsOptional() + @MaxLength(500, { message: 'Reason cannot exceed 500 characters.' }) + reason?: string; // Kept your optional feedback field intact } diff --git a/src/API/src/db/email-registry/dto/verify-token.dto.ts b/src/API/src/db/email-registry/dto/verify-token.dto.ts index 4f51ec150..7bdb72226 100644 --- a/src/API/src/db/email-registry/dto/verify-token.dto.ts +++ b/src/API/src/db/email-registry/dto/verify-token.dto.ts @@ -1,7 +1,7 @@ import { IsNotEmpty, IsString } from 'class-validator'; export class VerifyTokenDto { - @IsString({ message: 'The verification token must be a text string.' }) - @IsNotEmpty({ message: 'Verification token is missing from the URL.' }) - token: string; + @IsString({ message: 'The verification token must be a text string.' }) + @IsNotEmpty({ message: 'Verification token is missing from the URL.' }) + token: string; } diff --git a/src/API/src/db/email-registry/email-registry.controller.ts b/src/API/src/db/email-registry/email-registry.controller.ts index 68e1b1cad..abbbb4e97 100644 --- a/src/API/src/db/email-registry/email-registry.controller.ts +++ b/src/API/src/db/email-registry/email-registry.controller.ts @@ -1,4 +1,18 @@ -import { Controller, Get, Body, Post, Delete, UseInterceptors, ClassSerializerInterceptor, HttpStatus, HttpCode, Query} from '@nestjs/common'; +import { + Controller, + Get, + Body, + Post, + Delete, + UseInterceptors, + ClassSerializerInterceptor, + HttpStatus, + HttpCode, + Query, + Res, + UseGuards, +} from '@nestjs/common'; +import { Response } from 'express'; import { SubscribeEmailDto } from './dto/subscribe-email.dto'; import { VerifyTokenDto } from './dto/verify-token.dto'; @@ -6,31 +20,41 @@ import { UnsubscribeEmailDto } from './dto/unsubscribe-email.dto'; import { SubscriptionResponseDto } from './dto/subscription-response.dto'; import { EmailRegistryService } from './email-registry.service'; - +import { GqlAuthGuard } from 'src/auth/gqlAuthGuard'; +import { RolesGuard } from 'src/auth/user_role/roles.guard'; @Controller('api') @UseInterceptors(ClassSerializerInterceptor) -export class EmailREgistryController{ - - constructor(private readonly emailRegistryService: EmailRegistryService) {} - - @Post('subscribe') - @HttpCode(HttpStatus.CREATED) - async subscribe(@Body() subscribeEmailDto: SubscribeEmailDto): Promise { - return this.emailRegistryService.subscribe(subscribeEmailDto); - } - - @Get('verify') - @HttpCode(HttpStatus.OK) - async verify(@Query() query:VerifyTokenDto): Promise { - return this.emailRegistryService.verify(query); - } - - @Delete('unsubscribe') - @HttpCode(HttpStatus.NO_CONTENT) - async unsubscribe(@Body() unsubscribeEmailDto: UnsubscribeEmailDto): Promise { - return this.emailRegistryService.unsubscribe(unsubscribeEmailDto); - } - - -} \ No newline at end of file +export class EmailRegistryController { + constructor(private readonly emailRegistryService: EmailRegistryService) {} + + @Post('subscribe') + @HttpCode(HttpStatus.CREATED) + async subscribe( + @Body() subscribeEmailDto: SubscribeEmailDto, + ): Promise { + return this.emailRegistryService.subscribe(subscribeEmailDto); + } + + @Get('verify') + @HttpCode(HttpStatus.OK) + async verify( + @Query() query: VerifyTokenDto, + ): Promise { + return this.emailRegistryService.verify(query); + } + + @Delete('unsubscribe') + @HttpCode(HttpStatus.NO_CONTENT) + async unsubscribe( + @Body() unsubscribeEmailDto: UnsubscribeEmailDto, + ): Promise { + return this.emailRegistryService.unsubscribe(unsubscribeEmailDto); + } + + @Get('export') + @UseGuards(GqlAuthGuard, RolesGuard) + async export(@Res() res: Response) { + return this.emailRegistryService.exportExcel(res); + } +} diff --git a/src/API/src/db/email-registry/email-registry.module.ts b/src/API/src/db/email-registry/email-registry.module.ts index c90c93a5c..c613c3d6d 100644 --- a/src/API/src/db/email-registry/email-registry.module.ts +++ b/src/API/src/db/email-registry/email-registry.module.ts @@ -4,10 +4,11 @@ import { EmailRegistryResolver } from './email-registry.resolver'; import { EmailRegistry } from './entities/email-registry.entity'; import { TypeOrmModule } from '@nestjs/typeorm/dist/typeorm.module'; import { EmailModule } from 'src/email/email.module'; +import { EmailRegistryController } from './email-registry.controller'; @Module({ - imports: [TypeOrmModule.forFeature([EmailRegistry]), -EmailModule], - providers: [EmailRegistryService, EmailRegistryResolver] + imports: [TypeOrmModule.forFeature([EmailRegistry]), EmailModule], + providers: [EmailRegistryService, EmailRegistryResolver], + controllers: [EmailRegistryController], }) export class EmailRegistryModule {} diff --git a/src/API/src/db/email-registry/email-registry.resolver.ts b/src/API/src/db/email-registry/email-registry.resolver.ts index d4e85ebe2..6f231859e 100644 --- a/src/API/src/db/email-registry/email-registry.resolver.ts +++ b/src/API/src/db/email-registry/email-registry.resolver.ts @@ -1,4 +1,94 @@ -import { Resolver } from '@nestjs/graphql'; +import { + Resolver, + Query, + Mutation, + Args, + Int, + ObjectType, + Field, + InputType, +} from '@nestjs/graphql'; +import { UseGuards } from '@nestjs/common'; +import { EmailRegistryService } from './email-registry.service'; +import { EmailRegistry } from './entities/email-registry.entity'; +import { RolesGuard } from 'src/auth/user_role/roles.guard'; +import { GqlAuthGuard } from 'src/auth/gqlAuthGuard'; +import { Role } from 'src/auth/user_role/role.enum'; +import { Roles } from 'src/auth/user_role/roles.decorator'; -@Resolver() -export class EmailRegistryResolver {} +@ObjectType() +class RegistryMeta { + @Field(() => Int) page: number; + @Field(() => Int) limit: number; + @Field(() => Int) total: number; + @Field(() => Int) totalPages: number; +} + +@ObjectType() +class RegistryPaginatedResponse { + @Field(() => [EmailRegistry]) data: EmailRegistry[]; + @Field(() => RegistryMeta) meta: RegistryMeta; +} + +@InputType() +class ManualRegistryInput { + @Field() email: string; + @Field({ nullable: true }) first_name?: string; + @Field({ nullable: true }) last_name?: string; +} + +@Resolver(() => EmailRegistry) +export class EmailRegistryResolver { + constructor(private readonly service: EmailRegistryService) {} + + @Query(() => RegistryPaginatedResponse, { name: 'adminEmailRegistry' }) + @UseGuards(GqlAuthGuard, RolesGuard) + @Roles(Role.Admin) + async getRegistry( + @Args('page', { nullable: true, type: () => Int }) page?: number, + @Args('limit', { nullable: true, type: () => Int }) limit?: number, + @Args('search', { nullable: true }) search?: string, + @Args('status', { nullable: true }) status?: string, + ) { + return this.service.findAll({ page, limit, search, status }); + } + + @Mutation(() => EmailRegistry, { name: 'adminAddEmailRegistry' }) + @UseGuards(GqlAuthGuard, RolesGuard) + @Roles(Role.Admin) + async addManual(@Args('input') input: ManualRegistryInput) { + return this.service.createManual(input); + } + + @Mutation(() => String, { name: 'queueDatasetCampaign' }) + @UseGuards(GqlAuthGuard, RolesGuard) + @Roles(Role.Admin) + async queueCampaign( + @Args('title') title: string, + @Args('message') message: string, + @Args('datasetUrl') datasetUrl: string, + ) { + const result = await this.service.queueDatasetCampaign( + title, + message, + datasetUrl, + ); + return `Queued ${result.sent} emails for delivery`; + } + + @Mutation(() => String, { name: 'queueNewsCampaign' }) + @UseGuards(GqlAuthGuard, RolesGuard) + @Roles(Role.Admin) + async queueNews( + @Args('title') title: string, + @Args('message') message: string, + @Args('newsUrl', { nullable: true }) newsUrl?: string, + ) { + const result = await this.service.queueNewsCampaign( + title, + message, + newsUrl, + ); + return `Queued ${result.sent} emails for delivery`; + } +} diff --git a/src/API/src/db/email-registry/email-registry.service.spec.ts b/src/API/src/db/email-registry/email-registry.service.spec.ts new file mode 100644 index 000000000..a9649d17c --- /dev/null +++ b/src/API/src/db/email-registry/email-registry.service.spec.ts @@ -0,0 +1,127 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { EmailRegistryService } from './email-registry.service'; +import { EmailService } from 'src/email/email.service'; +import { EmailRegistry } from './entities/email-registry.entity'; + +describe('EmailRegistryService - Campaigns', () => { + let service: EmailRegistryService; + let emailService: jest.Mocked; + let repo: jest.Mocked>; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmailRegistryService, + { + provide: EmailService, + useValue: { sendEmail: jest.fn().mockResolvedValue(undefined) }, + }, + { + provide: getRepositoryToken(EmailRegistry), + useValue: { + createQueryBuilder: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(EmailRegistryService); + emailService = module.get(EmailService); + repo = module.get(getRepositoryToken(EmailRegistry)); + }); + + it('queueNewsCampaign: compiles green template and sends only to verified subscribers', async () => { + const subscriber = { + id: 'usr-1', + email: 'alice@test.com', + first_name: 'Alice', + last_name: 'Smith', + unsubscription_token: 'tok-abc', + } as EmailRegistry; + + // Mock the query builder chain used by streamVerified() + const mockQb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getMany: jest + .fn() + .mockResolvedValueOnce([subscriber]) // first chunk has our user + .mockResolvedValueOnce([]), // second chunk is empty → stream ends + }; + repo.createQueryBuilder.mockReturnValue(mockQb as any); + + const result = await service.queueNewsCampaign( + 'August Newsletter', + 'Check out the new map overlays.', + 'https://vectoratlas.org/news/august-update', + ); + + expect(result.sent).toBe(1); + expect(emailService.sendEmail).toHaveBeenCalledTimes(1); + + const [recipients, , subject, html] = emailService.sendEmail.mock.calls[0]; + expect(recipients).toEqual(['alice@test.com']); + expect(subject).toBe('August Newsletter'); + expect(html).toContain('Hello Alice'); + expect(html).toContain('Check out the new map overlays.'); + expect(html).toContain('https://vectoratlas.org/news/august-update'); + expect(html).toContain('tok-abc'); // unsubscribe token injected + expect(html).toContain('Read Full Story'); // green CTA present + expect(html).toContain('background: linear-gradient'); // green header + }); + + it('queueNewsCampaign: returns 0 when no verified subscribers exist', async () => { + const mockQb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + }; + repo.createQueryBuilder.mockReturnValue(mockQb as any); + + const result = await service.queueNewsCampaign('Test', 'Test msg'); + expect(result.sent).toBe(0); + expect(emailService.sendEmail).not.toHaveBeenCalled(); + }); + + it('queueNewsCampaign: renders correctly without a newsUrl (no CTA button)', async () => { + const subscriber = { + id: 'usr-2', + email: 'bob@test.com', + first_name: 'Bob', + last_name: 'Jones', + unsubscription_token: 'tok-xyz', + } as EmailRegistry; + + const mockQb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getMany: jest + .fn() + .mockResolvedValueOnce([subscriber]) + .mockResolvedValueOnce([]), + }; + repo.createQueryBuilder.mockReturnValue(mockQb as any); + + const result = await service.queueNewsCampaign( + 'Quick Update', + 'Just a short message.', + undefined, // no URL + ); + + expect(result.sent).toBe(1); + const [, , , html] = emailService.sendEmail.mock.calls[0]; + expect(html).toContain('Just a short message.'); + expect(html).not.toContain('Read Full Story'); // CTA absent when no URL + expect(html).toContain('tok-xyz'); + }); +}); diff --git a/src/API/src/db/email-registry/email-registry.service.ts b/src/API/src/db/email-registry/email-registry.service.ts index 27e955800..9cd62d041 100644 --- a/src/API/src/db/email-registry/email-registry.service.ts +++ b/src/API/src/db/email-registry/email-registry.service.ts @@ -1,4 +1,8 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { v4 as uuidv4 } from 'uuid'; @@ -8,6 +12,8 @@ import { SubscribeEmailDto } from './dto/subscribe-email.dto'; import { VerifyTokenDto } from './dto/verify-token.dto'; import { UnsubscribeEmailDto } from './dto/unsubscribe-email.dto'; import { AccountStatus } from './entities/email-registry.entity'; +import { ILike } from 'typeorm'; +import * as ExcelJS from 'exceljs'; /** * Evaluates token windows against a strict 48-hour Time-To-Live validation boundary. @@ -16,67 +22,70 @@ const VERIFICATION_CODE_TTL_MS = 48 * 60 * 60 * 1000; @Injectable() export class EmailRegistryService { + constructor( + @InjectRepository(EmailRegistry) + private readonly emailRegistryRepository: Repository, + private readonly emailService: EmailService, + ) {} - constructor( - @InjectRepository(EmailRegistry) - private readonly emailRegistryRepository: Repository, - private readonly emailService: EmailService - ){} - - async subscribe(payload: SubscribeEmailDto): Promise { - const email = payload.email.trim().toLowerCase(); - const verificationToken = uuidv4(); - const unsubscriptionToken = uuidv4(); // Generate early so it is immediately ready - const codeExpiresAt = new Date(Date.now() + VERIFICATION_CODE_TTL_MS); - - let entry = await this.emailRegistryRepository.findOne({ where: { email } }); - - if (entry) { - if (entry.account_status === AccountStatus.VERIFIED) { - throw new BadRequestException('This email address is already actively subscribed.'); - } - - Object.assign(entry, { - first_name: payload.first_name, - last_name: payload.last_name, - notifications_enabled: payload.notifications_enabled, - }); - } else { - entry = this.emailRegistryRepository.create({ - id: uuidv4(), - first_name: payload.first_name, - last_name: payload.last_name, - email: email, - notifications_enabled: payload.notifications_enabled, - }); - } - - entry.account_status = AccountStatus.PENDING_VERIFICATION; - entry.verification_token = verificationToken; - entry.token_expires_at = codeExpiresAt; - entry.unsubscription_token = unsubscriptionToken; // Assign the secure token - - const savedEntry = await this.emailRegistryRepository.save(entry); - - const baseUrl = - process.env.EMAIL_VERIFICATION_BASE_URL ?? - process.env.API_BASE_URL ?? - 'http://localhost:3001'; - - const verificationLink = new URL('/api/verify', baseUrl); - verificationLink.searchParams.set('token', verificationToken); - - // Architectural Fix: Route lookup by Primary ID to optimize index tree traversal - // Expose the unsubscription token as the tamper-proof access check vector - const unsubscribeLink = new URL('/api/unsubscribe', baseUrl); - unsubscribeLink.searchParams.set('id', savedEntry.id); - unsubscribeLink.searchParams.set('token', unsubscriptionToken); - - await this.emailService.sendEmail( - [savedEntry.email], - [], - 'Verify your email subscription', - ` + async subscribe(payload: SubscribeEmailDto): Promise { + const email = payload.email.trim().toLowerCase(); + const verificationToken = uuidv4(); + const unsubscriptionToken = uuidv4(); // Generate early so it is immediately ready + const codeExpiresAt = new Date(Date.now() + VERIFICATION_CODE_TTL_MS); + + let entry = await this.emailRegistryRepository.findOne({ + where: { email }, + }); + + if (entry) { + if (entry.account_status === AccountStatus.VERIFIED) { + throw new BadRequestException( + 'This email address is already actively subscribed.', + ); + } + + Object.assign(entry, { + first_name: payload.first_name, + last_name: payload.last_name, + notifications_enabled: payload.notifications_enabled, + }); + } else { + entry = this.emailRegistryRepository.create({ + id: uuidv4(), + first_name: payload.first_name, + last_name: payload.last_name, + email: email, + notifications_enabled: payload.notifications_enabled, + }); + } + + entry.account_status = AccountStatus.PENDING_VERIFICATION; + entry.verification_token = verificationToken; + entry.token_expires_at = codeExpiresAt; + entry.unsubscription_token = unsubscriptionToken; // Assign the secure token + + const savedEntry = await this.emailRegistryRepository.save(entry); + + const baseUrl = + process.env.EMAIL_VERIFICATION_BASE_URL ?? + process.env.API_BASE_URL ?? + 'http://localhost:3001'; + + const verificationLink = new URL('/api/verify', baseUrl); + verificationLink.searchParams.set('token', verificationToken); + + // Architectural Fix: Route lookup by Primary ID to optimize index tree traversal + // Expose the unsubscription token as the tamper-proof access check vector + const unsubscribeLink = new URL('/api/unsubscribe', baseUrl); + unsubscribeLink.searchParams.set('id', savedEntry.id); + unsubscribeLink.searchParams.set('token', unsubscriptionToken); + + await this.emailService.sendEmail( + [savedEntry.email], + [], + 'Verify your email subscription', + `

Thanks for subscribing to updates, ${savedEntry.first_name}.

Please verify your email address by clicking this link:

${verificationLink.toString()}

@@ -87,63 +96,353 @@ export class EmailRegistryService { Received this by mistake? Unsubscribe instantly here.

`, - ); + ); + + return savedEntry; + } + + async verify(query: VerifyTokenDto): Promise { + const trimmedToken = query.token?.trim(); + if (!trimmedToken) { + throw new BadRequestException( + 'Verification token parameter is missing from request.', + ); + } + + const entry = await this.emailRegistryRepository.findOne({ + where: { verification_token: trimmedToken }, + }); - return savedEntry; + if (!entry) { + throw new NotFoundException( + 'The verification token provided is invalid or has already been used.', + ); } - async verify(query: VerifyTokenDto): Promise { - const trimmedToken = query.token?.trim(); - if (!trimmedToken) { - throw new BadRequestException('Verification token parameter is missing from request.'); - } + if ( + entry.token_expires_at && + entry.token_expires_at.getTime() < Date.now() + ) { + throw new BadRequestException( + 'This verification link has expired. Please request a new subscription.', + ); + } + + entry.account_status = AccountStatus.VERIFIED; + entry.verification_token = null; + entry.token_expires_at = null; - const entry = await this.emailRegistryRepository.findOne({ - where: { verification_token: trimmedToken }, - }); + return await this.emailRegistryRepository.save(entry); + } - if (!entry) { - throw new NotFoundException('The verification token provided is invalid or has already been used.'); - } + async unsubscribe(payload: UnsubscribeEmailDto): Promise { + const targetId = payload.id?.trim(); + const secureToken = payload.token?.trim(); - if (entry.token_expires_at && entry.token_expires_at.getTime() < Date.now()) { - throw new BadRequestException('This verification link has expired. Please request a new subscription.'); - } + if (!targetId || !secureToken) { + throw new BadRequestException( + 'Required unsubscription identifiers are missing.', + ); + } - entry.account_status = AccountStatus.VERIFIED; - entry.verification_token = null; - entry.token_expires_at = null; + const entry = await this.emailRegistryRepository.findOne({ + where: { id: targetId }, + }); - return await this.emailRegistryRepository.save(entry); + if (!entry || entry.unsubscription_token !== secureToken) { + throw new NotFoundException( + 'The unsubscription link is invalid or has already been processed.', + ); } - - async unsubscribe(payload: UnsubscribeEmailDto): Promise { - const targetId = payload.id?.trim(); - const secureToken = payload.token?.trim(); - - if (!targetId || !secureToken) { - throw new BadRequestException('Required unsubscription identifiers are missing.'); - } - - - const entry = await this.emailRegistryRepository.findOne({ - where: { id: targetId }, - }); - - - if (!entry || entry.unsubscription_token !== secureToken) { - throw new NotFoundException('The unsubscription link is invalid or has already been processed.'); - } - - - entry.account_status = AccountStatus.UNSUBSCRIBED; - entry.notifications_enabled = false; - - - entry.unsubscription_token = null; - - await this.emailRegistryRepository.save(entry); + entry.account_status = AccountStatus.UNSUBSCRIBED; + entry.notifications_enabled = false; + + entry.unsubscription_token = null; + + await this.emailRegistryRepository.save(entry); + } + + private async *streamVerified() { + const chunkSize = 500; + let lastId: string | null = null; + + while (true) { + const qb = this.emailRegistryRepository + .createQueryBuilder('er') + .where('er.account_status = :status', { + status: AccountStatus.VERIFIED, + }) + .andWhere('er.notifications_enabled = :enabled', { enabled: true }) + .orderBy('er.id', 'ASC') + .take(chunkSize); + + if (lastId) { + qb.andWhere('er.id > :lastId', { lastId }); + } + + const rows = await qb.getMany(); + if (rows.length === 0) break; + + for (const row of rows) { + yield row; // "yield" means: pause here, give this row to the caller, resume later + } + + lastId = rows[rows.length - 1].id; } -} + } + async findAll(query: { + page?: number; + limit?: number; + search?: string; + status?: string; + }) { + const { page = 1, limit = 20, search, status } = query; + const skip = (page - 1) * limit; + + const where: any = {}; + if (search) where.email = ILike(`%${search}%`); + if (status && status !== 'all') where.account_status = status; + + const [data, total] = await this.emailRegistryRepository.findAndCount({ + where, + order: { token_expires_at: 'DESC' }, + skip, + take: limit, + }); + + return { + data, + meta: { page, limit, total, totalPages: Math.ceil(total / limit) }, + }; + } + + async createManual(dto: { + email: string; + first_name?: string; + last_name?: string; + }) { + const email = dto.email.trim().toLowerCase(); + const exists = await this.emailRegistryRepository.findOne({ + where: { email }, + }); + if (exists) { + throw new BadRequestException('Email already exists in registry.'); + } + + const record = this.emailRegistryRepository.create({ + id: uuidv4(), + email, + first_name: dto.first_name || '', + last_name: dto.last_name || '', + account_status: AccountStatus.VERIFIED, + notifications_enabled: true, + verification_token: uuidv4(), + token_expires_at: new Date(Date.now() + 48 * 60 * 60 * 1000), + unsubscription_token: uuidv4(), + }); + + return this.emailRegistryRepository.save(record); + } + + async exportExcel(res: any) { + const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ + stream: res, + useStyles: true, + }); + + const ws = workbook.addWorksheet('Email Registry'); + ws.columns = [ + { header: 'ID', key: 'id', width: 36 }, + { header: 'Email', key: 'email', width: 40 }, + { header: 'First Name', key: 'first_name', width: 20 }, + { header: 'Last Name', key: 'last_name', width: 20 }, + { header: 'Status', key: 'account_status', width: 20 }, + { header: 'Notifications', key: 'notifications_enabled', width: 18 }, + { header: 'Token Expires', key: 'token_expires_at', width: 25 }, + ]; + + ws.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } }; + ws.getRow(1).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF2563EB' }, + }; + + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + res.setHeader( + 'Content-Disposition', + 'attachment; filename=email-registry.xlsx', + ); + + // Stream rows in chunks + const batchSize = 1000; + let lastId: string | null = null; + + while (true) { + const qb = this.emailRegistryRepository + .createQueryBuilder('er') + .orderBy('er.id', 'ASC') + .take(batchSize); + + if (lastId) qb.where('er.id > :lastId', { lastId }); + + const rows = await qb.getMany(); + if (rows.length === 0) break; + + for (const r of rows) { + ws.addRow({ + id: r.id, + email: r.email, + first_name: r.first_name || '', + last_name: r.last_name || '', + account_status: r.account_status, + notifications_enabled: r.notifications_enabled ? 'Yes' : 'No', + token_expires_at: r.token_expires_at?.toISOString() || '', + }).commit(); + } + + lastId = rows[rows.length - 1].id; + } + + await ws.commit(); + await workbook.commit(); + } + + private compileDatasetTemplate( + title: string, + message: string, + datasetUrl: string, + unsubscribeUrl: string, + firstName?: string, + ): string { + return ` + + + + + + +

Vector Atlas

+
+

${title}

+

Hello ${firstName || 'there'},

+

${message}

+ View Dataset +
+ + + `; + } + + async queueDatasetCampaign( + title: string, + message: string, + datasetUrl: string, + ) { + let count = 0; + const baseUrl = process.env.API_BASE_URL ?? 'http://localhost:3001'; + + for await (const record of this.streamVerified()) { + const html = this.compileDatasetTemplate( + title, + message, + datasetUrl, + `${baseUrl}/api/unsubscribe?id=${record.id}&token=${record.unsubscription_token}`, + record.first_name, + ); + + await this.emailService.sendEmail([record.email], [], title, html); + count++; + + // THROTTLE: 5 emails per second to respect SMTP limits + if (count % 5 === 0) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + + return { sent: count }; + } + + private compileNewsTemplate( + title: string, + message: string, + newsUrl: string | undefined, + unsubscribeUrl: string, + firstName?: string, + ): string { + const ctaButton = newsUrl + ? `Read Full Story` + : ''; + + return ` + + + + + + + +
+

Vector Atlas

+
+

${title}

+

Hello ${firstName || 'there'},

+

${message}

+ ${ctaButton} +
+ +
+ + `; + } + + async queueNewsCampaign(title: string, message: string, newsUrl?: string) { + let count = 0; + const baseUrl = process.env.API_BASE_URL ?? 'http://localhost:3001'; + + for await (const record of this.streamVerified()) { + const html = this.compileNewsTemplate( + title, + message, + newsUrl, + `${baseUrl}/api/unsubscribe?id=${record.id}&token=${record.unsubscription_token}`, + record.first_name, + ); + + await this.emailService.sendEmail([record.email], [], title, html); + count++; + + if (count % 5 === 0) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + + return { sent: count }; + } +} diff --git a/src/API/src/db/email-registry/entities/email-registry.entity.ts b/src/API/src/db/email-registry/entities/email-registry.entity.ts index b7a0cc159..46ecf1b57 100644 --- a/src/API/src/db/email-registry/entities/email-registry.entity.ts +++ b/src/API/src/db/email-registry/entities/email-registry.entity.ts @@ -1,64 +1,59 @@ -import { ObjectType, Field, registerEnumType } from "@nestjs/graphql"; +import { ObjectType, Field, registerEnumType } from '@nestjs/graphql'; // import { BaseEntityExtended } from "../../base.entity.extended"; -import { Column, Entity, PrimaryColumn } from "typeorm"; -import { IsEnum } from "class-validator"; +import { Column, Entity, PrimaryColumn } from 'typeorm'; +import { IsEnum } from 'class-validator'; export enum AccountStatus { - PENDING_VERIFICATION = 'pending_verification', - VERIFIED = 'verified', - DEACTIVATED = 'deactivated', - UNSUBSCRIBED = 'unsubscribed', + PENDING_VERIFICATION = 'pending_verification', + VERIFIED = 'verified', + DEACTIVATED = 'deactivated', + UNSUBSCRIBED = 'unsubscribed', } registerEnumType(AccountStatus, { - name: 'AccountStatus', - description: 'The current verification or activity state of the email registry account.', + name: 'AccountStatus', + description: + 'The current verification or activity state of the email registry account.', }); - @Entity('email_registry') -@ObjectType({ description: 'Email Registry'}) +@ObjectType({ description: 'Email Registry' }) //data model to store email registry information. This will be used to send emails to users for various events - -export class EmailRegistry{ - - @Field(() => String) - @PrimaryColumn() - id: string; - - @Field(() => String) - @Column() - first_name: string; - - @Field(() => String) - @Column() - last_name: string; - - @Field(() => String) - @Column({ unique: true , nullable: false}) - email: string; - - @Field(() => AccountStatus) - @Column({default: 'pending_verification'}) - @IsEnum(AccountStatus) - account_status: AccountStatus; - - @Field() - @Column({default: true}) - notifications_enabled: boolean; - - @Field() - @Column() - verification_token: string; - - @Field(() => Date) - @Column({nullable: false, type: 'timestamp'}) - token_expires_at: Date; - - @Field() - @Column({nullable: true}) - unsubscription_token: string; - +export class EmailRegistry { + @Field(() => String) + @PrimaryColumn() + id: string; + + @Field(() => String) + @Column() + first_name: string; + + @Field(() => String) + @Column() + last_name: string; + + @Field(() => String) + @Column({ unique: true, nullable: false }) + email: string; + + @Field(() => AccountStatus) + @Column({ default: 'pending_verification' }) + @IsEnum(AccountStatus) + account_status: AccountStatus; + + @Field() + @Column({ default: true }) + notifications_enabled: boolean; + + @Field() + @Column() + verification_token: string; + + @Field(() => Date) + @Column({ nullable: false, type: 'timestamp' }) + token_expires_at: Date; + + @Field() + @Column({ nullable: true }) + unsubscription_token: string; } - - diff --git a/src/API/src/main.ts b/src/API/src/main.ts index 9b2a73e00..d084c23b1 100644 --- a/src/API/src/main.ts +++ b/src/API/src/main.ts @@ -70,6 +70,6 @@ async function bootstrap() { }); app.use(json({ limit: '30mb' })); app.enableCors(); - await app.listen(3001); + await app.listen(3001, '0.0.0.0'); } bootstrap(); diff --git a/src/API/src/schema.gql b/src/API/src/schema.gql index 4e6648567..f9b20389e 100644 --- a/src/API/src/schema.gql +++ b/src/API/src/schema.gql @@ -2,6 +2,16 @@ # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) # ------------------------------------------------------ +""" +The current verification or activity state of the email registry account. +""" +enum AccountStatus { + DEACTIVATED + PENDING_VERIFICATION + UNSUBSCRIBED + VERIFIED +} + """bionomics data""" type Bionomics { adult_data: Boolean @@ -160,6 +170,19 @@ A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date """ scalar DateTime +"""Email Registry""" +type EmailRegistry { + account_status: AccountStatus! + email: String! + first_name: String! + id: String! + last_name: String! + notifications_enabled: Boolean! + token_expires_at: DateTime! + unsubscription_token: String! + verification_token: String! +} + type ExportJob { blobPath: String completedAt: DateTime @@ -199,16 +222,25 @@ The `JSON` scalar type represents JSON values as specified by [ECMA-404](http:// """ scalar JSON +input ManualRegistryInput { + email: String! + first_name: String + last_name: String +} + type ModelProcessingStatus { status: String! } type Mutation { + adminAddEmailRegistry(input: ManualRegistryInput!): EmailRegistry! createEditNews(input: CreateNewsInput!): News! createEditSpeciesInformation(input: CreateSpeciesInformationInput!): SpeciesInformation! createReference(input: CreateReferenceInput!): Reference! deleteSpeciesInformation(id: String!): Boolean! disableNotifications(disable: Boolean!, userId: String!): Boolean! + queueDatasetCampaign(datasetUrl: String!, message: String!, title: String!): String! + queueNewsCampaign(message: String!, newsUrl: String, title: String!): String! requestRoles(email: String!, requestReason: String!, rolesRequested: [String!]!): Boolean! updateCountry(input: UpdateCountryInput!): Country! updateRecordedSpecies(input: UpdateRecordedSpeciesInput!): RecordedSpecies! @@ -336,6 +368,7 @@ type Query { FullOccurrenceData(selectedIds: [String!]!): [Occurrence!]! OccurrenceCsvData(bounds: BoundsFilter, downloaderEmail: String!, downloaderName: String!, filters: OccurrenceFilter, generateDoi: Boolean!, skip: Float = 0, take: Float = 1): PaginatedStringData! OccurrenceData(bounds: BoundsFilter, filters: OccurrenceFilter, skip: Float = 0, take: Float = 1): PaginatedOccurrenceReturnData! + adminEmailRegistry(limit: Int, page: Int, search: String, status: String): RegistryPaginatedResponse! allCommunicationLogs: [CommunicationLog!]! allCommunicationLogsBySentStatus(status: String!): [CommunicationLog!]! allCountries: [Country!]! @@ -394,6 +427,18 @@ type Reference { year: Int } +type RegistryMeta { + limit: Int! + page: Int! + total: Int! + totalPages: Int! +} + +type RegistryPaginatedResponse { + data: [EmailRegistry!]! + meta: RegistryMeta! +} + """sample data""" type Sample { control: Boolean diff --git a/src/UI/components/shared/footer.tsx b/src/UI/components/shared/footer.tsx index 0d6b83b4c..a910758dc 100644 --- a/src/UI/components/shared/footer.tsx +++ b/src/UI/components/shared/footer.tsx @@ -1,7 +1,13 @@ -import styles from '../../styles/Home.module.css'; - -import { useAppSelector } from '../../state/hooks'; +import { + Box, + Container, + Typography, + Link as MuiLink, + Grid, +} from '@mui/material'; +import Link from 'next/link'; import { useTranslations } from 'next-intl'; +import { useAppSelector } from '../../state/hooks'; function Footer() { const t = useTranslations('Footer'); @@ -9,9 +15,88 @@ function Footer() { const version_api = useAppSelector((state) => state.config.version_api); return ( -
- {/* {t('uiVersion')}: {version_ui} | {t('apiVersion')}: {version_api} */} -
+ + + + + + Vector Atlas + + + {t('description') || + 'Mapping the future of vector-borne disease research.'} + + + UI: {version_ui} | API: {version_api} + + + + + + {t('contactTitle') || 'Contact Us'} + + + + vectoratlas@icipe.org + + + + + + + {t('resourcesTitle') || 'Resources'} + + + + + {t('docs') || 'Documentation'} + + + + + {t('datasets') || 'Datasets'} + + + + + {t('subscribe') || 'Subscribe to Updates'} + + + + + + + + + © {new Date().getFullYear()} Vector Atlas. All rights reserved. + + + + ); } diff --git a/src/UI/package.json b/src/UI/package.json index e6873f741..0138fadad 100644 --- a/src/UI/package.json +++ b/src/UI/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "start:dev": "next dev -p 3002", + "start:dev": "next dev -H 0.0.0.0 -p 3002", "build": "next build", "start": "next start", "lint": "next lint --dir .", diff --git a/src/UI/pages/_app.tsx b/src/UI/pages/_app.tsx index 43b8abaae..b34e11260 100644 --- a/src/UI/pages/_app.tsx +++ b/src/UI/pages/_app.tsx @@ -8,7 +8,7 @@ import CssBaseline from '@mui/material/CssBaseline'; import theme from '../styles/theme'; import store from '../state/store'; import NavBar from '../components/shared/navbar'; -// import Footer from '../components/shared/footer'; +import Footer from '../components/shared/footer'; import { useEffect } from 'react'; import 'react-toastify/dist/ReactToastify.css'; import { ToastContainer } from 'react-toastify'; @@ -79,6 +79,7 @@ function MyApp({ Component, pageProps }: AppProps) { > +