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:
@@ -0,0 +1,123 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
import { STATUS } from '$lib/utils/constants/course';
|
||||
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
|
||||
|
||||
const COURSE_SELECT_QUERY = `
|
||||
id, title, type, description, overview, logo, is_published, version, group_id,
|
||||
slug, cost, currency, metadata, is_certificate_downloadable, certificate_theme,
|
||||
lesson_section(id, title, order, created_at),
|
||||
lessons:lesson(
|
||||
id, title, public, lesson_at, is_unlocked, order, created_at, section_id,
|
||||
note, videos, slide_url, call_url, totalExercises:exercise(count),
|
||||
totalComments:lesson_comment(count), profile:teacher_id(id, avatar_url, fullname),
|
||||
lesson_completion(id, profile_id, is_complete)
|
||||
),
|
||||
attendance:group_attendance(*),
|
||||
polls:apps_poll(status)
|
||||
`;
|
||||
|
||||
const GROUP_MEMBERS_SELECT = `
|
||||
id,
|
||||
members:groupmember(
|
||||
id, role_id, profile_id, email, created_at, assigned_student_id, profile(*)
|
||||
)
|
||||
`;
|
||||
|
||||
async function getCourse(supabase: any, courseId: string) {
|
||||
const { data: course, error } = await supabase
|
||||
.from('course')
|
||||
.select(COURSE_SELECT_QUERY)
|
||||
.eq('id', courseId)
|
||||
.eq('status', STATUS[STATUS.ACTIVE])
|
||||
.single();
|
||||
|
||||
if (error || !course) {
|
||||
throw new Error('Course not found');
|
||||
}
|
||||
|
||||
return course;
|
||||
}
|
||||
|
||||
async function getGroupMembers(
|
||||
supabase: any,
|
||||
groupId: string,
|
||||
isStudent: boolean,
|
||||
userMembership: any
|
||||
) {
|
||||
if (isStudent) {
|
||||
return {
|
||||
id: groupId,
|
||||
members: userMembership ? [userMembership] : []
|
||||
};
|
||||
}
|
||||
|
||||
const { data: group, error } = await supabase
|
||||
.from('group')
|
||||
.select(GROUP_MEMBERS_SELECT)
|
||||
.eq('id', groupId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error fetching group members');
|
||||
}
|
||||
|
||||
return {
|
||||
id: group.id,
|
||||
members: group.members
|
||||
};
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const userId = request.headers.get('user_id');
|
||||
if (!userId) {
|
||||
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { courseId } = await request.json();
|
||||
if (!courseId) {
|
||||
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getServerSupabase();
|
||||
const course = await getCourse(supabase, courseId);
|
||||
|
||||
const { hasAccess, isStudent, userMembership } = await checkUserCoursePermissions(
|
||||
supabase,
|
||||
userId,
|
||||
course.group_id
|
||||
);
|
||||
|
||||
if (!hasAccess) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Access denied. User is not a member of this course.',
|
||||
data: null
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const group = await getGroupMembers(supabase, course.group_id, isStudent, userMembership);
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: { ...course, group }
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
const status = message === 'Course not found' ? 404 : 500;
|
||||
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message,
|
||||
data: null
|
||||
},
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const userId = request.headers.get('user_id');
|
||||
const { courseId } = await request.json();
|
||||
|
||||
if (!courseId) {
|
||||
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return json({ success: false, message: 'User ID is required' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
// Get course group_id for permission check
|
||||
const { data: course } = await supabase
|
||||
.from('course')
|
||||
.select('group_id')
|
||||
.eq('id', courseId)
|
||||
.single();
|
||||
|
||||
if (!course) {
|
||||
return json({ success: false, message: 'Course not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const { hasAccess } = await checkUserCoursePermissions(supabase, userId, course.group_id);
|
||||
|
||||
if (!hasAccess) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Access denied'
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data: exercises, error } = await supabase
|
||||
.rpc('get_exercises')
|
||||
.eq('course_id', courseId);
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error fetching exercises');
|
||||
}
|
||||
|
||||
const formattedExercises = exercises.map((exercise) => ({
|
||||
lesson_id: exercise.lesson_id,
|
||||
exercise_id: exercise.id,
|
||||
exercise_title: exercise.title,
|
||||
points: exercise.points
|
||||
}));
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: formattedExercises
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const userId = request.headers.get('user_id');
|
||||
const { courseId } = await request.json();
|
||||
|
||||
if (!courseId) {
|
||||
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return json({ success: false, message: 'User ID is required' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
// Get course group_id for permission check
|
||||
const { data: course } = await supabase
|
||||
.from('course')
|
||||
.select('group_id')
|
||||
.eq('id', courseId)
|
||||
.single();
|
||||
|
||||
if (!course) {
|
||||
return json({ success: false, message: 'Course not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const { hasAccess } = await checkUserCoursePermissions(supabase, userId, course.group_id);
|
||||
|
||||
if (!hasAccess) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Access denied'
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data: marks, error } = await supabase.rpc('get_marks').eq('course_id', courseId);
|
||||
|
||||
if (error) {
|
||||
console.log('error', error);
|
||||
throw new Error('Error fetching marks');
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: marks
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('error', error);
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
|
||||
|
||||
export const GET: RequestHandler = async ({ request, url }) => {
|
||||
const courseId = url.searchParams.get('courseId');
|
||||
const userId = request.headers.get('user_id');
|
||||
|
||||
if (!userId) {
|
||||
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!courseId) {
|
||||
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
const hasPermission = await checkUserCoursePermissions(supabase, userId, courseId);
|
||||
|
||||
if (!hasPermission) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Access denied. User is not a member of this course or organization.'
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch newsfeeds
|
||||
const { data, error } = 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 });
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error fetching newsfeeds');
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: data || []
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
|
||||
|
||||
export const GET: RequestHandler = async ({ request, url }) => {
|
||||
const exerciseId = url.searchParams.get('exerciseId');
|
||||
const courseId = url.searchParams.get('courseId');
|
||||
const submittedBy = url.searchParams.get('submittedBy');
|
||||
const userId = request.headers.get('user_id');
|
||||
|
||||
if (!userId) {
|
||||
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!exerciseId) {
|
||||
return json({ success: false, message: 'Exercise ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
// If courseId is provided, check permissions
|
||||
if (courseId) {
|
||||
const hasPermission = await checkUserCoursePermissions(supabase, userId, courseId);
|
||||
|
||||
if (!hasPermission) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Access denied. User is not a member of this course or organization.'
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build query object
|
||||
const query: {
|
||||
exercise_id: string;
|
||||
course_id?: string;
|
||||
submitted_by?: string;
|
||||
} = {
|
||||
exercise_id: exerciseId
|
||||
};
|
||||
|
||||
if (courseId) {
|
||||
query.course_id = courseId;
|
||||
}
|
||||
if (submittedBy) {
|
||||
query.submitted_by = submittedBy;
|
||||
}
|
||||
|
||||
// Fetch submission
|
||||
const { data, error } = await supabase
|
||||
.from('submission')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
answers:question_answer(*),
|
||||
status_id,
|
||||
feedback,
|
||||
submitted_by:groupmember!inner(
|
||||
profile!inner(
|
||||
id,
|
||||
fullname,
|
||||
avatar_url
|
||||
)
|
||||
)
|
||||
`
|
||||
)
|
||||
.match(query);
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error fetching submission');
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: data || []
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
|
||||
|
||||
export const GET: RequestHandler = async ({ request, url }) => {
|
||||
const courseId = url.searchParams.get('courseId');
|
||||
const userId = request.headers.get('user_id');
|
||||
|
||||
if (!userId) {
|
||||
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!courseId) {
|
||||
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
const hasPermission = await checkUserCoursePermissions(supabase, userId, courseId);
|
||||
|
||||
if (!hasPermission) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Access denied. User is not a member of this course or organization.'
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch submissions
|
||||
const { data, error } = await 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: courseId
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error fetching submissions');
|
||||
}
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: data || []
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
|
||||
export const GET: RequestHandler = async ({ request, url }) => {
|
||||
const userId = request.headers.get('user_id');
|
||||
const orgId = url.searchParams.get('orgId');
|
||||
|
||||
if (!orgId) {
|
||||
return json({ success: false, message: 'Organization ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return json({ success: false, message: 'User ID is required' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
// Check if user has access to this organization
|
||||
const { data: orgMember } = await supabase
|
||||
.from('organizationmember')
|
||||
.select('role_id')
|
||||
.eq('organization_id', orgId)
|
||||
.eq('profile_id', userId)
|
||||
.in('role_id', [1, 2]) // Admin or Member roles
|
||||
.single();
|
||||
|
||||
if (!orgMember) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Access denied. User is not a member of this organization.'
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 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', 3); // STUDENT role
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error fetching organization audience');
|
||||
}
|
||||
|
||||
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()
|
||||
}));
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
audience: audience
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
import { ROLE, ROLE_LABEL } from '$lib/utils/constants/roles';
|
||||
|
||||
export const GET: RequestHandler = async ({ request, url }) => {
|
||||
const orgId = url.searchParams.get('orgId');
|
||||
const userId = request.headers.get('user_id');
|
||||
|
||||
if (!userId) {
|
||||
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
return json({ success: false, message: 'Organization ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
// Check if user has access to this organization
|
||||
const { data: orgMember } = await supabase
|
||||
.from('organizationmember')
|
||||
.select('role_id')
|
||||
.eq('organization_id', orgId)
|
||||
.eq('profile_id', userId)
|
||||
.in('role_id', [1]) // Admin roles only
|
||||
.single();
|
||||
|
||||
if (!orgMember) {
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Access denied. User is not a member of this organization.'
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch organization team
|
||||
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 });
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error fetching organization team');
|
||||
}
|
||||
|
||||
const team = (data || []).map((teamMember) => ({
|
||||
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
|
||||
}));
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
team: team
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export const load = ({ params = { id: '' } }) => {
|
||||
return {
|
||||
courseId: params.id
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export const load = async ({ params = { id: '' } }) => {
|
||||
return {
|
||||
courseId: params.id
|
||||
};
|
||||
};
|
||||
@@ -117,7 +117,7 @@
|
||||
</select> -->
|
||||
</div>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<p class="hidden w-20 text-lg dark:text-white lg:block" />
|
||||
<p class="hidden w-20 text-lg lg:block dark:text-white" />
|
||||
</RoleBasedSecurity>
|
||||
</div>
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
>{$t('course.navItem.people.action')}</StructuredListCell
|
||||
>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<p class="hidden w-20 text-lg dark:text-white lg:block" />
|
||||
<p class="hidden w-20 text-lg lg:block dark:text-white" />
|
||||
</RoleBasedSecurity>
|
||||
</StructuredListRow>
|
||||
</StructuredListHead>
|
||||
|
||||
Reference in New Issue
Block a user