Skip to content

Commit 76dd14f

Browse files
authored
Merge pull request #577 from Haroldwonder/feature/issues-524-525-528-529-user-wallet-notifications-referrals-export
feat: implement wallet management, notification preferences, referral…
2 parents 5339d54 + 44e6b82 commit 76dd14f

22 files changed

+1020
-29
lines changed

backend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"@nestjs/typeorm": "^11.0.0",
4141
"@stellar/stellar-sdk": "^14.5.0",
4242
"axios": "^1.13.5",
43+
"archiver": "^7.0.1",
4344
"bcrypt": "^6.0.0",
4445
"cache-manager": "^7.2.8",
4546
"class-transformer": "^0.5.1",
@@ -64,6 +65,7 @@
6465
"@eslint/eslintrc": "^3.2.0",
6566
"@eslint/js": "^9.18.0",
6667
"@fast-csv/format": "^5.0.0",
68+
"@types/archiver": "^6.0.3",
6769
"@nestjs/cli": "^11.0.0",
6870
"@nestjs/schematics": "^11.0.0",
6971
"@nestjs/testing": "^11.0.1",

backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { TestRbacModule } from './test-rbac/test-rbac.module';
4141
import { TestThrottlingModule } from './test-throttling/test-throttling.module';
4242
import { ApiVersioningModule } from './common/versioning/api-versioning.module';
4343
import { BackupModule } from './modules/backup/backup.module';
44+
import { DataExportModule } from './modules/data-export/data-export.module';
4445
import { ConnectionPoolModule } from './common/database/connection-pool.module';
4546
import { CircuitBreakerModule } from './common/circuit-breaker/circuit-breaker.module';
4647
import { PostmanModule } from './common/postman/postman.module';
@@ -202,6 +203,7 @@ const envValidationSchema = Joi.object({
202203
TestThrottlingModule,
203204
ApiVersioningModule,
204205
BackupModule,
206+
DataExportModule,
205207
ConnectionPoolModule,
206208
CircuitBreakerModule,
207209
PostmanModule,
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class CreateUserWalletsAndDataExport1796000000000
4+
implements MigrationInterface
5+
{
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
// user_wallets table (issue #524)
8+
await queryRunner.query(`
9+
CREATE TABLE IF NOT EXISTS "user_wallets" (
10+
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
11+
"userId" UUID NOT NULL,
12+
"address" VARCHAR(60) NOT NULL,
13+
"isPrimary" BOOLEAN NOT NULL DEFAULT false,
14+
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
15+
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
16+
CONSTRAINT "PK_user_wallets" PRIMARY KEY ("id"),
17+
CONSTRAINT "UQ_user_wallets_address" UNIQUE ("address"),
18+
CONSTRAINT "FK_user_wallets_user" FOREIGN KEY ("userId")
19+
REFERENCES "users"("id") ON DELETE CASCADE
20+
)
21+
`);
22+
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_user_wallets_userId" ON "user_wallets" ("userId")`);
23+
24+
// notification_preferences new columns (issue #525)
25+
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "pushNotifications" BOOLEAN NOT NULL DEFAULT false`);
26+
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "smsNotifications" BOOLEAN NOT NULL DEFAULT false`);
27+
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "depositNotifications" BOOLEAN NOT NULL DEFAULT true`);
28+
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "withdrawalNotifications" BOOLEAN NOT NULL DEFAULT true`);
29+
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "goalNotifications" BOOLEAN NOT NULL DEFAULT true`);
30+
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "governanceNotifications" BOOLEAN NOT NULL DEFAULT true`);
31+
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "marketingNotifications" BOOLEAN NOT NULL DEFAULT false`);
32+
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "quietHoursEnabled" BOOLEAN NOT NULL DEFAULT false`);
33+
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "quietHoursStart" VARCHAR(5) NOT NULL DEFAULT '22:00'`);
34+
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "quietHoursEnd" VARCHAR(5) NOT NULL DEFAULT '08:00'`);
35+
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "timezone" VARCHAR(50) NOT NULL DEFAULT 'UTC'`);
36+
await queryRunner.query(`
37+
DO $$ BEGIN
38+
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'digest_frequency_enum') THEN
39+
CREATE TYPE "digest_frequency_enum" AS ENUM ('instant', 'daily', 'weekly');
40+
END IF;
41+
END $$
42+
`);
43+
await queryRunner.query(`ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "digestFrequency" "digest_frequency_enum" NOT NULL DEFAULT 'instant'`);
44+
45+
// data_export_requests table (issue #529)
46+
await queryRunner.query(`
47+
DO $$ BEGIN
48+
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'export_status_enum') THEN
49+
CREATE TYPE "export_status_enum" AS ENUM ('pending', 'processing', 'ready', 'expired', 'failed');
50+
END IF;
51+
END $$
52+
`);
53+
await queryRunner.query(`
54+
CREATE TABLE IF NOT EXISTS "data_export_requests" (
55+
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
56+
"userId" UUID NOT NULL,
57+
"status" "export_status_enum" NOT NULL DEFAULT 'pending',
58+
"token" VARCHAR(64) UNIQUE,
59+
"filePath" VARCHAR,
60+
"expiresAt" TIMESTAMP,
61+
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
62+
"completedAt" TIMESTAMP,
63+
CONSTRAINT "PK_data_export_requests" PRIMARY KEY ("id")
64+
)
65+
`);
66+
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_data_export_userId" ON "data_export_requests" ("userId")`);
67+
}
68+
69+
public async down(queryRunner: QueryRunner): Promise<void> {
70+
await queryRunner.query(`DROP TABLE IF EXISTS "data_export_requests"`);
71+
await queryRunner.query(`DROP TYPE IF EXISTS "export_status_enum"`);
72+
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "digestFrequency"`);
73+
await queryRunner.query(`DROP TYPE IF EXISTS "digest_frequency_enum"`);
74+
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "timezone"`);
75+
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "quietHoursEnd"`);
76+
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "quietHoursStart"`);
77+
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "quietHoursEnabled"`);
78+
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "marketingNotifications"`);
79+
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "governanceNotifications"`);
80+
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "goalNotifications"`);
81+
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "withdrawalNotifications"`);
82+
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "depositNotifications"`);
83+
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "smsNotifications"`);
84+
await queryRunner.query(`ALTER TABLE "notification_preferences" DROP COLUMN IF EXISTS "pushNotifications"`);
85+
await queryRunner.query(`DROP TABLE IF EXISTS "user_wallets"`);
86+
}
87+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {
2+
Controller,
3+
Post,
4+
Get,
5+
Param,
6+
Body,
7+
UseGuards,
8+
Res,
9+
HttpCode,
10+
HttpStatus,
11+
} from '@nestjs/common';
12+
import {
13+
ApiTags,
14+
ApiBearerAuth,
15+
ApiOperation,
16+
ApiResponse,
17+
} from '@nestjs/swagger';
18+
import { Response } from 'express';
19+
import * as path from 'path';
20+
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
21+
import { CurrentUser } from '../../common/decorators/current-user.decorator';
22+
import { DataExportService } from './data-export.service';
23+
import { RequestDataExportDto } from './dto/request-data-export.dto';
24+
25+
@ApiTags('users')
26+
@ApiBearerAuth()
27+
@Controller('users/data')
28+
@UseGuards(JwtAuthGuard)
29+
export class DataExportController {
30+
constructor(private readonly dataExportService: DataExportService) {}
31+
32+
@Post('export')
33+
@HttpCode(HttpStatus.ACCEPTED)
34+
@ApiOperation({ summary: 'Request a GDPR data export (async)' })
35+
@ApiResponse({ status: 202, description: 'Export request accepted' })
36+
requestExport(
37+
@CurrentUser() user: { id: string },
38+
@Body() _dto: RequestDataExportDto,
39+
) {
40+
return this.dataExportService.requestExport(user.id);
41+
}
42+
43+
@Get('export/:requestId/status')
44+
@ApiOperation({ summary: 'Check export request status' })
45+
getStatus(
46+
@CurrentUser() user: { id: string },
47+
@Param('requestId') requestId: string,
48+
) {
49+
return this.dataExportService.getExportStatus(requestId, user.id);
50+
}
51+
52+
@Get('export/download/:token')
53+
@ApiOperation({ summary: 'Download export ZIP by token (token acts as auth)' })
54+
async download(@Param('token') token: string, @Res() res: Response) {
55+
const { filePath } = await this.dataExportService.getExportFile(token);
56+
res.setHeader('Content-Type', 'application/zip');
57+
res.setHeader('Content-Disposition', 'attachment; filename="nestera-data-export.zip"');
58+
res.sendFile(path.resolve(filePath));
59+
}
60+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { DataExportController } from './data-export.controller';
4+
import { DataExportService } from './data-export.service';
5+
import { DataExportRequest } from './entities/data-export-request.entity';
6+
import { User } from '../user/entities/user.entity';
7+
import { Transaction } from '../transactions/entities/transaction.entity';
8+
import { Notification } from '../notifications/entities/notification.entity';
9+
import { SavingsGoal } from '../savings/entities/savings-goal.entity';
10+
import { MailModule } from '../mail/mail.module';
11+
12+
@Module({
13+
imports: [
14+
TypeOrmModule.forFeature([
15+
DataExportRequest,
16+
User,
17+
Transaction,
18+
Notification,
19+
SavingsGoal,
20+
]),
21+
MailModule,
22+
],
23+
controllers: [DataExportController],
24+
providers: [DataExportService],
25+
})
26+
export class DataExportModule {}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import {
2+
Injectable,
3+
Logger,
4+
NotFoundException,
5+
BadRequestException,
6+
} from '@nestjs/common';
7+
import { InjectRepository } from '@nestjs/typeorm';
8+
import { Repository } from 'typeorm';
9+
import { OnEvent } from '@nestjs/event-emitter';
10+
import { randomBytes } from 'crypto';
11+
import * as fs from 'fs';
12+
import * as path from 'path';
13+
import * as os from 'os';
14+
import * as archiver from 'archiver';
15+
import {
16+
DataExportRequest,
17+
ExportStatus,
18+
} from './entities/data-export-request.entity';
19+
import { User } from '../user/entities/user.entity';
20+
import { Transaction } from '../transactions/entities/transaction.entity';
21+
import { Notification } from '../notifications/entities/notification.entity';
22+
import { SavingsGoal } from '../savings/entities/savings-goal.entity';
23+
import { MailService } from '../mail/mail.service';
24+
25+
const EXPORT_DIR = path.join(os.tmpdir(), 'nestera-exports');
26+
const LINK_EXPIRY_DAYS = 7;
27+
28+
@Injectable()
29+
export class DataExportService {
30+
private readonly logger = new Logger(DataExportService.name);
31+
32+
constructor(
33+
@InjectRepository(DataExportRequest)
34+
private readonly exportRepository: Repository<DataExportRequest>,
35+
@InjectRepository(User)
36+
private readonly userRepository: Repository<User>,
37+
@InjectRepository(Transaction)
38+
private readonly transactionRepository: Repository<Transaction>,
39+
@InjectRepository(Notification)
40+
private readonly notificationRepository: Repository<Notification>,
41+
@InjectRepository(SavingsGoal)
42+
private readonly savingsGoalRepository: Repository<SavingsGoal>,
43+
private readonly mailService: MailService,
44+
) {
45+
fs.mkdirSync(EXPORT_DIR, { recursive: true });
46+
}
47+
48+
/**
49+
* Create an export request and trigger async processing.
50+
*/
51+
async requestExport(userId: string): Promise<{ requestId: string; message: string }> {
52+
const user = await this.userRepository.findOne({ where: { id: userId } });
53+
if (!user) throw new NotFoundException('User not found');
54+
55+
const request = this.exportRepository.create({ userId, status: ExportStatus.PENDING });
56+
const saved = await this.exportRepository.save(request);
57+
58+
this.logger.log(`Data export requested for user ${userId}, request ${saved.id}`);
59+
60+
// Trigger async processing (fire-and-forget)
61+
this.processExport(saved.id, user).catch((err) =>
62+
this.logger.error(`Export ${saved.id} failed`, err),
63+
);
64+
65+
return {
66+
requestId: saved.id,
67+
message: 'Export request received. You will receive an email when your data is ready.',
68+
};
69+
}
70+
71+
/**
72+
* Download a ready export by token.
73+
*/
74+
async getExportFile(token: string): Promise<{ filePath: string; userId: string }> {
75+
const request = await this.exportRepository.findOne({ where: { token } });
76+
if (!request || request.status !== ExportStatus.READY) {
77+
throw new NotFoundException('Export not found or not ready');
78+
}
79+
if (request.expiresAt && request.expiresAt < new Date()) {
80+
await this.exportRepository.update(request.id, { status: ExportStatus.EXPIRED });
81+
throw new BadRequestException('Export link has expired');
82+
}
83+
if (!request.filePath || !fs.existsSync(request.filePath)) {
84+
throw new NotFoundException('Export file not found');
85+
}
86+
return { filePath: request.filePath, userId: request.userId };
87+
}
88+
89+
/**
90+
* Get export request status.
91+
*/
92+
async getExportStatus(requestId: string, userId: string) {
93+
const request = await this.exportRepository.findOne({
94+
where: { id: requestId, userId },
95+
});
96+
if (!request) throw new NotFoundException('Export request not found');
97+
return {
98+
requestId: request.id,
99+
status: request.status,
100+
createdAt: request.createdAt,
101+
completedAt: request.completedAt,
102+
expiresAt: request.expiresAt,
103+
};
104+
}
105+
106+
/**
107+
* Async: build ZIP, update record, email user.
108+
*/
109+
private async processExport(requestId: string, user: User): Promise<void> {
110+
await this.exportRepository.update(requestId, { status: ExportStatus.PROCESSING });
111+
112+
try {
113+
const [transactions, notifications, goals] = await Promise.all([
114+
this.transactionRepository.find({ where: { userId: user.id } }),
115+
this.notificationRepository.find({ where: { userId: user.id } }),
116+
this.savingsGoalRepository.find({ where: { userId: user.id } }),
117+
]);
118+
119+
const zipPath = path.join(EXPORT_DIR, `${requestId}.zip`);
120+
await this.buildZip(zipPath, {
121+
'profile.json': { id: user.id, email: user.email, name: user.name, createdAt: user.createdAt },
122+
'transactions.json': transactions,
123+
'goals.json': goals,
124+
'notifications.json': notifications,
125+
});
126+
127+
const token = randomBytes(32).toString('hex');
128+
const expiresAt = new Date(Date.now() + LINK_EXPIRY_DAYS * 86_400_000);
129+
130+
await this.exportRepository.update(requestId, {
131+
status: ExportStatus.READY,
132+
token,
133+
filePath: zipPath,
134+
expiresAt,
135+
completedAt: new Date(),
136+
});
137+
138+
// Email the download link
139+
const downloadUrl = `/users/data/export/download/${token}`;
140+
await this.mailService.sendRawMail(
141+
user.email,
142+
'Your Nestera data export is ready',
143+
`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`,
144+
);
145+
146+
this.logger.log(`Export ${requestId} completed for user ${user.id}`);
147+
} catch (err) {
148+
await this.exportRepository.update(requestId, { status: ExportStatus.FAILED });
149+
throw err;
150+
}
151+
}
152+
153+
private buildZip(
154+
outputPath: string,
155+
files: Record<string, unknown>,
156+
): Promise<void> {
157+
return new Promise((resolve, reject) => {
158+
const output = fs.createWriteStream(outputPath);
159+
const archive = archiver('zip', { zlib: { level: 6 } });
160+
161+
output.on('close', resolve);
162+
archive.on('error', reject);
163+
archive.pipe(output);
164+
165+
for (const [name, data] of Object.entries(files)) {
166+
archive.append(JSON.stringify(data, null, 2), { name });
167+
}
168+
169+
archive.finalize();
170+
});
171+
}
172+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { ApiPropertyOptional } from '@nestjs/swagger';
2+
import { IsOptional, IsArray, IsString } from 'class-validator';
3+
4+
export class RequestDataExportDto {
5+
@ApiPropertyOptional({
6+
description: 'Specific data sections to include. Defaults to all.',
7+
example: ['profile', 'transactions', 'savings', 'goals', 'notifications'],
8+
type: [String],
9+
})
10+
@IsOptional()
11+
@IsArray()
12+
@IsString({ each: true })
13+
sections?: string[];
14+
}

0 commit comments

Comments
 (0)