Skip to content
Open
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 context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ EAS build for Expo preview → then landing page → then GitHub issues → then

## In Progress

- **Learner Profile Onboarding** — Extended profile table, added PATCH /learners/profile and GET /learners/profile/completion endpoints for rich onboarding.

- **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`)

---
Expand Down
146 changes: 113 additions & 33 deletions src/modules/learners/dto/learner-profile.dto.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,136 @@
import { IsString, IsOptional, IsEnum, IsNumber, Min, Max } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export enum IncomeType {
EMPLOYED = 'employed',
INTERN = 'intern',
FREELANCE = 'freelance',
STUDENT = 'student',
UNEMPLOYED = 'unemployed',
import {
IsString,
IsOptional,
IsEnum,
IsNumber,
Min,
Max,
MinLength,
MaxLength,
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsUrl,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';

export enum CurrentRole {
STUDENT = 'Student',
INTERN = 'Intern',
EARLY_CAREER_DEV = 'EarlyCareerDev',
FREELANCER = 'Freelancer',
EMPLOYED = 'Employed',
}

export enum Skill {
JAVASCRIPT = 'JavaScript',
TYPESCRIPT = 'TypeScript',
RUST = 'Rust',
PYTHON = 'Python',
GO = 'Go',
REACT = 'React',
REACT_NATIVE = 'React Native',
NESTJS = 'NestJS',
SOLIDITY = 'Solidity',
SOROBAN = 'Soroban',
DESIGN = 'Design',
DEVOPS = 'DevOps',
TESTING = 'Testing',
TECHNICAL_WRITING = 'Technical Writing',
OTHER = 'Other',
}

export enum ProgramType {
BOOTCAMP = 'bootcamp',
UNIVERSITY = 'university',
SELF_TAUGHT = 'self_taught',
ONLINE_COURSE = 'online_course',
APPRENTICESHIP = 'apprenticeship',
export enum FinanceGoal {
LAPTOP = 'Laptop',
COURSE = 'Course',
BOOTCAMP = 'Bootcamp',
DEV_TOOLS = 'Dev Tools',
SUBSCRIPTIONS = 'Subscriptions',
BOOKS = 'Books',
OTHER = 'Other',
}

export class UpdateLearnerProfileDto {
export enum MonthlyIncomeRange {
NO_INCOME = 'No Income',
UNDER_500 = 'Under $500',
RANGE_500_1000 = '$500-$1000',
RANGE_1000_5000 = '$1000-$5000',
ABOVE_5000 = 'Above $5000',
}

export class CreateLearnerProfileDto {
@ApiProperty({ example: 'John Doe' })
@IsString()
@MinLength(2)
@MaxLength(100)
full_name: string;

@ApiPropertyOptional({ example: 'I am a passionate learner.' })
@IsOptional()
@IsString()
@MaxLength(500)
bio?: string;

@ApiProperty({ example: 'Nigeria' })
@IsString()
country: string;

@ApiPropertyOptional({ example: 'Lagos' })
@IsOptional()
@IsString()
city?: string;

@ApiPropertyOptional({ enum: CurrentRole })
@IsOptional()
@IsEnum(CurrentRole)
current_role?: CurrentRole;

@ApiPropertyOptional({ example: 'University of Lagos' })
@IsOptional()
@IsString()
school?: string;
@MaxLength(100)
institution?: string;

@ApiPropertyOptional({ example: 'Computer Science' })
@IsOptional()
@IsString()
@MaxLength(100)
program?: string;

@ApiPropertyOptional({ enum: ProgramType })
@ApiPropertyOptional({ example: 2024 })
@IsOptional()
@IsEnum(ProgramType)
programType?: ProgramType;
@IsNumber()
@Min(2020)
@Max(2035)
graduation_year?: number;

@ApiPropertyOptional({ enum: IncomeType })
@ApiPropertyOptional({ enum: Skill, isArray: true, maxItems: 15 })
@IsOptional()
@IsEnum(IncomeType)
incomeType?: IncomeType;
@IsArray()
@IsEnum(Skill, { each: true })
@ArrayMaxSize(15)
skills?: Skill[];

@ApiProperty({ enum: FinanceGoal, isArray: true, minItems: 1 })
@IsArray()
@IsEnum(FinanceGoal, { each: true })
@ArrayMinSize(1)
finance_goals: FinanceGoal[];

@ApiPropertyOptional({ example: 500 })
@ApiPropertyOptional({ enum: MonthlyIncomeRange })
@IsOptional()
@IsNumber()
@Min(0)
monthlyIncome?: number;
@IsEnum(MonthlyIncomeRange)
monthly_income_range?: MonthlyIncomeRange;

@ApiPropertyOptional({ example: 'Nigeria' })
@ApiPropertyOptional({ example: 'https://github.com/johndoe' })
@IsOptional()
@IsString()
country?: string;
@IsUrl()
github_url?: string;

@ApiPropertyOptional({ example: 'Lagos' })
@ApiPropertyOptional({ example: 'https://linkedin.com/in/johndoe' })
@IsOptional()
@IsString()
city?: string;
@IsUrl()
linkedin_url?: string;
}

export class UpdateLearnerProfileDto extends PartialType(CreateLearnerProfileDto) {}
21 changes: 14 additions & 7 deletions src/modules/learners/dto/learner-response.dto.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IncomeType, ProgramType } from './learner-profile.dto';
import { CurrentRole, Skill, FinanceGoal, MonthlyIncomeRange } from './learner-profile.dto';

export class LearnerResponseDto {
@ApiProperty() id: string;
@ApiProperty() walletAddress: string;
@ApiPropertyOptional() school?: string;
@ApiPropertyOptional() program?: string;
@ApiPropertyOptional({ enum: ProgramType }) programType?: ProgramType;
@ApiPropertyOptional({ enum: IncomeType }) incomeType?: IncomeType;
@ApiPropertyOptional() monthlyIncome?: number;
@ApiPropertyOptional() fullName?: string;
@ApiPropertyOptional() bio?: string;
@ApiPropertyOptional() country?: string;
@ApiPropertyOptional() city?: string;
@ApiPropertyOptional() deviceOwned?: boolean;
@ApiPropertyOptional({ enum: CurrentRole }) currentRole?: CurrentRole;
@ApiPropertyOptional() institution?: string;
@ApiPropertyOptional() program?: string;
@ApiPropertyOptional() graduationYear?: number;
@ApiPropertyOptional({ enum: Skill, isArray: true }) skills?: Skill[];
@ApiPropertyOptional({ enum: FinanceGoal, isArray: true }) financeGoals?: FinanceGoal[];
@ApiPropertyOptional({ enum: MonthlyIncomeRange }) monthlyIncomeRange?: MonthlyIncomeRange;
@ApiPropertyOptional() githubUrl?: string;
@ApiPropertyOptional() linkedinUrl?: string;
@ApiPropertyOptional() profileComplete?: boolean;
@ApiPropertyOptional() onboardingCompletedAt?: string;
@ApiProperty() createdAt: string;
@ApiProperty() updatedAt: string;
}
12 changes: 10 additions & 2 deletions src/modules/learners/learners.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
export class LearnersController {
constructor(private readonly learnersService: LearnersService) {}

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

@Patch('me')
@Patch('profile')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Update learner profile' })
@ApiBody({ type: UpdateLearnerProfileDto })
Expand All @@ -33,4 +33,12 @@ export class LearnersController {
): Promise<LearnerResponseDto> {
return this.learnersService.upsertProfile(user.wallet, dto);
}

@Get('profile/completion')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Get learner profile completion status' })
@ApiResponse({ status: 200, description: 'Completion status and missing fields' })
async getCompletionStatus(@CurrentUser() user: { wallet: string }): Promise<{ complete: boolean; missingFields: string[] }> {
return this.learnersService.getCompletionStatus(user.wallet);
}
}
97 changes: 77 additions & 20 deletions src/modules/learners/learners.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,28 +28,78 @@ export class LearnersService {
return this.mapToDto(data);
}

async getCompletionStatus(wallet: string): Promise<{ complete: boolean; missingFields: string[] }> {
let profile;
try {
profile = await this.getProfile(wallet);
} catch (err) {
return { complete: false, missingFields: ['fullName', 'country', 'financeGoals'] };
}

const requiredFields = ['fullName', 'country', 'financeGoals'];
const missingFields = requiredFields.filter(field => {
const val = profile[field as keyof LearnerResponseDto];
if (Array.isArray(val)) return val.length === 0;
return !val;
});

return {
complete: missingFields.length === 0,
missingFields,
};
}

async upsertProfile(
wallet: string,
dto: UpdateLearnerProfileDto,
): Promise<LearnerResponseDto> {
const client = this.supabaseService.getServiceRoleClient();

const { data: existing } = await client
.from('learner_profiles')
.select('*')
.eq('wallet_address', wallet)
.single();

const fullName = dto.full_name !== undefined ? dto.full_name : existing?.full_name;
const country = dto.country !== undefined ? dto.country : existing?.country;
const financeGoals = dto.finance_goals !== undefined ? dto.finance_goals : existing?.finance_goals;

const isComplete = !!(
fullName &&
country &&
Array.isArray(financeGoals) && financeGoals.length > 0
);

const wasComplete = existing?.profile_complete === true;
const isNewlyComplete = isComplete && !wasComplete;

const onboardingCompletedAt = isNewlyComplete ? new Date().toISOString() : existing?.onboarding_completed_at;

const updatePayload: any = {
wallet_address: wallet,
profile_complete: isComplete,
onboarding_completed_at: onboardingCompletedAt,
updated_at: new Date().toISOString(),
};

if (dto.full_name !== undefined) updatePayload.full_name = dto.full_name;
if (dto.bio !== undefined) updatePayload.bio = dto.bio;
if (dto.country !== undefined) updatePayload.country = dto.country;
if (dto.city !== undefined) updatePayload.city = dto.city;
if (dto.current_role !== undefined) updatePayload.current_role = dto.current_role;
if (dto.institution !== undefined) updatePayload.institution = dto.institution;
if (dto.program !== undefined) updatePayload.program = dto.program;
if (dto.graduation_year !== undefined) updatePayload.graduation_year = dto.graduation_year;
if (dto.skills !== undefined) updatePayload.skills = dto.skills;
if (dto.finance_goals !== undefined) updatePayload.finance_goals = dto.finance_goals;
if (dto.monthly_income_range !== undefined) updatePayload.monthly_income_range = dto.monthly_income_range;
if (dto.github_url !== undefined) updatePayload.github_url = dto.github_url;
if (dto.linkedin_url !== undefined) updatePayload.linkedin_url = dto.linkedin_url;

const { data, error } = await client
.from('learner_profiles')
.upsert(
{
wallet_address: wallet,
school: dto.school,
program: dto.program,
program_type: dto.programType,
income_type: dto.incomeType,
monthly_income: dto.monthlyIncome,
country: dto.country,
city: dto.city,
updated_at: new Date().toISOString(),
},
{ onConflict: 'wallet_address' },
)
.upsert(updatePayload, { onConflict: 'wallet_address' })
.select()
.single();

Expand All @@ -66,14 +116,21 @@ export class LearnersService {
return {
id: data.id,
walletAddress: data.wallet_address,
school: data.school,
program: data.program,
programType: data.program_type,
incomeType: data.income_type,
monthlyIncome: data.monthly_income,
fullName: data.full_name,
bio: data.bio,
country: data.country,
city: data.city,
deviceOwned: data.device_owned,
currentRole: data.current_role,
institution: data.institution,
program: data.program,
graduationYear: data.graduation_year,
skills: data.skills,
financeGoals: data.finance_goals,
monthlyIncomeRange: data.monthly_income_range,
githubUrl: data.github_url,
linkedinUrl: data.linkedin_url,
profileComplete: data.profile_complete,
onboardingCompletedAt: data.onboarding_completed_at,
createdAt: data.created_at,
updatedAt: data.updated_at,
};
Expand Down
15 changes: 15 additions & 0 deletions supabase/migrations/20260626111334_extend_learner_profile.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS full_name VARCHAR(100);
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS bio TEXT;
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS country VARCHAR(60);
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS city VARCHAR(60);
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS current_role VARCHAR(50);
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS institution VARCHAR(100);
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS program VARCHAR(100);
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS graduation_year INTEGER;
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS skills TEXT[];
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS finance_goals TEXT[];
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS monthly_income_range VARCHAR(30);
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS github_url VARCHAR(200);
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS linkedin_url VARCHAR(200);
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS profile_complete BOOLEAN DEFAULT FALSE;
ALTER TABLE learner_profiles ADD COLUMN IF NOT EXISTS onboarding_completed_at TIMESTAMPTZ;
Loading