Skip to content

Commit c4f95d2

Browse files
committed
create user profile and preferences module
1 parent 30b6eec commit c4f95d2

11 files changed

Lines changed: 554 additions & 4 deletions

backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { GovernanceModule } from './governance/governance.module';
3434
import { ComplianceModule } from './compliance/compliance.module';
3535
import { SettlementModule } from "./settlement/settlement.module";
3636
import { TemplatesModule } from "./templates/templates.module";
37+
import { ProfileModule } from "./profile/profile.module";
3738
// Load environment variables
3839
dotenv.config({
3940
path: path.resolve(__dirname, '../.env'),
@@ -110,6 +111,7 @@ dotenv.config({
110111
ComplianceModule,
111112
SettlementModule,
112113
TemplatesModule,
114+
ProfileModule,
113115
],
114116
})
115117
export class AppModule { }
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"generatedAt": "2026-02-26T02:33:01.527Z",
3+
"totalEvents": 2,
4+
"actions": [
5+
"test",
6+
"login"
7+
],
8+
"users": [
9+
"user1",
10+
"user2"
11+
]
12+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
2+
3+
export class CreateUserProfilesTable1738300000000 implements MigrationInterface {
4+
public async up(queryRunner: QueryRunner): Promise<void> {
5+
await queryRunner.createTable(
6+
new Table({
7+
name: 'user_profiles',
8+
columns: [
9+
{
10+
name: 'wallet_address',
11+
type: 'varchar',
12+
length: '56',
13+
isPrimary: true,
14+
isNullable: false,
15+
},
16+
{
17+
name: 'display_name',
18+
type: 'varchar',
19+
length: '255',
20+
isNullable: true,
21+
},
22+
{
23+
name: 'avatar_url',
24+
type: 'varchar',
25+
length: '2048',
26+
isNullable: true,
27+
},
28+
{
29+
name: 'preferred_currency',
30+
type: 'varchar',
31+
length: '10',
32+
default: "'USD'",
33+
isNullable: false,
34+
},
35+
{
36+
name: 'default_split_type',
37+
type: 'varchar',
38+
length: '20',
39+
default: "'equal'",
40+
isNullable: false,
41+
},
42+
{
43+
name: 'email_notifications',
44+
type: 'boolean',
45+
default: true,
46+
isNullable: false,
47+
},
48+
{
49+
name: 'push_notifications',
50+
type: 'boolean',
51+
default: true,
52+
isNullable: false,
53+
},
54+
{
55+
name: 'created_at',
56+
type: 'timestamp',
57+
default: 'CURRENT_TIMESTAMP',
58+
isNullable: false,
59+
},
60+
{
61+
name: 'updated_at',
62+
type: 'timestamp',
63+
default: 'CURRENT_TIMESTAMP',
64+
isNullable: false,
65+
},
66+
],
67+
}),
68+
true,
69+
);
70+
71+
await queryRunner.createIndex(
72+
'user_profiles',
73+
new TableIndex({
74+
name: 'idx_user_profiles_wallet_address',
75+
columnNames: ['wallet_address'],
76+
}),
77+
);
78+
}
79+
80+
public async down(queryRunner: QueryRunner): Promise<void> {
81+
await queryRunner.dropTable('user_profiles', true);
82+
}
83+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {
2+
IsString,
3+
IsOptional,
4+
IsUrl,
5+
IsBoolean,
6+
IsEnum,
7+
MaxLength,
8+
} from 'class-validator';
9+
import { ApiPropertyOptional } from '@nestjs/swagger';
10+
import { DefaultSplitType } from '../profile.entity';
11+
12+
export class UpdateProfileDto {
13+
@ApiPropertyOptional({ description: 'Display name', maxLength: 255 })
14+
@IsOptional()
15+
@IsString()
16+
@MaxLength(255)
17+
displayName?: string | null;
18+
19+
@ApiPropertyOptional({ description: 'Avatar URL' })
20+
@IsOptional()
21+
@IsUrl()
22+
@MaxLength(2048)
23+
avatarUrl?: string | null;
24+
25+
@ApiPropertyOptional({
26+
description: 'Preferred currency (e.g. USD, EUR, XLM). Must be from supported list.',
27+
example: 'USD',
28+
})
29+
@IsOptional()
30+
@IsString()
31+
@MaxLength(10)
32+
preferredCurrency?: string;
33+
34+
@ApiPropertyOptional({
35+
description: 'Default split type for new splits',
36+
enum: DefaultSplitType,
37+
})
38+
@IsOptional()
39+
@IsEnum(DefaultSplitType)
40+
defaultSplitType?: DefaultSplitType;
41+
42+
@ApiPropertyOptional({ description: 'Email notifications opt-in' })
43+
@IsOptional()
44+
@IsBoolean()
45+
emailNotifications?: boolean;
46+
47+
@ApiPropertyOptional({ description: 'Push notifications opt-in' })
48+
@IsOptional()
49+
@IsBoolean()
50+
pushNotifications?: boolean;
51+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { NotFoundException } from '@nestjs/common';
3+
import { ProfileController } from './profile.controller';
4+
import { ProfileService } from './profile.service';
5+
import { UserProfile, DefaultSplitType } from './profile.entity';
6+
7+
describe('ProfileController', () => {
8+
let controller: ProfileController;
9+
let profileService: ProfileService;
10+
11+
const walletAddress = 'GDZST3XVCDTUJ76ZAV2HA72KYQODXXZ5PTMAPZGDHZ6CS7RO7MGG3DBM';
12+
const mockProfile: UserProfile = {
13+
walletAddress,
14+
displayName: 'Alice',
15+
avatarUrl: null,
16+
preferredCurrency: 'USD',
17+
defaultSplitType: DefaultSplitType.EQUAL,
18+
emailNotifications: true,
19+
pushNotifications: true,
20+
createdAt: new Date(),
21+
updatedAt: new Date(),
22+
};
23+
24+
const mockProfileService = {
25+
getByWalletAddress: jest.fn().mockResolvedValue(mockProfile),
26+
update: jest.fn().mockResolvedValue(mockProfile),
27+
};
28+
29+
beforeEach(async () => {
30+
jest.clearAllMocks();
31+
mockProfileService.getByWalletAddress.mockResolvedValue(mockProfile);
32+
mockProfileService.update.mockResolvedValue(mockProfile);
33+
34+
const module: TestingModule = await Test.createTestingModule({
35+
controllers: [ProfileController],
36+
providers: [
37+
{
38+
provide: ProfileService,
39+
useValue: mockProfileService,
40+
},
41+
],
42+
}).compile();
43+
44+
controller = module.get<ProfileController>(ProfileController);
45+
profileService = module.get<ProfileService>(ProfileService);
46+
});
47+
48+
it('should be defined', () => {
49+
expect(controller).toBeDefined();
50+
});
51+
52+
describe('getByWalletAddress', () => {
53+
it('should return profile for wallet address', async () => {
54+
const result = await controller.getByWalletAddress(walletAddress);
55+
expect(result).toEqual(mockProfile);
56+
expect(profileService.getByWalletAddress).toHaveBeenCalledWith(walletAddress);
57+
});
58+
59+
it('should throw when service throws NotFoundException', async () => {
60+
mockProfileService.getByWalletAddress.mockRejectedValue(
61+
new NotFoundException('Profile for wallet address GUNKNOWN not found'),
62+
);
63+
await expect(
64+
controller.getByWalletAddress('GUNKNOWN'),
65+
).rejects.toThrow(NotFoundException);
66+
});
67+
});
68+
69+
describe('update', () => {
70+
it('should update and return profile', async () => {
71+
const dto = {
72+
displayName: 'Alice Updated',
73+
preferredCurrency: 'EUR' as const,
74+
};
75+
const result = await controller.update(walletAddress, dto);
76+
expect(result).toEqual(mockProfile);
77+
expect(profileService.update).toHaveBeenCalledWith(walletAddress, dto);
78+
});
79+
});
80+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {
2+
Controller,
3+
Get,
4+
Patch,
5+
Body,
6+
Param,
7+
ValidationPipe,
8+
} from '@nestjs/common';
9+
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
10+
import { ProfileService } from './profile.service';
11+
import { UserProfile } from './profile.entity';
12+
import { UpdateProfileDto } from './dto/update-profile.dto';
13+
14+
@ApiTags('Profile')
15+
@Controller('profile')
16+
export class ProfileController {
17+
constructor(private readonly profileService: ProfileService) {}
18+
19+
@Get(':walletAddress')
20+
@ApiOperation({ summary: 'Get user profile by wallet address' })
21+
@ApiParam({
22+
name: 'walletAddress',
23+
description: 'Stellar wallet address (G...)',
24+
example: 'GDZST3XVCDTUJ76ZAV2HA72KYQODXXZ5PTMAPZGDHZ6CS7RO7MGG3DBM',
25+
})
26+
@ApiResponse({ status: 200, description: 'Profile found', type: UserProfile })
27+
@ApiResponse({ status: 404, description: 'Profile not found for wallet address' })
28+
async getByWalletAddress(
29+
@Param('walletAddress') walletAddress: string,
30+
): Promise<UserProfile> {
31+
return this.profileService.getByWalletAddress(walletAddress);
32+
}
33+
34+
@Patch(':walletAddress')
35+
@ApiOperation({ summary: 'Update user profile (creates if not exists)' })
36+
@ApiParam({
37+
name: 'walletAddress',
38+
description: 'Stellar wallet address (G...)',
39+
})
40+
@ApiResponse({ status: 200, description: 'Profile updated', type: UserProfile })
41+
@ApiResponse({ status: 400, description: 'Validation error (e.g. unsupported currency)' })
42+
async update(
43+
@Param('walletAddress') walletAddress: string,
44+
@Body(ValidationPipe) dto: UpdateProfileDto,
45+
): Promise<UserProfile> {
46+
return this.profileService.update(walletAddress, dto);
47+
}
48+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {
2+
Entity,
3+
PrimaryColumn,
4+
Column,
5+
CreateDateColumn,
6+
UpdateDateColumn,
7+
} from 'typeorm';
8+
9+
export enum DefaultSplitType {
10+
EQUAL = 'equal',
11+
ITEMIZED = 'itemized',
12+
PERCENTAGE = 'percentage',
13+
CUSTOM = 'custom',
14+
}
15+
16+
@Entity('user_profiles')
17+
export class UserProfile {
18+
@PrimaryColumn({ type: 'varchar', length: 56, name: 'wallet_address' })
19+
walletAddress!: string;
20+
21+
@Column({ type: 'varchar', length: 255, nullable: true, name: 'display_name' })
22+
displayName!: string | null;
23+
24+
@Column({ type: 'varchar', length: 2048, nullable: true, name: 'avatar_url' })
25+
avatarUrl!: string | null;
26+
27+
@Column({ type: 'varchar', length: 10, default: 'USD', name: 'preferred_currency' })
28+
preferredCurrency!: string;
29+
30+
@Column({
31+
type: 'varchar',
32+
length: 20,
33+
default: DefaultSplitType.EQUAL,
34+
name: 'default_split_type',
35+
})
36+
defaultSplitType!: DefaultSplitType;
37+
38+
@Column({ type: 'boolean', default: true, name: 'email_notifications' })
39+
emailNotifications!: boolean;
40+
41+
@Column({ type: 'boolean', default: true, name: 'push_notifications' })
42+
pushNotifications!: boolean;
43+
44+
@CreateDateColumn({ name: 'created_at' })
45+
createdAt!: Date;
46+
47+
@UpdateDateColumn({ name: 'updated_at' })
48+
updatedAt!: Date;
49+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { UserProfile } from './profile.entity';
4+
import { ProfileService } from './profile.service';
5+
import { ProfileController } from './profile.controller';
6+
import { CurrencyModule } from '../modules/currency/currency.module';
7+
8+
@Module({
9+
imports: [
10+
TypeOrmModule.forFeature([UserProfile]),
11+
CurrencyModule,
12+
],
13+
controllers: [ProfileController],
14+
providers: [ProfileService],
15+
exports: [ProfileService],
16+
})
17+
export class ProfileModule {}

0 commit comments

Comments
 (0)