Skip to content

Commit e12b4b6

Browse files
committed
feat: extended learner onboarding profile endpoint
- Added SQL migration to expand learner_profiles table - Refactored DTOs with strict class-validator bounds for rich profiles - Implemented PATCH /api/v1/learners/profile for partial onboarding updates - Added GET /api/v1/learners/profile/completion for mobile progress tracking - Added unit tests for service, controller, and DTO validation
1 parent 4224bb6 commit e12b4b6

9 files changed

Lines changed: 435 additions & 62 deletions

File tree

context/progress-tracker.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ EAS build for Expo preview → then landing page → then GitHub issues → then
6262

6363
## In Progress
6464

65+
- **Learner Profile Onboarding** — Extended profile table, added PATCH /learners/profile and GET /learners/profile/completion endpoints for rich onboarding.
66+
6567
- **Stellar.toml endpoint** — Added `GET /.well-known/stellar.toml` via `StellarTomlController` for Stellar ecosystem discoverability (Lobstr, StellarExpert). Returns TOML metadata with org info and 4 contract IDs. Cached in-memory with 1-hour TTL. (`stellar-toml.controller.ts`, `health.module.ts`, `stellar-toml.e2e-spec.ts`)
6668

6769
---
Lines changed: 113 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,136 @@
1-
import { IsString, IsOptional, IsEnum, IsNumber, Min, Max } from 'class-validator';
2-
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
3-
4-
export enum IncomeType {
5-
EMPLOYED = 'employed',
6-
INTERN = 'intern',
7-
FREELANCE = 'freelance',
8-
STUDENT = 'student',
9-
UNEMPLOYED = 'unemployed',
1+
import {
2+
IsString,
3+
IsOptional,
4+
IsEnum,
5+
IsNumber,
6+
Min,
7+
Max,
8+
MinLength,
9+
MaxLength,
10+
ArrayMaxSize,
11+
ArrayMinSize,
12+
IsArray,
13+
IsUrl,
14+
} from 'class-validator';
15+
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
16+
17+
export enum CurrentRole {
18+
STUDENT = 'Student',
19+
INTERN = 'Intern',
20+
EARLY_CAREER_DEV = 'EarlyCareerDev',
21+
FREELANCER = 'Freelancer',
22+
EMPLOYED = 'Employed',
23+
}
24+
25+
export enum Skill {
26+
JAVASCRIPT = 'JavaScript',
27+
TYPESCRIPT = 'TypeScript',
28+
RUST = 'Rust',
29+
PYTHON = 'Python',
30+
GO = 'Go',
31+
REACT = 'React',
32+
REACT_NATIVE = 'React Native',
33+
NESTJS = 'NestJS',
34+
SOLIDITY = 'Solidity',
35+
SOROBAN = 'Soroban',
36+
DESIGN = 'Design',
37+
DEVOPS = 'DevOps',
38+
TESTING = 'Testing',
39+
TECHNICAL_WRITING = 'Technical Writing',
40+
OTHER = 'Other',
1041
}
1142

12-
export enum ProgramType {
13-
BOOTCAMP = 'bootcamp',
14-
UNIVERSITY = 'university',
15-
SELF_TAUGHT = 'self_taught',
16-
ONLINE_COURSE = 'online_course',
17-
APPRENTICESHIP = 'apprenticeship',
43+
export enum FinanceGoal {
44+
LAPTOP = 'Laptop',
45+
COURSE = 'Course',
46+
BOOTCAMP = 'Bootcamp',
47+
DEV_TOOLS = 'Dev Tools',
48+
SUBSCRIPTIONS = 'Subscriptions',
49+
BOOKS = 'Books',
50+
OTHER = 'Other',
1851
}
1952

20-
export class UpdateLearnerProfileDto {
53+
export enum MonthlyIncomeRange {
54+
NO_INCOME = 'No Income',
55+
UNDER_500 = 'Under $500',
56+
RANGE_500_1000 = '$500-$1000',
57+
RANGE_1000_5000 = '$1000-$5000',
58+
ABOVE_5000 = 'Above $5000',
59+
}
60+
61+
export class CreateLearnerProfileDto {
62+
@ApiProperty({ example: 'John Doe' })
63+
@IsString()
64+
@MinLength(2)
65+
@MaxLength(100)
66+
full_name: string;
67+
68+
@ApiPropertyOptional({ example: 'I am a passionate learner.' })
69+
@IsOptional()
70+
@IsString()
71+
@MaxLength(500)
72+
bio?: string;
73+
74+
@ApiProperty({ example: 'Nigeria' })
75+
@IsString()
76+
country: string;
77+
78+
@ApiPropertyOptional({ example: 'Lagos' })
79+
@IsOptional()
80+
@IsString()
81+
city?: string;
82+
83+
@ApiPropertyOptional({ enum: CurrentRole })
84+
@IsOptional()
85+
@IsEnum(CurrentRole)
86+
current_role?: CurrentRole;
87+
2188
@ApiPropertyOptional({ example: 'University of Lagos' })
2289
@IsOptional()
2390
@IsString()
24-
school?: string;
91+
@MaxLength(100)
92+
institution?: string;
2593

2694
@ApiPropertyOptional({ example: 'Computer Science' })
2795
@IsOptional()
2896
@IsString()
97+
@MaxLength(100)
2998
program?: string;
3099

31-
@ApiPropertyOptional({ enum: ProgramType })
100+
@ApiPropertyOptional({ example: 2024 })
32101
@IsOptional()
33-
@IsEnum(ProgramType)
34-
programType?: ProgramType;
102+
@IsNumber()
103+
@Min(2020)
104+
@Max(2035)
105+
graduation_year?: number;
35106

36-
@ApiPropertyOptional({ enum: IncomeType })
107+
@ApiPropertyOptional({ enum: Skill, isArray: true, maxItems: 15 })
37108
@IsOptional()
38-
@IsEnum(IncomeType)
39-
incomeType?: IncomeType;
109+
@IsArray()
110+
@IsEnum(Skill, { each: true })
111+
@ArrayMaxSize(15)
112+
skills?: Skill[];
113+
114+
@ApiProperty({ enum: FinanceGoal, isArray: true, minItems: 1 })
115+
@IsArray()
116+
@IsEnum(FinanceGoal, { each: true })
117+
@ArrayMinSize(1)
118+
finance_goals: FinanceGoal[];
40119

41-
@ApiPropertyOptional({ example: 500 })
120+
@ApiPropertyOptional({ enum: MonthlyIncomeRange })
42121
@IsOptional()
43-
@IsNumber()
44-
@Min(0)
45-
monthlyIncome?: number;
122+
@IsEnum(MonthlyIncomeRange)
123+
monthly_income_range?: MonthlyIncomeRange;
46124

47-
@ApiPropertyOptional({ example: 'Nigeria' })
125+
@ApiPropertyOptional({ example: 'https://github.com/johndoe' })
48126
@IsOptional()
49-
@IsString()
50-
country?: string;
127+
@IsUrl()
128+
github_url?: string;
51129

52-
@ApiPropertyOptional({ example: 'Lagos' })
130+
@ApiPropertyOptional({ example: 'https://linkedin.com/in/johndoe' })
53131
@IsOptional()
54-
@IsString()
55-
city?: string;
132+
@IsUrl()
133+
linkedin_url?: string;
56134
}
135+
136+
export class UpdateLearnerProfileDto extends PartialType(CreateLearnerProfileDto) {}
Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2-
import { IncomeType, ProgramType } from './learner-profile.dto';
2+
import { CurrentRole, Skill, FinanceGoal, MonthlyIncomeRange } from './learner-profile.dto';
33

44
export class LearnerResponseDto {
55
@ApiProperty() id: string;
66
@ApiProperty() walletAddress: string;
7-
@ApiPropertyOptional() school?: string;
8-
@ApiPropertyOptional() program?: string;
9-
@ApiPropertyOptional({ enum: ProgramType }) programType?: ProgramType;
10-
@ApiPropertyOptional({ enum: IncomeType }) incomeType?: IncomeType;
11-
@ApiPropertyOptional() monthlyIncome?: number;
7+
@ApiPropertyOptional() fullName?: string;
8+
@ApiPropertyOptional() bio?: string;
129
@ApiPropertyOptional() country?: string;
1310
@ApiPropertyOptional() city?: string;
14-
@ApiPropertyOptional() deviceOwned?: boolean;
11+
@ApiPropertyOptional({ enum: CurrentRole }) currentRole?: CurrentRole;
12+
@ApiPropertyOptional() institution?: string;
13+
@ApiPropertyOptional() program?: string;
14+
@ApiPropertyOptional() graduationYear?: number;
15+
@ApiPropertyOptional({ enum: Skill, isArray: true }) skills?: Skill[];
16+
@ApiPropertyOptional({ enum: FinanceGoal, isArray: true }) financeGoals?: FinanceGoal[];
17+
@ApiPropertyOptional({ enum: MonthlyIncomeRange }) monthlyIncomeRange?: MonthlyIncomeRange;
18+
@ApiPropertyOptional() githubUrl?: string;
19+
@ApiPropertyOptional() linkedinUrl?: string;
20+
@ApiPropertyOptional() profileComplete?: boolean;
21+
@ApiPropertyOptional() onboardingCompletedAt?: string;
1522
@ApiProperty() createdAt: string;
1623
@ApiProperty() updatedAt: string;
1724
}

src/modules/learners/learners.controller.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
1313
export class LearnersController {
1414
constructor(private readonly learnersService: LearnersService) {}
1515

16-
@Get('me')
16+
@Get('profile')
1717
@HttpCode(HttpStatus.OK)
1818
@ApiOperation({ summary: 'Get learner profile' })
1919
@ApiResponse({ status: 200, description: 'Learner profile', type: LearnerResponseDto })
@@ -22,7 +22,7 @@ export class LearnersController {
2222
return this.learnersService.getProfile(user.wallet);
2323
}
2424

25-
@Patch('me')
25+
@Patch('profile')
2626
@HttpCode(HttpStatus.OK)
2727
@ApiOperation({ summary: 'Update learner profile' })
2828
@ApiBody({ type: UpdateLearnerProfileDto })
@@ -33,4 +33,12 @@ export class LearnersController {
3333
): Promise<LearnerResponseDto> {
3434
return this.learnersService.upsertProfile(user.wallet, dto);
3535
}
36+
37+
@Get('profile/completion')
38+
@HttpCode(HttpStatus.OK)
39+
@ApiOperation({ summary: 'Get learner profile completion status' })
40+
@ApiResponse({ status: 200, description: 'Completion status and missing fields' })
41+
async getCompletionStatus(@CurrentUser() user: { wallet: string }): Promise<{ complete: boolean; missingFields: string[] }> {
42+
return this.learnersService.getCompletionStatus(user.wallet);
43+
}
3644
}

src/modules/learners/learners.service.ts

Lines changed: 77 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,28 +28,78 @@ export class LearnersService {
2828
return this.mapToDto(data);
2929
}
3030

31+
async getCompletionStatus(wallet: string): Promise<{ complete: boolean; missingFields: string[] }> {
32+
let profile;
33+
try {
34+
profile = await this.getProfile(wallet);
35+
} catch (err) {
36+
return { complete: false, missingFields: ['fullName', 'country', 'financeGoals'] };
37+
}
38+
39+
const requiredFields = ['fullName', 'country', 'financeGoals'];
40+
const missingFields = requiredFields.filter(field => {
41+
const val = profile[field as keyof LearnerResponseDto];
42+
if (Array.isArray(val)) return val.length === 0;
43+
return !val;
44+
});
45+
46+
return {
47+
complete: missingFields.length === 0,
48+
missingFields,
49+
};
50+
}
51+
3152
async upsertProfile(
3253
wallet: string,
3354
dto: UpdateLearnerProfileDto,
3455
): Promise<LearnerResponseDto> {
3556
const client = this.supabaseService.getServiceRoleClient();
3657

58+
const { data: existing } = await client
59+
.from('learner_profiles')
60+
.select('*')
61+
.eq('wallet_address', wallet)
62+
.single();
63+
64+
const fullName = dto.full_name !== undefined ? dto.full_name : existing?.full_name;
65+
const country = dto.country !== undefined ? dto.country : existing?.country;
66+
const financeGoals = dto.finance_goals !== undefined ? dto.finance_goals : existing?.finance_goals;
67+
68+
const isComplete = !!(
69+
fullName &&
70+
country &&
71+
Array.isArray(financeGoals) && financeGoals.length > 0
72+
);
73+
74+
const wasComplete = existing?.profile_complete === true;
75+
const isNewlyComplete = isComplete && !wasComplete;
76+
77+
const onboardingCompletedAt = isNewlyComplete ? new Date().toISOString() : existing?.onboarding_completed_at;
78+
79+
const updatePayload: any = {
80+
wallet_address: wallet,
81+
profile_complete: isComplete,
82+
onboarding_completed_at: onboardingCompletedAt,
83+
updated_at: new Date().toISOString(),
84+
};
85+
86+
if (dto.full_name !== undefined) updatePayload.full_name = dto.full_name;
87+
if (dto.bio !== undefined) updatePayload.bio = dto.bio;
88+
if (dto.country !== undefined) updatePayload.country = dto.country;
89+
if (dto.city !== undefined) updatePayload.city = dto.city;
90+
if (dto.current_role !== undefined) updatePayload.current_role = dto.current_role;
91+
if (dto.institution !== undefined) updatePayload.institution = dto.institution;
92+
if (dto.program !== undefined) updatePayload.program = dto.program;
93+
if (dto.graduation_year !== undefined) updatePayload.graduation_year = dto.graduation_year;
94+
if (dto.skills !== undefined) updatePayload.skills = dto.skills;
95+
if (dto.finance_goals !== undefined) updatePayload.finance_goals = dto.finance_goals;
96+
if (dto.monthly_income_range !== undefined) updatePayload.monthly_income_range = dto.monthly_income_range;
97+
if (dto.github_url !== undefined) updatePayload.github_url = dto.github_url;
98+
if (dto.linkedin_url !== undefined) updatePayload.linkedin_url = dto.linkedin_url;
99+
37100
const { data, error } = await client
38101
.from('learner_profiles')
39-
.upsert(
40-
{
41-
wallet_address: wallet,
42-
school: dto.school,
43-
program: dto.program,
44-
program_type: dto.programType,
45-
income_type: dto.incomeType,
46-
monthly_income: dto.monthlyIncome,
47-
country: dto.country,
48-
city: dto.city,
49-
updated_at: new Date().toISOString(),
50-
},
51-
{ onConflict: 'wallet_address' },
52-
)
102+
.upsert(updatePayload, { onConflict: 'wallet_address' })
53103
.select()
54104
.single();
55105

@@ -66,14 +116,21 @@ export class LearnersService {
66116
return {
67117
id: data.id,
68118
walletAddress: data.wallet_address,
69-
school: data.school,
70-
program: data.program,
71-
programType: data.program_type,
72-
incomeType: data.income_type,
73-
monthlyIncome: data.monthly_income,
119+
fullName: data.full_name,
120+
bio: data.bio,
74121
country: data.country,
75122
city: data.city,
76-
deviceOwned: data.device_owned,
123+
currentRole: data.current_role,
124+
institution: data.institution,
125+
program: data.program,
126+
graduationYear: data.graduation_year,
127+
skills: data.skills,
128+
financeGoals: data.finance_goals,
129+
monthlyIncomeRange: data.monthly_income_range,
130+
githubUrl: data.github_url,
131+
linkedinUrl: data.linkedin_url,
132+
profileComplete: data.profile_complete,
133+
onboardingCompletedAt: data.onboarding_completed_at,
77134
createdAt: data.created_at,
78135
updatedAt: data.updated_at,
79136
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS full_name VARCHAR(100);
2+
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS bio TEXT;
3+
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS country VARCHAR(60);
4+
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS city VARCHAR(60);
5+
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS current_role VARCHAR(50);
6+
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS institution VARCHAR(100);
7+
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS program VARCHAR(100);
8+
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS graduation_year INTEGER;
9+
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS skills TEXT[];
10+
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS finance_goals TEXT[];
11+
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS monthly_income_range VARCHAR(30);
12+
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS github_url VARCHAR(200);
13+
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS linkedin_url VARCHAR(200);
14+
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS profile_complete BOOLEAN DEFAULT FALSE;
15+
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS onboarding_completed_at TIMESTAMPTZ;

0 commit comments

Comments
 (0)