Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@nestjs/typeorm": "^11.0.0",
"@stellar/stellar-sdk": "^14.5.0",
"axios": "^1.13.5",
"archiver": "^7.0.1",
"bcrypt": "^6.0.0",
"cache-manager": "^7.2.8",
"class-transformer": "^0.5.1",
Expand All @@ -64,6 +65,7 @@
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@fast-csv/format": "^5.0.0",
"@types/archiver": "^6.0.3",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { TestRbacModule } from './test-rbac/test-rbac.module';
import { TestThrottlingModule } from './test-throttling/test-throttling.module';
import { ApiVersioningModule } from './common/versioning/api-versioning.module';
import { BackupModule } from './modules/backup/backup.module';
import { DataExportModule } from './modules/data-export/data-export.module';
import { ConnectionPoolModule } from './common/database/connection-pool.module';
import { CircuitBreakerModule } from './common/circuit-breaker/circuit-breaker.module';
import { PostmanModule } from './common/postman/postman.module';
Expand Down Expand Up @@ -202,6 +203,7 @@ const envValidationSchema = Joi.object({
TestThrottlingModule,
ApiVersioningModule,
BackupModule,
DataExportModule,
ConnectionPoolModule,
CircuitBreakerModule,
PostmanModule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateUserWalletsAndDataExport1796000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
// user_wallets table (issue #524)
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "user_wallets" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"userId" UUID NOT NULL,
"address" VARCHAR(60) NOT NULL,
"isPrimary" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_user_wallets" PRIMARY KEY ("id"),
CONSTRAINT "UQ_user_wallets_address" UNIQUE ("address"),
CONSTRAINT "FK_user_wallets_user" FOREIGN KEY ("userId")
REFERENCES "users"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_user_wallets_userId" ON "user_wallets" ("userId")`);

// notification_preferences new columns (issue #525)
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "pushNotifications" BOOLEAN NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "smsNotifications" BOOLEAN NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "depositNotifications" BOOLEAN NOT NULL DEFAULT true`);
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "withdrawalNotifications" BOOLEAN NOT NULL DEFAULT true`);
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "goalNotifications" BOOLEAN NOT NULL DEFAULT true`);
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "governanceNotifications" BOOLEAN NOT NULL DEFAULT true`);
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "marketingNotifications" BOOLEAN NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "quietHoursEnabled" BOOLEAN NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "quietHoursStart" VARCHAR(5) NOT NULL DEFAULT '22:00'`);
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "quietHoursEnd" VARCHAR(5) NOT NULL DEFAULT '08:00'`);
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "timezone" VARCHAR(50) NOT NULL DEFAULT 'UTC'`);
await queryRunner.query(`
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'digest_frequency_enum') THEN
CREATE TYPE "digest_frequency_enum" AS ENUM ('instant', 'daily', 'weekly');
END IF;
END $$
`);
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "digestFrequency" "digest_frequency_enum" NOT NULL DEFAULT 'instant'`);

