Files
pwap/shared/lib/perwerde.ts
T
pezkuwichain c488cfc3bc feat: Phase 1B complete - Perwerde & ValidatorPool UI
Perwerde (Education Platform):
- Add hybrid backend (Supabase + Blockchain + IPFS)
- Implement CourseList, CourseCreator, StudentDashboard
- Create courses table with RLS policies
- Add IPFS upload utility
- Integrate with pallet-perwerde extrinsics

ValidatorPool:
- Add validator pool management UI
- Implement PoolCategorySelector with 3 categories
- Add ValidatorPoolDashboard with pool stats
- Integrate with pallet-validator-pool extrinsics
- Add to StakingDashboard as new tab

Technical:
- Fix all toast imports (sonner)
- Fix IPFS File upload (Blob conversion)
- Fix RLS policies (wallet_address → auth.uid)
- Add error boundaries
- Add loading states

Status: UI complete, blockchain integration pending VPS deployment
2025-11-17 05:04:51 +03:00

373 lines
10 KiB
TypeScript

import { ApiPromise } from '@polkadot/api';
import { SubmittableExtrinsic } from '@polkadot/api/types';
import { InjectedAccountWithMeta } from '@polkadot/extension-inject/types';
import { toast } from 'sonner';
import { supabase } from '@/lib/supabase';
/**
* Course data structure matching blockchain pallet
*/
export interface Course {
id: number;
owner: string;
name: string;
description: string;
content_link: string; // IPFS hash
status: 'Active' | 'Archived';
created_at: string;
}
/**
* Enrollment data structure
*/
export interface Enrollment {
id: string;
student_address: string;
course_id: number;
enrolled_at: string;
completed_at?: string;
points_earned: number;
is_completed: boolean;
}
/**
* Create a new course on blockchain and sync to Supabase
*
* Flow:
* 1. Call blockchain create_course extrinsic
* 2. Wait for block inclusion
* 3. Extract course_id from event
* 4. Insert to Supabase with blockchain course_id
*/
export async function createCourse(
api: ApiPromise,
account: InjectedAccountWithMeta,
name: string,
description: string,
ipfsHash: string
): Promise<number> {
try {
// Convert strings to bounded vecs (Vec<u8>)
const nameVec = Array.from(new TextEncoder().encode(name));
const descVec = Array.from(new TextEncoder().encode(description));
const linkVec = Array.from(new TextEncoder().encode(ipfsHash));
// Create extrinsic
const extrinsic = api.tx.perwerde.createCourse(nameVec, descVec, linkVec);
// Sign and send
const courseId = await new Promise<number>((resolve, reject) => {
let unsub: () => void;
extrinsic.signAndSend(
account.address,
({ status, events, dispatchError }) => {
if (dispatchError) {
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs}`));
} else {
reject(new Error(dispatchError.toString()));
}
if (unsub) unsub();
return;
}
if (status.isInBlock || status.isFinalized) {
// Find CourseCreated event
const courseCreatedEvent = events.find(
({ event }) =>
event.section === 'perwerde' && event.method === 'CourseCreated'
);
if (courseCreatedEvent) {
const courseId = courseCreatedEvent.event.data[0].toString();
resolve(parseInt(courseId));
} else {
reject(new Error('CourseCreated event not found'));
}
if (unsub) unsub();
}
}
).then((unsubscribe) => {
unsub = unsubscribe;
});
});
// Insert to Supabase
const { error: supabaseError } = await supabase.from('courses').insert({
id: courseId,
owner: account.address,
name,
description,
content_link: ipfsHash,
status: 'Active',
created_at: new Date().toISOString(),
});
if (supabaseError) {
console.error('Supabase insert failed:', supabaseError);
toast.error('Course created on blockchain but failed to sync to database');
} else {
toast.success(`Course created with ID: ${courseId}`);
}
return courseId;
} catch (error) {
console.error('Create course error:', error);
toast.error(error instanceof Error ? error.message : 'Failed to create course');
throw error;
}
}
/**
* Enroll student in a course
*/
export async function enrollInCourse(
api: ApiPromise,
account: InjectedAccountWithMeta,
courseId: number
): Promise<void> {
try {
const extrinsic = api.tx.perwerde.enroll(courseId);
await new Promise<void>((resolve, reject) => {
let unsub: () => void;
extrinsic.signAndSend(
account.address,
({ status, events, dispatchError }) => {
if (dispatchError) {
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs}`));
} else {
reject(new Error(dispatchError.toString()));
}
if (unsub) unsub();
return;
}
if (status.isInBlock || status.isFinalized) {
resolve();
if (unsub) unsub();
}
}
).then((unsubscribe) => {
unsub = unsubscribe;
});
});
// Insert enrollment to Supabase
const { error: supabaseError } = await supabase.from('enrollments').insert({
student_address: account.address,
course_id: courseId,
enrolled_at: new Date().toISOString(),
is_completed: false,
points_earned: 0,
});
if (supabaseError) {
console.error('Supabase enrollment insert failed:', supabaseError);
toast.error('Enrolled on blockchain but failed to sync to database');
} else {
toast.success('Successfully enrolled in course');
}
} catch (error) {
console.error('Enroll error:', error);
toast.error(error instanceof Error ? error.message : 'Failed to enroll in course');
throw error;
}
}
/**
* Mark course as completed (student self-completes)
*/
export async function completeCourse(
api: ApiPromise,
account: InjectedAccountWithMeta,
courseId: number,
points: number
): Promise<void> {
try {
const extrinsic = api.tx.perwerde.completeCourse(courseId, points);
await new Promise<void>((resolve, reject) => {
let unsub: () => void;
extrinsic.signAndSend(
account.address,
({ status, dispatchError }) => {
if (dispatchError) {
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs}`));
} else {
reject(new Error(dispatchError.toString()));
}
if (unsub) unsub();
return;
}
if (status.isInBlock || status.isFinalized) {
resolve();
if (unsub) unsub();
}
}
).then((unsubscribe) => {
unsub = unsubscribe;
});
});
// Update enrollment in Supabase
const { error: supabaseError } = await supabase
.from('enrollments')
.update({
is_completed: true,
completed_at: new Date().toISOString(),
points_earned: points,
})
.eq('student_address', account.address)
.eq('course_id', courseId);
if (supabaseError) {
console.error('Supabase completion update failed:', supabaseError);
toast.error('Completed on blockchain but failed to sync to database');
} else {
toast.success(`Course completed! Earned ${points} points`);
}
} catch (error) {
console.error('Complete course error:', error);
toast.error(error instanceof Error ? error.message : 'Failed to complete course');
throw error;
}
}
/**
* Archive a course (admin/owner only)
*/
export async function archiveCourse(
api: ApiPromise,
account: InjectedAccountWithMeta,
courseId: number
): Promise<void> {
try {
const extrinsic = api.tx.perwerde.archiveCourse(courseId);
await new Promise<void>((resolve, reject) => {
let unsub: () => void;
extrinsic.signAndSend(
account.address,
({ status, dispatchError }) => {
if (dispatchError) {
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs}`));
} else {
reject(new Error(dispatchError.toString()));
}
if (unsub) unsub();
return;
}
if (status.isInBlock || status.isFinalized) {
resolve();
if (unsub) unsub();
}
}
).then((unsubscribe) => {
unsub = unsubscribe;
});
});
// Update course status in Supabase
const { error: supabaseError } = await supabase
.from('courses')
.update({ status: 'Archived' })
.eq('id', courseId);
if (supabaseError) {
console.error('Supabase archive update failed:', supabaseError);
toast.error('Archived on blockchain but failed to sync to database');
} else {
toast.success('Course archived');
}
} catch (error) {
console.error('Archive course error:', error);
toast.error(error instanceof Error ? error.message : 'Failed to archive course');
throw error;
}
}
/**
* Get Perwerde score for a student (from blockchain)
*/
export async function getPerwerdeScore(
api: ApiPromise,
studentAddress: string
): Promise<number> {
try {
// This would require a custom RPC or query if exposed
// For now, calculate from Supabase
const { data, error } = await supabase
.from('enrollments')
.select('points_earned')
.eq('student_address', studentAddress)
.eq('is_completed', true);
if (error) throw error;
const totalPoints = data?.reduce((sum, e) => sum + e.points_earned, 0) || 0;
return totalPoints;
} catch (error) {
console.error('Get Perwerde score error:', error);
return 0;
}
}
/**
* Fetch all courses from Supabase
*/
export async function getCourses(status?: 'Active' | 'Archived'): Promise<Course[]> {
try {
let query = supabase.from('courses').select('*').order('created_at', { ascending: false });
if (status) {
query = query.eq('status', status);
}
const { data, error } = await query;
if (error) throw error;
return data || [];
} catch (error) {
console.error('Get courses error:', error);
toast.error('Failed to fetch courses');
return [];
}
}
/**
* Fetch student enrollments
*/
export async function getStudentEnrollments(studentAddress: string): Promise<Enrollment[]> {
try {
const { data, error } = await supabase
.from('enrollments')
.select('*')
.eq('student_address', studentAddress)
.order('enrolled_at', { ascending: false });
if (error) throw error;
return data || [];
} catch (error) {
console.error('Get enrollments error:', error);
toast.error('Failed to fetch enrollments');
return [];
}
}