feat: migrate client-side database calls to secure server-side API endpoints

- Create secure server-side API endpoints for course data, marks, exercises, newsfeeds, submissions, and organization management
- Implement role-based permission checking using checkUserCoursePermissions utility
- Move all client-side Supabase calls to authenticated server endpoints in /api/courses/ and /api/org/
- Add proper authentication and authorization middleware via hooks.server.ts
- Update service functions to use getAccessToken() for API authentication
- Ensure data filtering based on user roles (students only see their own data)
- Maintain backward compatibility with existing component interfaces
This commit is contained in:
Rotimi Best
2025-12-05 10:48:01 +01:00
parent bf614fd782
commit ac4fdf1dd3
22 changed files with 986 additions and 200 deletions
@@ -0,0 +1,56 @@
import type { SupabaseClient } from '@supabase/supabase-js';
export interface UserPermissionResult {
hasAccess: boolean;
isOrgAdmin: boolean;
userMembership: any;
isStudent: boolean;
}
/**
* Check if a user has access to a course and determine their role
*/
export async function checkUserCoursePermissions(
supabase: SupabaseClient,
userId: string,
courseGroupId: string
): Promise<UserPermissionResult> {
// Check if user is a member of the course group
const { data: userMembership } = await supabase
.from('groupmember')
.select('role_id, id, profile_id, email, created_at, assigned_student_id, profile(*)')
.eq('group_id', courseGroupId)
.eq('profile_id', userId)
.single();
// Check if user is org admin
const { data: orgData } = await supabase
.from('group')
.select('organization_id')
.eq('id', courseGroupId)
.single();
let isOrgAdmin = false;
if (orgData?.organization_id) {
const { data: orgMembership } = await supabase
.from('organizationmember')
.select('role_id')
.eq('organization_id', orgData.organization_id)
.eq('profile_id', userId)
.eq('role_id', 1) // Admin role
.single();
isOrgAdmin = !!orgMembership;
}
// Check if user has access (either is course member or org admin)
const hasAccess = !!userMembership || isOrgAdmin;
const isStudent = userMembership?.role_id === 3 && !isOrgAdmin;
return {
hasAccess,
isOrgAdmin,
userMembership,
isStudent
};
}
@@ -17,7 +17,7 @@ import { STATUS } from '$lib/utils/constants/course';
import { get } from 'svelte/store';
import { isOrgAdmin } from '$lib/utils/store/org';
import { isUUID } from '$lib/utils/functions/isUUID';
import { supabase } from '$lib/utils/functions/supabase';
import { supabase, getAccessToken } from '$lib/utils/functions/supabase';
export async function fetchCourses(profileId, orgId) {
if (!orgId || !profileId) return;
@@ -156,6 +156,33 @@ export async function fetchCourse(courseId?: Course['id'], slug?: Course['slug']
};
}
export async function fetchCourseFromAPI(courseId: Course['id']) {
try {
const accessToken = await getAccessToken();
const response = await fetch('/api/courses/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify({ courseId })
});
const result = await response.json();
if (!result.success) {
console.error('fetchCourseFromAPI error:', result.message);
return { data: null, error: result.error || result.message };
}
return { data: result.data, error: null };
} catch (error) {
console.error('fetchCourseFromAPI error:', error);
return { data: null, error };
}
}
export async function fetchExploreCourses(profileId, orgId) {
if (!orgId || !profileId) return;
@@ -379,8 +406,29 @@ export function createExercise(exercise: any) {
return supabase.from('exercise').insert(exercise).select();
}
export function fetchExercisesByMarks(courseId: Course['id']) {
return supabase.rpc('get_exercises').eq('course_id', courseId);
export async function fetchExercisesByMarks(courseId: Course['id']) {
try {
const accessToken = await getAccessToken();
const response = await fetch('/api/courses/exercises', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify({ courseId })
});
const result = await response.json();
if (!result.success) {
return { data: null, error: result.message };
}
return { data: result.data, error: null };
} catch (error) {
return { data: null, error };
}
}
function isNew(item: any) {
@@ -1,7 +0,0 @@
import { supabase } from '$lib/utils/functions/supabase';
export async function fetchMarks(courseId) {
const { data, error } = await supabase.rpc('get_marks').eq('course_id', courseId);
return { data, error };
}
@@ -0,0 +1,26 @@
import { getAccessToken } from '$lib/utils/functions/supabase';
export async function fetchMarks(courseId: string) {
try {
const accessToken = await getAccessToken();
const response = await fetch('/api/courses/marks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify({ courseId })
});
const result = await response.json();
if (!result.success) {
return { data: null, error: result.message };
}
return { data: result.data, error: null };
} catch (error) {
return { data: null, error };
}
}
@@ -1,5 +1,5 @@
import type { Course } from '$lib/utils/types';
import { supabase } from '$lib/utils/functions/supabase';
import { supabase, getAccessToken } from '$lib/utils/functions/supabase';
import type { Reaction, FeedApi, Feed } from '$lib/utils/types/feed';
export async function fetchNewsFeedReaction(feedId: Feed['id']) {
@@ -7,38 +7,34 @@ export async function fetchNewsFeedReaction(feedId: Feed['id']) {
}
export async function fetchNewsFeeds(courseId?: Course['id']) {
const response = await supabase
.from('course_newsfeed')
.select(
`
id,
created_at,
content,
course_id,
author:groupmember(
profile(
id,
fullname,
avatar_url
)
),
reaction,
is_pinned,
comment:course_newsfeed_comment(
id,
created_at,
author:groupmember( profile(id, fullname, avatar_url) ),
content,
course_newsfeed_id)
`
)
.match({ course_id: courseId })
.order('created_at', { ascending: false })
.returns<FeedApi[]>();
const accessToken = await getAccessToken();
const { data, error } = response;
const response = await fetch(`/api/courses/newsfeed?courseId=${courseId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
}
});
return { data, error };
if (!response.ok) {
const error = await response.text();
return {
data: null,
error: { message: error }
};
}
const { success, data, message } = await response.json();
if (!success) {
return {
data: null,
error: { message }
};
}
return { data, error: null };
}
export async function createNewFeed(post: {
@@ -6,61 +6,41 @@ import type { OrganizationPlan } from '$lib/utils/types';
import type { PostgrestError } from '@supabase/supabase-js';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { supabase } from '$lib/utils/functions/supabase';
import { supabase, getAccessToken } from '$lib/utils/functions/supabase';
export async function getOrgTeam(orgId: string) {
const { data, error } = await supabase
.from('organizationmember')
.select(
`
id,
email,
verified,
role_id,
profile(
id,
fullname,
email
)
`
)
.eq('organization_id', orgId)
.neq('role_id', ROLE.STUDENT)
.order('id', { ascending: false })
.returns<
{
id: number;
email: string;
verified: boolean;
role_id: number;
profile: {
id: string;
fullname: string;
email: string;
};
}[]
>();
const accessToken = await getAccessToken();
const team: OrgTeamMember[] = [];
if (data?.length) {
data.forEach((teamMember) => {
team.push({
id: teamMember.id,
email: teamMember?.profile?.email || teamMember.email,
verified: teamMember.verified,
profileId: teamMember?.profile?.id,
fullname: teamMember?.profile?.fullname || '',
role: ROLE_LABEL[teamMember?.role_id] || '',
isAdmin: teamMember?.role_id === ROLE.ADMIN
});
});
const response = await fetch(`/api/org/team?orgId=${orgId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
}
});
orgTeam.set(team);
if (!response.ok) {
const error = await response.text();
return {
team: [],
error: { message: error }
};
}
const { success, team, message } = await response.json();
if (!success) {
return {
team: [],
error: { message }
};
}
orgTeam.set(team);
return {
team: get(orgTeam),
error
error: null
};
}
@@ -146,39 +126,38 @@ export async function getOrganizations(userId: string, isOrgSite?: boolean, orgS
}
export async function getOrgAudience(orgId: string) {
// get all students who are participants in any course belonging to an org
const { data, error } = await supabase
.from('profile')
.select(
`
id,
fullname,
email,
avatar_url,
created_at,
groupmember!inner(
role_id,
group_id:group!inner(
organization_id
)
)
`
)
.eq('groupmember.group.organization_id', orgId)
.eq('groupmember.role_id', ROLE.STUDENT);
const accessToken = await getAccessToken();
const response = await fetch(`/api/org/audience?orgId=${orgId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
}
});
if (!response.ok) {
const error = await response.text();
return {
audience: [],
error: { message: error }
};
}
const { audience, error } = await response.json();
if (error) {
return {
audience: [],
error
};
}
const audience = (data || []).map((profile) => ({
id: profile.id,
name: profile.fullname,
email: profile.email,
avatar_url: profile.avatar_url,
date_joined: new Date(profile.created_at).toDateString()
}));
orgAudience.set(audience);
return {
audience: audience,
error
error: null
};
}
@@ -1,38 +1,39 @@
import { supabase } from '$lib/utils/functions/supabase';
import { supabase, getAccessToken } from '$lib/utils/functions/supabase';
import type { ExerciseSubmissions } from '$lib/utils/types';
export function fetchSubmissionStatus() {
return supabase.from('submissionstatus').select(`*`);
}
export function fetchSubmissions(course_id: string) {
return supabase
.from('submission')
.select(
`
id,
created_at,
answers:question_answer(*),
exercise:exercise_id(
id, title, due_by,
lesson:lesson_id(id, title),
questions:question(
*,
options:option(*),
question_type:question_type_id(id, label)
)
),
status_id,
feedback,
course:course_id(*),
groupmember:submitted_by(
profile(*)
)
`
)
.match({
course_id
});
export async function fetchSubmissions(course_id: string) {
const accessToken = await getAccessToken();
const response = await fetch(`/api/courses/submissions?courseId=${course_id}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
}
});
if (!response.ok) {
const error = await response.text();
return {
data: null,
error: { message: error }
};
}
const { success, data, message } = await response.json();
if (!success) {
return {
data: null,
error: { message }
};
}
return { data, error: null };
}
export async function fetchSubmission({
@@ -44,40 +45,38 @@ export async function fetchSubmission({
courseId?: string;
submittedBy?: string;
}) {
const query: {
exercise_id: string;
course_id?: string;
submitted_by?: string;
} = {
exercise_id: exerciseId
};
const accessToken = await getAccessToken();
if (courseId) {
query.course_id = courseId;
}
if (submittedBy) {
query.submitted_by = submittedBy;
const params = new URLSearchParams({ exerciseId });
if (courseId) params.append('courseId', courseId);
if (submittedBy) params.append('submittedBy', submittedBy);
const response = await fetch(`/api/courses/submission?${params.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
}
});
if (!response.ok) {
const error = await response.text();
return {
data: null,
error: { message: error }
};
}
return supabase
.from('submission')
.select(
`
id,
answers:question_answer(*),
status_id,
feedback,
submitted_by:groupmember!inner(
profile!inner(
id,
fullname,
avatar_url
)
)
`
)
.match(query)
.returns<ExerciseSubmissions[]>();
const { success, data, message } = await response.json();
if (!success) {
return {
data: null,
error: { message }
};
}
return { data, error: null };
}
export async function updateSubmission(