// data_export_requests table (issue #529)
await queryRunner.query(`
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'export_status_enum') THEN
CREATE TYPE "export_status_enum" AS ENUM ('pending', 'processing', 'ready', 'expired', 'failed');
END IF;
END $$
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "data_export_requests" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"userId" UUID NOT NULL,
"status" "export_status_enum" NOT NULL DEFAULT 'pending',
"token" VARCHAR(64) UNIQUE,
"filePath" VARCHAR,
"expiresAt" TIMESTAMP,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"completedAt" TIMESTAMP,
CONSTRAINT "PK_data_export_requests" PRIMARY KEY ("id")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_data_export_userId" ON "data_export_requests" ("userId")`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "data_export_requests"`);
await queryRunner.query(`DROP TYPE IF EXISTS "export_status_enum"`);
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "digestFrequency"`);
await queryRunner.query(`DROP TYPE IF EXISTS "digest_frequency_enum"`);
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "timezone"`);
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "quietHoursEnd"`);
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "quietHoursStart"`);
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "quietHoursEnabled"`);
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "marketingNotifications"`);
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "governanceNotifications"`);
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "goalNotifications"`);
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "withdrawalNotifications"`);
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "depositNotifications"`);
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "smsNotifications"`);
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "pushNotifications"`);
await queryRunner.query(`DROP TABLE IF EXISTS "user_wallets"`);
}
}
60 changes: 60 additions & 0 deletions backend/src/modules/data-export/data-export.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
Controller,
Post,
Get,
Param,
Body,
UseGuards,
Res,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiResponse,
} from '@nestjs/swagger';
import { Response } from 'express';
import * as path from 'path';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { DataExportService } from './data-export.service';
import { RequestDataExportDto } from './dto/request-data-export.dto';

@ApiTags('users')
@ApiBearerAuth()
@Controller('users/data')
@UseGuards(JwtAuthGuard)
export class DataExportController {
constructor(private readonly dataExportService: DataExportService) {}

@Post('export')
@HttpCode(HttpStatus.ACCEPTED)
@ApiOperation({ summary: 'Request a GDPR data export (async)' })
@ApiResponse({ status: 202, description: 'Export request accepted' })
requestExport(
@CurrentUser() user: { id: string },
@Body() _dto: RequestDataExportDto,
) {
return this.dataExportService.requestExport(user.id);
}

@Get('export/:requestId/status')
@ApiOperation({ summary: 'Check export request status' })
getStatus(
@CurrentUser() user: { id: string },
@Param('requestId') requestId: string,
) {
return this.dataExportService.getExportStatus(requestId, user.id);
}

@Get('export/download/:token')
@ApiOperation({ summary: 'Download export ZIP by token (token acts as auth)' })
async download(@Param('token') token: string, @Res() res: Response) {
const { filePath } = await this.dataExportService.getExportFile(token);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', 'attachment; filename="nestera-data-export.zip"');
res.sendFile(path.resolve(filePath));
}
}
26 changes: 26 additions & 0 deletions backend/src/modules/data-export/data-export.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DataExportController } from './data-export.controller';
import { DataExportService } from './data-export.service';
import { DataExportRequest } from './entities/data-export-request.entity';
import { User } from '../user/entities/user.entity';
import { Transaction } from '../transactions/entities/transaction.entity';
import { Notification } from '../notifications/entities/notification.entity';
import { SavingsGoal } from '../savings/entities/savings-goal.entity';
import { MailModule } from '../mail/mail.module';

@Module({
imports: [
TypeOrmModule.forFeature([
DataExportRequest,
User,
Transaction,
Notification,
SavingsGoal,
]),
MailModule,
],
controllers: [DataExportController],
providers: [DataExportService],
})
export class DataExportModule {}
172 changes: 172 additions & 0 deletions backend/src/modules/data-export/data-export.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OnEvent } from '@nestjs/event-emitter';
import { randomBytes } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as archiver from 'archiver';
import {
DataExportRequest,
ExportStatus,
} from './entities/data-export-request.entity';
import { User } from '../user/entities/user.entity';
import { Transaction } from '../transactions/entities/transaction.entity';
import { Notification } from '../notifications/entities/notification.entity';
import { SavingsGoal } from '../savings/entities/savings-goal.entity';
import { MailService } from '../mail/mail.service';

const EXPORT_DIR = path.join(os.tmpdir(), 'nestera-exports');
const LINK_EXPIRY_DAYS = 7;

@Injectable()
export class DataExportService {
private readonly logger = new Logger(DataExportService.name);

constructor(
@InjectRepository(DataExportRequest)
private readonly exportRepository: Repository<DataExportRequest>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(Transaction)
private readonly transactionRepository: Repository<Transaction>,
@InjectRepository(Notification)
private readonly notificationRepository: Repository<Notification>,
@InjectRepository(SavingsGoal)
private readonly savingsGoalRepository: Repository<SavingsGoal>,
private readonly mailService: MailService,
) {
fs.mkdirSync(EXPORT_DIR, { recursive: true });
}

/**
* Create an export request and trigger async processing.
*/
async requestExport(userId: string): Promise<{ requestId: string; message: string }> {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) throw new NotFoundException('User not found');

const request = this.exportRepository.create({ userId, status: ExportStatus.PENDING });
const saved = await this.exportRepository.save(request);

this.logger.log(`Data export requested for user ${userId}, request ${saved.id}`);

// Trigger async processing (fire-and-forget)
this.processExport(saved.id, user).catch((err) =>
this.logger.error(`Export ${saved.id} failed`, err),
);

return {
requestId: saved.id,
message: 'Export request received. You will receive an email when your data is ready.',
};
}

/**
* Download a ready export by token.
*/
async getExportFile(token: string): Promise<{ filePath: string; userId: string }> {
const request = await this.exportRepository.findOne({ where: { token } });
if (!request || request.status !== ExportStatus.READY) {
throw new NotFoundException('Export not found or not ready');
}
if (request.expiresAt && request.expiresAt < new Date()) {
await this.exportRepository.update(request.id, { status: ExportStatus.EXPIRED });
throw new BadRequestException('Export link has expired');
}
if (!request.filePath || !fs.existsSync(request.filePath)) {
throw new NotFoundException('Export file not found');
}
return { filePath: request.filePath, userId: request.userId };
}

/**
* Get export request status.
*/
async getExportStatus(requestId: string, userId: string) {
const request = await this.exportRepository.findOne({
where: { id: requestId, userId },
});
if (!request) throw new NotFoundException('Export request not found');
return {
requestId: request.id,
status: request.status,
createdAt: request.createdAt,
completedAt: request.completedAt,
expiresAt: request.expiresAt,
};
}

/**
* Async: build ZIP, update record, email user.
*/
private async processExport(requestId: string, user: User): Promise<void> {
await this.exportRepository.update(requestId, { status: ExportStatus.PROCESSING });

try {
const [transactions, notifications, goals] = await Promise.all([
this.transactionRepository.find({ where: { userId: user.id } }),
this.notificationRepository.find({ where: { userId: user.id } }),
this.savingsGoalRepository.find({ where: { userId: user.id } }),
]);

const zipPath = path.join(EXPORT_DIR, `${requestId}.zip`);
await this.buildZip(zipPath, {
'profile.json': { id: user.id, email: user.email, name: user.name, createdAt: user.createdAt },
'transactions.json': transactions,
'goals.json': goals,
'notifications.json': notifications,
});

const token = randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + LINK_EXPIRY_DAYS * 86_400_000);

await this.exportRepository.update(requestId, {
status: ExportStatus.READY,
token,
filePath: zipPath,
expiresAt,
completedAt: new Date(),
});

// Email the download link
const downloadUrl = `/users/data/export/download/${token}`;
await this.mailService.sendRawMail(
user.email,
'Your Nestera data export is ready',
`Hi ${user.name || 'there'},\n\nYour data export is ready. Download it here:\n${downloadUrl}\n\nThis link expires in ${LINK_EXPIRY_DAYS} days.\n\nNestera Team`,
);

this.logger.log(`Export ${requestId} completed for user ${user.id}`);
} catch (err) {
await this.exportRepository.update(requestId, { status: ExportStatus.FAILED });
throw err;
}
}

private buildZip(
outputPath: string,
files: Record<string, unknown>,
): Promise<void> {
return new Promise((resolve, reject) => {
const output = fs.createWriteStream(outputPath);
const archive = archiver('zip', { zlib: { level: 6 } });

output.on('close', resolve);
archive.on('error', reject);
archive.pipe(output);

for (const [name, data] of Object.entries(files)) {
archive.append(JSON.stringify(data, null, 2), { name });
}

archive.finalize();
});
}
}
14 changes: 14 additions & 0 deletions backend/src/modules/data-export/dto/request-data-export.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsArray, IsString } from 'class-validator';

export class RequestDataExportDto {
@ApiPropertyOptional({
description: 'Specific data sections to include. Defaults to all.',
example: ['profile', 'transactions', 'savings', 'goals', 'notifications'],
type: [String],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
sections?: string[];
}
Loading