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 `
+
+
+
+
+
+
+
+
+
${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 `
+
+
+
+
+
+
+
+
+
+
+
${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 (
-
+
+
+
+
+
+ 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) {
>
+
diff --git a/src/UI/pages/admin/email-registry/index.tsx b/src/UI/pages/admin/email-registry/index.tsx
new file mode 100644
index 000000000..7e0f98bcc
--- /dev/null
+++ b/src/UI/pages/admin/email-registry/index.tsx
@@ -0,0 +1,213 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Container,
+ Typography,
+ Button,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Paper,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ TextField,
+ Box,
+ Chip,
+ CircularProgress,
+} from '@mui/material';
+import { useTranslations } from 'next-intl';
+import { getMessages } from '../../../utils/localization';
+import { GetServerSidePropsContext } from 'next';
+
+interface RegistryRecord {
+ id: string;
+ email: string;
+ first_name: string;
+ last_name: string;
+ account_status: string;
+ notifications_enabled: boolean;
+}
+
+const statusColors: Record<
+ string,
+ 'success' | 'warning' | 'default' | 'error'
+> = {
+ verified: 'success',
+ pending_verification: 'warning',
+ unsubscribed: 'default',
+ deactivated: 'error',
+};
+
+const AdminEmailRegistryPage = (): JSX.Element => {
+ const t = useTranslations('Admin');
+ const [records, setRecords] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showModal, setShowModal] = useState(false);
+ const [email, setEmail] = useState('');
+ const [firstName, setFirstName] = useState('');
+ const [lastName, setLastName] = useState('');
+
+ async function load() {
+ setLoading(true);
+ const res = await fetch('/vector-api/graphql', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ query: `
+ query {
+ adminEmailRegistry(page: 1, limit: 50) {
+ data { id email first_name last_name account_status notifications_enabled }
+ }
+ }
+ `,
+ }),
+ });
+ const json = await res.json();
+ setRecords(json.data?.adminEmailRegistry?.data || []);
+ setLoading(false);
+ }
+
+ async function addUser(e: React.FormEvent) {
+ e.preventDefault();
+ await fetch('/vector-api/graphql', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ query: `
+ mutation {
+ adminAddEmailRegistry(input: { email: "${email}", first_name: "${firstName}", last_name: "${lastName}" }) {
+ id
+ }
+ }
+ `,
+ }),
+ });
+ setShowModal(false);
+ setEmail('');
+ setFirstName('');
+ setLastName('');
+ load();
+ }
+
+ useEffect(() => {
+ load();
+ }, []);
+
+ return (
+
+
+ {t('title') || 'Email Registry'}
+
+
+
+
+
+
+
+
+
+ {loading ? (
+
+
+
+ ) : (
+
+
+
+
+ Email
+ Name
+ Status
+ Notifications
+
+
+
+ {records.map((r) => (
+
+ {r.email}
+
+ {r.first_name} {r.last_name}
+
+
+
+
+
+ {r.notifications_enabled ? 'On' : 'Off'}
+
+
+ ))}
+
+
+
+ )}
+
+ );
+};
+
+export async function getServerSideProps(context: GetServerSidePropsContext) {
+ return await getMessages(context);
+}
+
+export default AdminEmailRegistryPage;
diff --git a/src/UI/pages/subscribe/index.tsx b/src/UI/pages/subscribe/index.tsx
new file mode 100644
index 000000000..be15a34fd
--- /dev/null
+++ b/src/UI/pages/subscribe/index.tsx
@@ -0,0 +1,176 @@
+import React, { useState } from 'react';
+import {
+ Container,
+ Typography,
+ TextField,
+ Button,
+ FormControlLabel,
+ Checkbox,
+ Paper,
+ Alert,
+ Box,
+ CircularProgress,
+} from '@mui/material';
+import { useTranslations } from 'next-intl';
+import { getMessages } from '../../utils/localization';
+import { GetServerSidePropsContext } from 'next';
+
+const SubscribePage = (): JSX.Element => {
+ const t = useTranslations('EmailSubscription');
+
+ const [form, setForm] = useState({
+ first_name: '',
+ last_name: '',
+ email: '',
+ notifications_enabled: true,
+ });
+
+ const [status, setStatus] = useState<
+ 'idle' | 'loading' | 'success' | 'error'
+ >('idle');
+ const [errorMsg, setErrorMsg] = useState('');
+
+ const isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email);
+ const canSubmit =
+ form.first_name.trim().length > 0 &&
+ form.last_name.trim().length > 0 &&
+ isValidEmail &&
+ status !== 'loading';
+
+ const handleChange = (field: string, value: string | boolean) => {
+ setForm((prev) => ({ ...prev, [field]: value }));
+ };
+
+ const handleSubmit = async () => {
+ if (!canSubmit) return;
+
+ setStatus('loading');
+ setErrorMsg('');
+
+ try {
+ const res = await fetch('/vector-api/api/subscribe', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ ...form,
+ email: form.email.trim().toLowerCase(),
+ }),
+ });
+
+ if (res.ok) {
+ setStatus('success');
+ } else {
+ const err = await res.json().catch(() => ({}));
+ setErrorMsg(err.message || 'Failed to subscribe. Please try again.');
+ setStatus('error');
+ }
+ } catch {
+ setErrorMsg('Network error. Please check your connection and try again.');
+ setStatus('error');
+ }
+ };
+
+ return (
+
+
+
+ {t('title') || 'Subscribe for Updates'}
+
+
+ {status === 'success' ? (
+
+ {t('successMessage') ||
+ "Almost done! We've sent a verification link to your email — please check your inbox to confirm."}
+
+ ) : (
+ <>
+
+ {t('description') ||
+ 'Get notified about news and new datasets on VectorAtlas.'}
+
+
+ handleChange('first_name', e.target.value)}
+ margin="normal"
+ required
+ error={status === 'error' && !form.first_name.trim()}
+ />
+
+ handleChange('last_name', e.target.value)}
+ margin="normal"
+ required
+ error={status === 'error' && !form.last_name.trim()}
+ />
+
+ handleChange('email', e.target.value)}
+ margin="normal"
+ required
+ error={!!form.email && !isValidEmail}
+ helperText={
+ !!form.email && !isValidEmail
+ ? 'Please enter a valid email'
+ : ''
+ }
+ />
+
+
+
+ handleChange('notifications_enabled', e.target.checked)
+ }
+ />
+ }
+ label={
+ t('notificationsLabel') ||
+ 'Subscribe to news and dataset updates'
+ }
+ />
+
+
+ {status === 'error' && (
+
+ {errorMsg}
+
+ )}
+
+
+ >
+ )}
+
+
+ );
+};
+
+export async function getServerSideProps(context: GetServerSidePropsContext) {
+ return await getMessages(context);
+}
+
+export default SubscribePage;
diff --git a/src/UI/pages/unsubscribed-success/index.tsx b/src/UI/pages/unsubscribed-success/index.tsx
new file mode 100644
index 000000000..f2722d566
--- /dev/null
+++ b/src/UI/pages/unsubscribed-success/index.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { Container, Box, Typography, Button } from '@mui/material';
+import Link from 'next/link';
+import { useTranslations } from 'next-intl';
+import { getMessages } from '../../utils/localization';
+import { GetServerSidePropsContext } from 'next';
+
+const UnsubscribedSuccessPage = (): JSX.Element => {
+ const t = useTranslations('EmailSubscription');
+
+ return (
+
+
+
+ {t('unsubscribedTitle') || 'Unsubscribed'}
+
+
+ {t('unsubscribedBody') ||
+ "You've been removed from our mailing list. You won't receive any further updates."}
+
+
+
+
+
+
+ );
+};
+
+export async function getServerSideProps(context: GetServerSidePropsContext) {
+ return await getMessages(context);
+}
+
+export default UnsubscribedSuccessPage;
diff --git a/src/UI/pages/verify-expired/index.tsx b/src/UI/pages/verify-expired/index.tsx
new file mode 100644
index 000000000..7ad550509
--- /dev/null
+++ b/src/UI/pages/verify-expired/index.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { Container, Box, Typography, Button } from '@mui/material';
+import Link from 'next/link';
+import { useTranslations } from 'next-intl';
+import { getMessages } from '../../utils/localization';
+import { GetServerSidePropsContext } from 'next';
+
+const VerifyExpiredPage = (): JSX.Element => {
+ const t = useTranslations('EmailSubscription');
+
+ return (
+
+
+
+ {t('verifyExpiredTitle') || 'Link Expired'}
+
+
+ {t('verifyExpiredBody') ||
+ 'This verification link has expired. Please subscribe again to receive a new one.'}
+
+
+
+
+
+
+ );
+};
+
+export async function getServerSideProps(context: GetServerSidePropsContext) {
+ return await getMessages(context);
+}
+
+export default VerifyExpiredPage;
diff --git a/src/UI/pages/verify-success/index.tsx b/src/UI/pages/verify-success/index.tsx
new file mode 100644
index 000000000..ca7d6f2ac
--- /dev/null
+++ b/src/UI/pages/verify-success/index.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { Container, Box, Typography, Button } from '@mui/material';
+import Link from 'next/link';
+import { useTranslations } from 'next-intl';
+import { getMessages } from '../../utils/localization';
+import { GetServerSidePropsContext } from 'next';
+
+const VerifySuccessPage = (): JSX.Element => {
+ const t = useTranslations('EmailSubscription');
+
+ return (
+
+
+
+ {t('verifySuccessTitle') || 'Email Verified'}
+
+
+ {t('verifySuccessBody') ||
+ "You're all set — you'll now receive the updates you signed up for."}
+
+
+
+
+
+
+ );
+};
+
+export async function getServerSideProps(context: GetServerSidePropsContext) {
+ return await getMessages(context);
+}
+
+export default VerifySuccessPage;
diff --git a/src/UI/public/messages/en.json b/src/UI/public/messages/en.json
index 7003ff133..e7a86bca5 100644
--- a/src/UI/public/messages/en.json
+++ b/src/UI/public/messages/en.json
@@ -714,6 +714,14 @@
"disableNotifications": "Disable Email Notifications"
}
},
+ "Admin": {
+ "title": "Email Registry",
+ "addUser": "+ Add User",
+ "downloadExcel": "Download Excel",
+ "addUserTitle": "Manually Add Subscriber",
+ "cancel": "Cancel",
+ "add": "Add"
+ },
"MapPage": {
"areaModeOn": "Area mode on",
"drawerMap": {
@@ -804,7 +812,28 @@
"logout": "Logout"
},
"Footer": {
- "apiVersion": "API Version"
+ "apiVersion": "API Version",
+ "description": "Mapping the future of vector-borne disease research.",
+ "contactTitle": "Contact Us",
+ "resourcesTitle": "Resources",
+ "docs": "Documentation",
+ "datasets": "Datasets",
+ "subscribe": "Subscribe to Updates"
+ },
+ "EmailSubscription": {
+ "title": "Subscribe for Updates",
+ "description": "Get notified about news and new datasets on VectorAtlas.",
+ "successMessage": "Almost done! We've sent a verification link to your email — please check your inbox to confirm.",
+ "notificationsLabel": "Subscribe to news and dataset updates",
+ "subscribeBtn": "Subscribe",
+ "verifySuccessTitle": "Email Verified",
+ "verifySuccessBody": "You're all set — you'll now receive the updates you signed up for.",
+ "verifyExpiredTitle": "Link Expired",
+ "verifyExpiredBody": "This verification link has expired. Please subscribe again to receive a new one.",
+ "unsubscribedTitle": "Unsubscribed",
+ "unsubscribedBody": "You've been removed from our mailing list. You won't receive any further updates.",
+ "backHomeBtn": "Back to VectorAtlas",
+ "subscribeAgainBtn": "Subscribe Again"
},
"AuthWrapper": {
"notAssignedRole": "You are not currently {pronoun} ",
From 1df716a6fcd03daecbb8c42147ab7bc41b4ea231 Mon Sep 17 00:00:00 2001
From: Charity
Date: Thu, 27 Aug 2026 12:45:05 +0300
Subject: [PATCH 08/10] fix: remove duplicate email-sending queue, allow token
rotation on verify instead of null
---
package-lock.json | 468 ------------------
package.json | 6 -
src/API/package-lock.json | 61 ++-
src/API/src/app.module.ts | 2 +-
src/API/src/auth/auth.module.ts | 1 -
.../dataset-upload/dataset-upload.module.ts | 4 +-
src/API/src/db/communication-log/mailer.ts | 21 +-
.../dto/unsubscribe-email.dto.ts | 11 +-
.../email-registry/email-registry.resolver.ts | 7 +
.../email-registry/email-registry.service.ts | 26 +-
.../src/db/shared/dynamic-export.module.ts | 4 +-
src/API/src/db/shared/shared.module.ts | 7 +-
.../uploaded-dataset.module.ts | 4 +-
.../uploaded-model/uploaded-model.module.ts | 4 +-
src/API/src/email/email.module.ts | 5 +-
src/API/src/email/email.processor.ts | 4 +-
src/API/src/models/models.module.ts | 4 +-
src/API/src/review/review.module.ts | 4 +-
src/API/src/schema.gql | 7 +
src/UI/pages/unsubscribed-success/index.tsx | 67 ++-
src/UI/pages/verify-success/index.tsx | 88 +++-
21 files changed, 246 insertions(+), 559 deletions(-)
delete mode 100644 package-lock.json
delete mode 100644 package.json
diff --git a/package-lock.json b/package-lock.json
deleted file mode 100644
index 137430ecf..000000000
--- a/package-lock.json
+++ /dev/null
@@ -1,468 +0,0 @@
-{
- "name": "vectoratlas-software-code",
- "lockfileVersion": 3,
- "requires": true,
- "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
deleted file mode 100644
index d8e015050..000000000
--- a/package.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "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 89bbc239d..8db688177 100644
--- a/src/API/package-lock.json
+++ b/src/API/package-lock.json
@@ -1019,7 +1019,6 @@
"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",
@@ -1048,14 +1047,12 @@
"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==",
- "dev": true
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
},
"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"
}
@@ -1524,7 +1521,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==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
@@ -1536,7 +1533,7 @@
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
@@ -4630,25 +4627,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==",
- "dev": true
+ "devOptional": 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==",
- "dev": true
+ "devOptional": 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==",
- "dev": true
+ "devOptional": 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==",
- "dev": true
+ "devOptional": true
},
"node_modules/@types/accepts": {
"version": "1.3.7",
@@ -5478,7 +5475,7 @@
"version": "8.12.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
- "dev": true,
+ "devOptional": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5508,7 +5505,7 @@
"version": "8.3.3",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz",
"integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"acorn": "^8.11.0"
},
@@ -6098,7 +6095,7 @@
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
- "dev": true
+ "devOptional": true
},
"node_modules/argparse": {
"version": "2.0.1",
@@ -7346,7 +7343,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==",
- "dev": true
+ "devOptional": true
},
"node_modules/cron": {
"version": "4.4.0",
@@ -7653,7 +7650,7 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
- "dev": true,
+ "devOptional": true,
"engines": {
"node": ">=0.3.1"
}
@@ -11972,7 +11969,7 @@
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
- "dev": true
+ "devOptional": true
},
"node_modules/makeerror": {
"version": "1.0.12",
@@ -14073,6 +14070,20 @@
"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",
@@ -14722,6 +14733,16 @@
"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",
@@ -15892,7 +15913,7 @@
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -16227,7 +16248,7 @@
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
- "dev": true,
+ "devOptional": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16382,7 +16403,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==",
- "dev": true
+ "devOptional": true
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
@@ -17065,7 +17086,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
- "dev": true,
+ "devOptional": true,
"engines": {
"node": ">=6"
}
diff --git a/src/API/src/app.module.ts b/src/API/src/app.module.ts
index cbdb9281e..c35048c63 100644
--- a/src/API/src/app.module.ts
+++ b/src/API/src/app.module.ts
@@ -79,7 +79,7 @@ import { EmailRegistryModule } from './db/email-registry/email-registry.module';
port: Number(process.env.EMAIL_PORT),
secure: false,
auth: {
- user: process.env.EMAIL_FROM,
+ user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASSWORD,
},
},
diff --git a/src/API/src/auth/auth.module.ts b/src/API/src/auth/auth.module.ts
index 11c2e9775..d6dc287df 100644
--- a/src/API/src/auth/auth.module.ts
+++ b/src/API/src/auth/auth.module.ts
@@ -35,7 +35,6 @@ import { CommunicationLogService } from 'src/db/communication-log/communication-
GqlAuthGuard,
AuthResolver,
AuthService,
- EmailService,
CommunicationLogService,
Logger,
],
diff --git a/src/API/src/dataset-upload/dataset-upload.module.ts b/src/API/src/dataset-upload/dataset-upload.module.ts
index 926cecd7f..f629fc2e9 100644
--- a/src/API/src/dataset-upload/dataset-upload.module.ts
+++ b/src/API/src/dataset-upload/dataset-upload.module.ts
@@ -18,6 +18,7 @@ import { DOI } from 'src/db/doi/entities/doi.entity';
import { EmailService } from '../email/email.service';
import { AzureBlobService } from 'src/db/azure-blob/azure-blob.service';
import { SharedModule } from 'src/db/shared/shared.module';
+import { EmailModule } from 'src/email/email.module';
@Module({
controllers: [DatasetUploadController],
@@ -31,12 +32,13 @@ import { SharedModule } from 'src/db/shared/shared.module';
CommunicationLogService,
UploadedDatasetLogService,
DoiService,
- EmailService,
+ //EmailService,
AzureBlobService,
Logger,
],
imports: [
HttpModule,
+ EmailModule,
SharedModule,
// DoiModule,
// UploadedDatasetModule,
diff --git a/src/API/src/db/communication-log/mailer.ts b/src/API/src/db/communication-log/mailer.ts
index e17fc9be4..dd10c465f 100644
--- a/src/API/src/db/communication-log/mailer.ts
+++ b/src/API/src/db/communication-log/mailer.ts
@@ -13,10 +13,14 @@ export const sendEmail = async (
subject: string,
message: string,
): Promise => {
+
+ // 1. Generic SMTP configuration reading directly from your environmental primitives
const transporter = nodemailer.createTransport({
- service: 'gmail',
+ host: process.env.EMAIL_HOST,
+ port: Number(process.env.EMAIL_PORT),
+ secure: process.env.EMAIL_SECURE === 'true', // true for port 465, false for other ports
auth: {
- user: process.env.EMAIL_FROM,
+ user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASSWORD,
},
});
@@ -29,13 +33,6 @@ export const sendEmail = async (
html: message,
};
- // transporter.sendMail(mailOptions, function (error, info) {
- // if (error) {
- // console.log(error);
- // } else {
- // console.log('Email sent: ' + info.response);
- // }
- // });
const res: EmailSendResponse = {
success: false,
info: null,
@@ -44,7 +41,6 @@ export const sendEmail = async (
if (process.env.NODE_ENV == 'test') {
console.warn('We are in test mode. So we are just mock sending emails');
- // we are running tests, so we do not want to send an actual email
const mockInfo: SMTPTransport.SentMessageInfo = {
accepted: [recipients],
rejected: [],
@@ -66,10 +62,11 @@ export const sendEmail = async (
res.success = true;
res.info = info;
res.error = null;
- } catch (error) {
+ } catch (error: any) {
res.success = false;
res.info = null;
- res.error = error;
+ // Extracted message string prevents runtime serialization bugs in the response container
+ res.error = error?.message || String(error);
}
return res;
};
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 3b02db8b8..e38f1d1b3 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,3 +1,4 @@
+import { InputType, Field } from '@nestjs/graphql';
import {
IsUUID,
IsNotEmpty,
@@ -6,17 +7,21 @@ import {
MaxLength,
} from 'class-validator';
+@InputType()
export class UnsubscribeEmailDto {
+ @Field() // 2. Add this to register the field in the schema
@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
+ id: string;
+ @Field()
@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
+ token: string;
+ @Field({ nullable: true })
@IsString({ message: 'The reason must be text.' })
@IsOptional()
@MaxLength(500, { message: 'Reason cannot exceed 500 characters.' })
- reason?: string; // Kept your optional feedback field intact
+ reason?: string;
}
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 6f231859e..f96beeeb0 100644
--- a/src/API/src/db/email-registry/email-registry.resolver.ts
+++ b/src/API/src/db/email-registry/email-registry.resolver.ts
@@ -15,6 +15,7 @@ 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';
+import { UnsubscribeEmailDto } from './dto/unsubscribe-email.dto';
@ObjectType()
class RegistryMeta {
@@ -91,4 +92,10 @@ export class EmailRegistryResolver {
);
return `Queued ${result.sent} emails for delivery`;
}
+
+ @Mutation(() => Boolean, { name: 'unsubscribeEmail' })
+ async unsubscribe(@Args('payload') payload: UnsubscribeEmailDto): Promise {
+ await this.service.unsubscribe(payload);
+ return true;
+ }
}
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 9cd62d041..cb867866f 100644
--- a/src/API/src/db/email-registry/email-registry.service.ts
+++ b/src/API/src/db/email-registry/email-registry.service.ts
@@ -67,17 +67,15 @@ export class EmailRegistryService {
const savedEntry = await this.emailRegistryRepository.save(entry);
- const baseUrl =
- process.env.EMAIL_VERIFICATION_BASE_URL ??
- process.env.API_BASE_URL ??
- 'http://localhost:3001';
+ const frontendBaseUrl = process.env.FRONTEND_BASE_URL?.trim()
+ || 'http://localhost:3000';
- const verificationLink = new URL('/api/verify', baseUrl);
+ const verificationLink = new URL('/verify-success', frontendBaseUrl);
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);
+ const unsubscribeLink = new URL('/unsubscribe', frontendBaseUrl);
unsubscribeLink.searchParams.set('id', savedEntry.id);
unsubscribeLink.searchParams.set('token', unsubscriptionToken);
@@ -129,8 +127,8 @@ export class EmailRegistryService {
}
entry.account_status = AccountStatus.VERIFIED;
- entry.verification_token = null;
- entry.token_expires_at = null;
+ entry.verification_token = uuidv4();
+ entry.token_expires_at = new Date(Date.now() + VERIFICATION_CODE_TTL_MS);
return await this.emailRegistryRepository.save(entry);
}
@@ -158,7 +156,7 @@ export class EmailRegistryService {
entry.account_status = AccountStatus.UNSUBSCRIBED;
entry.notifications_enabled = false;
- entry.unsubscription_token = null;
+ entry.unsubscription_token = uuidv4();
await this.emailRegistryRepository.save(entry);
}
@@ -353,14 +351,15 @@ export class EmailRegistryService {
datasetUrl: string,
) {
let count = 0;
- const baseUrl = process.env.API_BASE_URL ?? 'http://localhost:3001';
+ const baseUrl = process.env.FRONTEND_BASE_URL?.trim()
+ || 'http://localhost:3000';
for await (const record of this.streamVerified()) {
const html = this.compileDatasetTemplate(
title,
message,
datasetUrl,
- `${baseUrl}/api/unsubscribe?id=${record.id}&token=${record.unsubscription_token}`,
+ `${baseUrl}/unsubscribe?id=${record.id}&token=${record.unsubscription_token}`,
record.first_name,
);
@@ -424,14 +423,15 @@ export class EmailRegistryService {
async queueNewsCampaign(title: string, message: string, newsUrl?: string) {
let count = 0;
- const baseUrl = process.env.API_BASE_URL ?? 'http://localhost:3001';
+ const baseUrl = process.env.FRONTEND_BASE_URL?.trim()
+ || 'http://localhost:3000';
for await (const record of this.streamVerified()) {
const html = this.compileNewsTemplate(
title,
message,
newsUrl,
- `${baseUrl}/api/unsubscribe?id=${record.id}&token=${record.unsubscription_token}`,
+ `${baseUrl}/unsubscribe?id=${record.id}&token=${record.unsubscription_token}`,
record.first_name,
);
diff --git a/src/API/src/db/shared/dynamic-export.module.ts b/src/API/src/db/shared/dynamic-export.module.ts
index 866741efa..27f2d6277 100644
--- a/src/API/src/db/shared/dynamic-export.module.ts
+++ b/src/API/src/db/shared/dynamic-export.module.ts
@@ -16,10 +16,12 @@ import { BlobCleanupService } from './blob-cleanup.service';
import { AzureBlobService } from '../azure-blob/azure-blob.service';
import { ExportsService } from 'src/exports/exports.service';
import { ExportJob } from 'src/exports/export-job.entity';
+import { EmailModule } from 'src/email/email.module';
@Module({
imports: [
DynamicQueryModule,
+ EmailModule,
HttpModule,
TypeOrmModule.forFeature([
Occurrence,
@@ -32,7 +34,7 @@ import { ExportJob } from 'src/exports/export-job.entity';
providers: [
DynamicExportService,
DoiService,
- EmailService,
+ //EmailService,
AuthService,
Logger,
CommunicationLogService,
diff --git a/src/API/src/db/shared/shared.module.ts b/src/API/src/db/shared/shared.module.ts
index f5b35e173..45a1d7b19 100644
--- a/src/API/src/db/shared/shared.module.ts
+++ b/src/API/src/db/shared/shared.module.ts
@@ -18,14 +18,16 @@ 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';
+import { EmailModule } from 'src/email/email.module';
@Global()
@Module({
imports: [
HttpModule,
+ EmailModule,
TypeOrmModule.forFeature([Reference, Dataset, RecordedSpecies]),
TypeOrmModule.forFeature([UserRole, CommunicationLog]),
- BullModule.registerQueue({ name: 'email-sending' }),
+ // BullModule.registerQueue({ name: 'email-sending' }),
],
providers: [
ReferenceService,
@@ -34,13 +36,12 @@ import { BullModule } from '@nestjs/bull/dist/bull.module';
DatasetResolver,
AuthService,
UserRoleService,
- EmailService,
CommunicationLogService,
RecordedSpeciesService,
RecordedSpeciesResolver,
Logger,
],
- exports: [ReferenceService, DatasetService, RecordedSpeciesService, BullModule],
+ exports: [ReferenceService, DatasetService, RecordedSpeciesService,],
controllers: [DatasetController],
})
export class SharedModule {}
diff --git a/src/API/src/db/uploaded-dataset/uploaded-dataset.module.ts b/src/API/src/db/uploaded-dataset/uploaded-dataset.module.ts
index 63af69472..a465102ed 100644
--- a/src/API/src/db/uploaded-dataset/uploaded-dataset.module.ts
+++ b/src/API/src/db/uploaded-dataset/uploaded-dataset.module.ts
@@ -21,9 +21,11 @@ import { Dataset } from '../shared/entities/dataset.entity';
import { SharedModule } from '../shared/shared.module';
import { DatasetUploadModule } from '../../dataset-upload/dataset-upload.module';
import { DoiModule } from '../doi/doi.module';
+import { EmailModule } from 'src/email/email.module';
@Module({
imports: [
+ EmailModule,
HttpModule,
SharedModule,
DatasetUploadModule,
@@ -45,7 +47,7 @@ import { DoiModule } from '../doi/doi.module';
CommunicationLogService,
AuthService,
DoiService,
- EmailService,
+ //EmailService,
UploadedDatasetLogService,
UserRoleService,
AzureBlobService,
diff --git a/src/API/src/db/uploaded-model/uploaded-model.module.ts b/src/API/src/db/uploaded-model/uploaded-model.module.ts
index d2d95799c..971346691 100644
--- a/src/API/src/db/uploaded-model/uploaded-model.module.ts
+++ b/src/API/src/db/uploaded-model/uploaded-model.module.ts
@@ -21,10 +21,12 @@ import { EmailService } from 'src/email/email.service';
import { UserRoleService } from 'src/auth/user_role/user_role.service';
import { AzureBlobService } from '../azure-blob/azure-blob.service';
import { DatasetService } from '../shared/dataset.service';
+import { EmailModule } from 'src/email/email.module';
@Module({
imports: [
HttpModule,
+ EmailModule,
SharedModule,
DatasetUploadModule,
forwardRef(() => DoiModule),
@@ -45,7 +47,7 @@ import { DatasetService } from '../shared/dataset.service';
CommunicationLogService,
AuthService,
DoiService,
- EmailService,
+ //EmailService,
UploadedModelLogService,
UserRoleService,
AzureBlobService,
diff --git a/src/API/src/email/email.module.ts b/src/API/src/email/email.module.ts
index 3979ee411..322fa323c 100644
--- a/src/API/src/email/email.module.ts
+++ b/src/API/src/email/email.module.ts
@@ -1,16 +1,17 @@
import { Logger, Module } from '@nestjs/common';
import { EmailService } from './email.service';
-import { BullModule } from '@nestjs/bull';
+import { BullModule } from '@nestjs/bullmq';
import { EmailController } from './email.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CommunicationLog } from '../db/communication-log/entities/communication-log.entity';
import { CommunicationLogModule } from '../db/communication-log/communication-log.module';
import { CommunicationLogService } from '../db/communication-log/communication-log.service';
import { HttpModule } from '@nestjs/axios';
+import { EmailProcessor } from './email.processor';
@Module({
controllers: [EmailController],
- providers: [EmailService, CommunicationLogService, Logger],
+ providers: [EmailService, CommunicationLogService, Logger, EmailProcessor],
imports: [
BullModule.registerQueue({
name: 'email-sending',
diff --git a/src/API/src/email/email.processor.ts b/src/API/src/email/email.processor.ts
index 7109c441d..c0b07e0d5 100644
--- a/src/API/src/email/email.processor.ts
+++ b/src/API/src/email/email.processor.ts
@@ -3,7 +3,9 @@ import { Job } from 'bullmq';
import * as nodemailer from 'nodemailer';
import { CommunicationLogService } from '../db/communication-log/communication-log.service';
import { CommunicationSentStatus } from '../../src/commonTypes';
+import { Injectable } from '@nestjs/common';
+@Injectable()
@Processor('email-sending')
export class EmailProcessor extends WorkerHost {
constructor(private readonly communicationLogService: CommunicationLogService) {
@@ -25,7 +27,7 @@ export class EmailProcessor extends WorkerHost {
port: Number(process.env.EMAIL_PORT),
secure: Boolean(Number(process.env.EMAIL_SECURE)),
auth: {
- user: process.env.EMAIL_FROM,
+ user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASSWORD,
},
},
diff --git a/src/API/src/models/models.module.ts b/src/API/src/models/models.module.ts
index b93834eb6..9935bdde8 100644
--- a/src/API/src/models/models.module.ts
+++ b/src/API/src/models/models.module.ts
@@ -20,11 +20,13 @@ import { DoiService } from 'src/db/doi/doi.service';
import { CommunicationLog } from 'src/db/communication-log/entities/communication-log.entity';
import { Dataset } from 'src/db/shared/entities/dataset.entity';
import { DOI } from 'src/db/doi/entities/doi.entity';
+import { EmailModule } from 'src/email/email.module';
// import { ModelLogResolver } from './model-log/model-log.resolver';
@Module({
imports: [
HttpModule,
+ EmailModule,
ModelProcessingStatus,
TypeOrmModule.forFeature([
UploadedModel,
@@ -42,7 +44,7 @@ import { DOI } from 'src/db/doi/entities/doi.entity';
ModelsResolver,
Logger,
AuthService,
- EmailService,
+ // EmailService,
UserRoleService,
UploadedModelLogService,
UploadedModelService,
diff --git a/src/API/src/review/review.module.ts b/src/API/src/review/review.module.ts
index 807b17cf9..fcaa6de6b 100644
--- a/src/API/src/review/review.module.ts
+++ b/src/API/src/review/review.module.ts
@@ -16,6 +16,7 @@ import { DoiService } from 'src/db/doi/doi.service';
import { EmailService } from '../email/email.service';
import { AzureBlobService } from 'src/db/azure-blob/azure-blob.service';
import { SharedModule } from 'src/db/shared/shared.module';
+import { EmailModule } from 'src/email/email.module';
@Module({
controllers: [ReviewController],
@@ -26,11 +27,12 @@ import { SharedModule } from 'src/db/shared/shared.module';
UploadedDatasetLogService,
CommunicationLogService,
DoiService,
- EmailService,
+ // EmailService,
AzureBlobService,
],
imports: [
HttpModule,
+ EmailModule,
SharedModule,
TypeOrmModule.forFeature([
Dataset,
diff --git a/src/API/src/schema.gql b/src/API/src/schema.gql
index f9b20389e..57658586d 100644
--- a/src/API/src/schema.gql
+++ b/src/API/src/schema.gql
@@ -242,6 +242,7 @@ type Mutation {
queueDatasetCampaign(datasetUrl: String!, message: String!, title: String!): String!
queueNewsCampaign(message: String!, newsUrl: String, title: String!): String!
requestRoles(email: String!, requestReason: String!, rolesRequested: [String!]!): Boolean!
+ unsubscribeEmail(payload: UnsubscribeEmailDto!): Boolean!
updateCountry(input: UpdateCountryInput!): Country!
updateRecordedSpecies(input: UpdateRecordedSpeciesInput!): RecordedSpecies!
updateReference(input: UpdateReferenceInput!, num_id: Int!): Reference!
@@ -500,6 +501,12 @@ type SpeciesInformation {
speciesImage: String
}
+input UnsubscribeEmailDto {
+ id: String!
+ reason: String
+ token: String!
+}
+
input UpdateCountryInput {
alternative_names: [String!]
id: String!
diff --git a/src/UI/pages/unsubscribed-success/index.tsx b/src/UI/pages/unsubscribed-success/index.tsx
index f2722d566..ab3863d11 100644
--- a/src/UI/pages/unsubscribed-success/index.tsx
+++ b/src/UI/pages/unsubscribed-success/index.tsx
@@ -5,25 +5,43 @@ import { useTranslations } from 'next-intl';
import { getMessages } from '../../utils/localization';
import { GetServerSidePropsContext } from 'next';
-const UnsubscribedSuccessPage = (): JSX.Element => {
+type UnsubscribeStatus = 'success' | 'invalid';
+
+interface Props {
+ status: UnsubscribeStatus;
+}
+
+const UnsubscribePage = ({ status }: Props): JSX.Element => {
const t = useTranslations('EmailSubscription');
+ const content = {
+ success: {
+ title: t('unsubscribeSuccessTitle') || 'Unsubscribed',
+ body:
+ t('unsubscribeSuccessBody') ||
+ "You've been unsubscribed and won't receive further updates.",
+ color: 'success.main',
+ },
+ invalid: {
+ title: t('unsubscribeInvalidTitle') || 'Invalid Link',
+ body:
+ t('unsubscribeInvalidBody') ||
+ 'This unsubscribe link is invalid or has already been used.',
+ color: 'error.main',
+ },
+ }[status];
+
return (
-
- {t('unsubscribedTitle') || 'Unsubscribed'}
+
+ {content.title}
- {t('unsubscribedBody') ||
- "You've been removed from our mailing list. You won't receive any further updates."}
+ {content.body}
-