Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
Replace Pipeline/Stage system with Competition/Round architecture. New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy, ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow. New services: round-engine, round-assignment, deliberation, result-lock, submission-manager, competition-context, ai-prompt-guard. Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with structured prompts, retry logic, and injection detection. All legacy pipeline/stage code removed. 4 new migrations + seed aligned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -37,7 +37,7 @@ export const fileRouter = router({
|
||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
ctx.prisma.assignment.findFirst({
|
||||
where: { userId: ctx.user.id, projectId: file.projectId },
|
||||
select: { id: true, stageId: true },
|
||||
select: { id: true, roundId: true },
|
||||
}),
|
||||
ctx.prisma.mentorAssignment.findFirst({
|
||||
where: { mentorId: ctx.user.id, projectId: file.projectId },
|
||||
@@ -63,25 +63,25 @@ export const fileRouter = router({
|
||||
}
|
||||
|
||||
if (juryAssignment && !mentorAssignment && !teamMembership) {
|
||||
const assignedStage = await ctx.prisma.stage.findUnique({
|
||||
where: { id: juryAssignment.stageId },
|
||||
select: { trackId: true, sortOrder: true },
|
||||
const assignedRound = await ctx.prisma.round.findUnique({
|
||||
where: { id: juryAssignment.roundId },
|
||||
select: { competitionId: true, sortOrder: true },
|
||||
})
|
||||
|
||||
if (assignedStage) {
|
||||
const priorOrCurrentStages = await ctx.prisma.stage.findMany({
|
||||
if (assignedRound) {
|
||||
const priorOrCurrentRounds = await ctx.prisma.round.findMany({
|
||||
where: {
|
||||
trackId: assignedStage.trackId,
|
||||
sortOrder: { lte: assignedStage.sortOrder },
|
||||
competitionId: assignedRound.competitionId,
|
||||
sortOrder: { lte: assignedRound.sortOrder },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
const stageIds = priorOrCurrentStages.map((s) => s.id)
|
||||
const roundIds = priorOrCurrentRounds.map((r) => r.id)
|
||||
|
||||
const hasFileRequirement = await ctx.prisma.fileRequirement.findFirst({
|
||||
where: {
|
||||
stageId: { in: stageIds },
|
||||
roundId: { in: roundIds },
|
||||
files: { some: { bucket: input.bucket, objectKey: input.objectKey } },
|
||||
},
|
||||
select: { id: true },
|
||||
@@ -135,7 +135,7 @@ export const fileRouter = router({
|
||||
fileType: z.enum(['EXEC_SUMMARY', 'PRESENTATION', 'VIDEO', 'OTHER']),
|
||||
mimeType: z.string(),
|
||||
size: z.number().int().positive(),
|
||||
stageId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -150,9 +150,9 @@ export const fileRouter = router({
|
||||
}
|
||||
|
||||
let isLate = false
|
||||
if (input.stageId) {
|
||||
const stage = await ctx.prisma.stage.findUnique({
|
||||
where: { id: input.stageId },
|
||||
if (input.roundId) {
|
||||
const stage = await ctx.prisma.round.findUnique({
|
||||
where: { id: input.roundId },
|
||||
select: { windowCloseAt: true },
|
||||
})
|
||||
|
||||
@@ -191,7 +191,7 @@ export const fileRouter = router({
|
||||
projectId: input.projectId,
|
||||
fileName: input.fileName,
|
||||
fileType: input.fileType,
|
||||
stageId: input.stageId,
|
||||
roundId: input.roundId,
|
||||
isLate,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
@@ -262,7 +262,7 @@ export const fileRouter = router({
|
||||
listByProject: protectedProcedure
|
||||
.input(z.object({
|
||||
projectId: z.string(),
|
||||
stageId: z.string().optional(),
|
||||
roundId: z.string().optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
@@ -298,8 +298,8 @@ export const fileRouter = router({
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = { projectId: input.projectId }
|
||||
if (input.stageId) {
|
||||
where.requirement = { stageId: input.stageId }
|
||||
if (input.roundId) {
|
||||
where.requirement = { roundId: input.roundId }
|
||||
}
|
||||
|
||||
return ctx.prisma.projectFile.findMany({
|
||||
@@ -311,8 +311,8 @@ export const fileRouter = router({
|
||||
name: true,
|
||||
description: true,
|
||||
isRequired: true,
|
||||
stageId: true,
|
||||
stage: { select: { id: true, name: true, sortOrder: true } },
|
||||
roundId: true,
|
||||
round: { select: { id: true, name: true, sortOrder: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -327,7 +327,7 @@ export const fileRouter = router({
|
||||
listByProjectForStage: protectedProcedure
|
||||
.input(z.object({
|
||||
projectId: z.string(),
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
|
||||
@@ -336,7 +336,7 @@ export const fileRouter = router({
|
||||
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
|
||||
ctx.prisma.assignment.findFirst({
|
||||
where: { userId: ctx.user.id, projectId: input.projectId },
|
||||
select: { id: true, stageId: true },
|
||||
select: { id: true, roundId: true },
|
||||
}),
|
||||
ctx.prisma.mentorAssignment.findFirst({
|
||||
where: { mentorId: ctx.user.id, projectId: input.projectId },
|
||||
@@ -362,27 +362,27 @@ export const fileRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
const targetStage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
select: { trackId: true, sortOrder: true },
|
||||
const targetRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { competitionId: true, sortOrder: true },
|
||||
})
|
||||
|
||||
const eligibleStages = await ctx.prisma.stage.findMany({
|
||||
const eligibleRounds = await ctx.prisma.round.findMany({
|
||||
where: {
|
||||
trackId: targetStage.trackId,
|
||||
sortOrder: { lte: targetStage.sortOrder },
|
||||
competitionId: targetRound.competitionId,
|
||||
sortOrder: { lte: targetRound.sortOrder },
|
||||
},
|
||||
select: { id: true, name: true, sortOrder: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
const eligibleStageIds = eligibleStages.map((s) => s.id)
|
||||
const eligibleRoundIds = eligibleRounds.map((r) => r.id)
|
||||
|
||||
const files = await ctx.prisma.projectFile.findMany({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
OR: [
|
||||
{ requirement: { stageId: { in: eligibleStageIds } } },
|
||||
{ requirement: { roundId: { in: eligibleRoundIds } } },
|
||||
{ requirementId: null },
|
||||
],
|
||||
},
|
||||
@@ -393,8 +393,8 @@ export const fileRouter = router({
|
||||
name: true,
|
||||
description: true,
|
||||
isRequired: true,
|
||||
stageId: true,
|
||||
stage: { select: { id: true, name: true, sortOrder: true } },
|
||||
roundId: true,
|
||||
round: { select: { id: true, name: true, sortOrder: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -402,8 +402,8 @@ export const fileRouter = router({
|
||||
})
|
||||
|
||||
const grouped: Array<{
|
||||
stageId: string | null
|
||||
stageName: string
|
||||
roundId: string | null
|
||||
roundName: string
|
||||
sortOrder: number
|
||||
files: typeof files
|
||||
}> = []
|
||||
@@ -411,21 +411,21 @@ export const fileRouter = router({
|
||||
const generalFiles = files.filter((f) => !f.requirementId)
|
||||
if (generalFiles.length > 0) {
|
||||
grouped.push({
|
||||
stageId: null,
|
||||
stageName: 'General',
|
||||
roundId: null,
|
||||
roundName: 'General',
|
||||
sortOrder: -1,
|
||||
files: generalFiles,
|
||||
})
|
||||
}
|
||||
|
||||
for (const stage of eligibleStages) {
|
||||
const stageFiles = files.filter((f) => f.requirement?.stageId === stage.id)
|
||||
if (stageFiles.length > 0) {
|
||||
for (const round of eligibleRounds) {
|
||||
const roundFiles = files.filter((f) => f.requirement?.roundId === round.id)
|
||||
if (roundFiles.length > 0) {
|
||||
grouped.push({
|
||||
stageId: stage.id,
|
||||
stageName: stage.name,
|
||||
sortOrder: stage.sortOrder,
|
||||
files: stageFiles,
|
||||
roundId: round.id,
|
||||
roundName: round.name,
|
||||
sortOrder: round.sortOrder,
|
||||
files: roundFiles,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -696,239 +696,116 @@ export const fileRouter = router({
|
||||
return results
|
||||
}),
|
||||
|
||||
// NOTE: getProjectRequirements procedure removed - depends on deleted Pipeline/Track/Stage models
|
||||
// Will need to be reimplemented with new Competition/Round architecture
|
||||
|
||||
// =========================================================================
|
||||
// FILE REQUIREMENTS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get file requirements for a project from its pipeline's intake stage.
|
||||
* Returns both configJson-based requirements and actual FileRequirement records,
|
||||
* along with which ones are already fulfilled by uploaded files.
|
||||
* Materialize legacy configJson file requirements into FileRequirement rows.
|
||||
* No-op if the stage already has DB-backed requirements.
|
||||
*/
|
||||
getProjectRequirements: adminProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// 1. Get the project and its program
|
||||
const project = await ctx.prisma.project.findUniqueOrThrow({
|
||||
where: { id: input.projectId },
|
||||
select: { programId: true },
|
||||
})
|
||||
|
||||
// 2. Find the pipeline for this program
|
||||
const pipeline = await ctx.prisma.pipeline.findFirst({
|
||||
where: { programId: project.programId },
|
||||
include: {
|
||||
tracks: {
|
||||
where: { kind: 'MAIN' },
|
||||
include: {
|
||||
stages: {
|
||||
where: { stageType: 'INTAKE' },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
materializeRequirementsFromConfig: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: {
|
||||
id: true,
|
||||
roundType: true,
|
||||
configJson: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!pipeline) return null
|
||||
if (stage.roundType !== 'INTAKE') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Requirements can only be materialized for INTAKE stages',
|
||||
})
|
||||
}
|
||||
|
||||
const mainTrack = pipeline.tracks[0]
|
||||
if (!mainTrack) return null
|
||||
|
||||
const intakeStage = mainTrack.stages[0]
|
||||
if (!intakeStage) return null
|
||||
|
||||
// 3. Check for actual FileRequirement records first
|
||||
const dbRequirements = await ctx.prisma.fileRequirement.findMany({
|
||||
where: { stageId: intakeStage.id },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
include: {
|
||||
files: {
|
||||
where: { projectId: input.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileType: true,
|
||||
mimeType: true,
|
||||
size: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
const existingCount = await ctx.prisma.fileRequirement.count({
|
||||
where: { roundId: input.roundId },
|
||||
})
|
||||
if (existingCount > 0) {
|
||||
return { created: 0, skipped: true, reason: 'already_materialized' as const }
|
||||
}
|
||||
|
||||
// 4. If we have DB requirements, return those (they're the canonical source)
|
||||
if (dbRequirements.length > 0) {
|
||||
return {
|
||||
stageId: intakeStage.id,
|
||||
requirements: dbRequirements.map((req) => ({
|
||||
id: req.id,
|
||||
name: req.name,
|
||||
description: req.description,
|
||||
acceptedMimeTypes: req.acceptedMimeTypes,
|
||||
maxSizeMB: req.maxSizeMB,
|
||||
isRequired: req.isRequired,
|
||||
fulfilled: req.files.length > 0,
|
||||
fulfilledFile: req.files[0] ?? null,
|
||||
})),
|
||||
const config = (stage.configJson as Record<string, unknown> | null) ?? {}
|
||||
const configRequirements = Array.isArray(config.fileRequirements)
|
||||
? (config.fileRequirements as Array<Record<string, unknown>>)
|
||||
: []
|
||||
|
||||
if (configRequirements.length === 0) {
|
||||
return { created: 0, skipped: true, reason: 'no_config_requirements' as const }
|
||||
}
|
||||
|
||||
const mapLegacyMimeType = (type: unknown): string[] => {
|
||||
switch (String(type ?? '').toUpperCase()) {
|
||||
case 'PDF':
|
||||
return ['application/pdf']
|
||||
case 'VIDEO':
|
||||
return ['video/*']
|
||||
case 'IMAGE':
|
||||
return ['image/*']
|
||||
case 'DOC':
|
||||
case 'DOCX':
|
||||
return ['application/vnd.openxmlformats-officedocument.wordprocessingml.document']
|
||||
case 'PPT':
|
||||
case 'PPTX':
|
||||
return ['application/vnd.openxmlformats-officedocument.presentationml.presentation']
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Fall back to configJson requirements
|
||||
const configJson = intakeStage.configJson as Record<string, unknown> | null
|
||||
const fileRequirements = (configJson?.fileRequirements as Array<{
|
||||
name: string
|
||||
description?: string
|
||||
acceptedMimeTypes?: string[]
|
||||
maxSizeMB?: number
|
||||
isRequired?: boolean
|
||||
type?: string
|
||||
required?: boolean
|
||||
}>) ?? []
|
||||
let created = 0
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
for (let i = 0; i < configRequirements.length; i++) {
|
||||
const raw = configRequirements[i]
|
||||
const name = typeof raw.name === 'string' ? raw.name.trim() : ''
|
||||
if (!name) continue
|
||||
|
||||
if (fileRequirements.length === 0) return null
|
||||
const acceptedMimeTypes = Array.isArray(raw.acceptedMimeTypes)
|
||||
? raw.acceptedMimeTypes.filter((v): v is string => typeof v === 'string')
|
||||
: mapLegacyMimeType(raw.type)
|
||||
|
||||
// 6. Get project files to check fulfillment
|
||||
const projectFiles = await ctx.prisma.projectFile.findMany({
|
||||
where: { projectId: input.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
fileName: true,
|
||||
fileType: true,
|
||||
mimeType: true,
|
||||
size: true,
|
||||
createdAt: true,
|
||||
},
|
||||
await tx.fileRequirement.create({
|
||||
data: {
|
||||
roundId: input.roundId,
|
||||
name,
|
||||
description:
|
||||
typeof raw.description === 'string' && raw.description.trim().length > 0
|
||||
? raw.description.trim()
|
||||
: undefined,
|
||||
acceptedMimeTypes,
|
||||
maxSizeMB:
|
||||
typeof raw.maxSizeMB === 'number' && Number.isFinite(raw.maxSizeMB)
|
||||
? Math.trunc(raw.maxSizeMB)
|
||||
: undefined,
|
||||
isRequired:
|
||||
(raw.isRequired as boolean | undefined) ??
|
||||
((raw.required as boolean | undefined) ?? false),
|
||||
sortOrder: i,
|
||||
},
|
||||
})
|
||||
created++
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
stageId: intakeStage.id,
|
||||
requirements: fileRequirements.map((req) => {
|
||||
const reqName = req.name.toLowerCase()
|
||||
// Match by checking if any uploaded file's fileName contains the requirement name
|
||||
const matchingFile = projectFiles.find((f) =>
|
||||
f.fileName.toLowerCase().includes(reqName) ||
|
||||
reqName.includes(f.fileName.toLowerCase().replace(/\.[^.]+$/, ''))
|
||||
)
|
||||
|
||||
return {
|
||||
id: null as string | null,
|
||||
name: req.name,
|
||||
description: req.description ?? null,
|
||||
acceptedMimeTypes: req.acceptedMimeTypes ?? [],
|
||||
maxSizeMB: req.maxSizeMB ?? null,
|
||||
// Handle both formats: isRequired (wizard type) and required (seed data)
|
||||
isRequired: req.isRequired ?? req.required ?? false,
|
||||
fulfilled: !!matchingFile,
|
||||
fulfilledFile: matchingFile ?? null,
|
||||
}
|
||||
}),
|
||||
}
|
||||
return { created, skipped: false as const }
|
||||
}),
|
||||
|
||||
// =========================================================================
|
||||
// FILE REQUIREMENTS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Materialize legacy configJson file requirements into FileRequirement rows.
|
||||
* No-op if the stage already has DB-backed requirements.
|
||||
*/
|
||||
materializeRequirementsFromConfig: adminProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const stage = await ctx.prisma.stage.findUniqueOrThrow({
|
||||
where: { id: input.stageId },
|
||||
select: {
|
||||
id: true,
|
||||
stageType: true,
|
||||
configJson: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (stage.stageType !== 'INTAKE') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Requirements can only be materialized for INTAKE stages',
|
||||
})
|
||||
}
|
||||
|
||||
const existingCount = await ctx.prisma.fileRequirement.count({
|
||||
where: { stageId: input.stageId },
|
||||
})
|
||||
if (existingCount > 0) {
|
||||
return { created: 0, skipped: true, reason: 'already_materialized' as const }
|
||||
}
|
||||
|
||||
const config = (stage.configJson as Record<string, unknown> | null) ?? {}
|
||||
const configRequirements = Array.isArray(config.fileRequirements)
|
||||
? (config.fileRequirements as Array<Record<string, unknown>>)
|
||||
: []
|
||||
|
||||
if (configRequirements.length === 0) {
|
||||
return { created: 0, skipped: true, reason: 'no_config_requirements' as const }
|
||||
}
|
||||
|
||||
const mapLegacyMimeType = (type: unknown): string[] => {
|
||||
switch (String(type ?? '').toUpperCase()) {
|
||||
case 'PDF':
|
||||
return ['application/pdf']
|
||||
case 'VIDEO':
|
||||
return ['video/*']
|
||||
case 'IMAGE':
|
||||
return ['image/*']
|
||||
case 'DOC':
|
||||
case 'DOCX':
|
||||
return ['application/vnd.openxmlformats-officedocument.wordprocessingml.document']
|
||||
case 'PPT':
|
||||
case 'PPTX':
|
||||
return ['application/vnd.openxmlformats-officedocument.presentationml.presentation']
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
let created = 0
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
for (let i = 0; i < configRequirements.length; i++) {
|
||||
const raw = configRequirements[i]
|
||||
const name = typeof raw.name === 'string' ? raw.name.trim() : ''
|
||||
if (!name) continue
|
||||
|
||||
const acceptedMimeTypes = Array.isArray(raw.acceptedMimeTypes)
|
||||
? raw.acceptedMimeTypes.filter((v): v is string => typeof v === 'string')
|
||||
: mapLegacyMimeType(raw.type)
|
||||
|
||||
await tx.fileRequirement.create({
|
||||
data: {
|
||||
stageId: input.stageId,
|
||||
name,
|
||||
description:
|
||||
typeof raw.description === 'string' && raw.description.trim().length > 0
|
||||
? raw.description.trim()
|
||||
: undefined,
|
||||
acceptedMimeTypes,
|
||||
maxSizeMB:
|
||||
typeof raw.maxSizeMB === 'number' && Number.isFinite(raw.maxSizeMB)
|
||||
? Math.trunc(raw.maxSizeMB)
|
||||
: undefined,
|
||||
isRequired:
|
||||
(raw.isRequired as boolean | undefined) ??
|
||||
((raw.required as boolean | undefined) ?? false),
|
||||
sortOrder: i,
|
||||
},
|
||||
})
|
||||
created++
|
||||
}
|
||||
})
|
||||
|
||||
return { created, skipped: false as const }
|
||||
}),
|
||||
|
||||
/**
|
||||
* List file requirements for a stage (available to any authenticated user)
|
||||
*/
|
||||
/**
|
||||
* List file requirements for a stage (available to any authenticated user)
|
||||
*/
|
||||
listRequirements: protectedProcedure
|
||||
.input(z.object({ stageId: z.string() }))
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.fileRequirement.findMany({
|
||||
where: { stageId: input.stageId },
|
||||
where: { roundId: input.roundId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
}),
|
||||
@@ -939,7 +816,7 @@ export const fileRouter = router({
|
||||
createRequirement: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(1000).optional(),
|
||||
acceptedMimeTypes: z.array(z.string()).default([]),
|
||||
@@ -960,7 +837,7 @@ export const fileRouter = router({
|
||||
action: 'CREATE',
|
||||
entityType: 'FileRequirement',
|
||||
entityId: requirement.id,
|
||||
detailsJson: { name: input.name, stageId: input.stageId },
|
||||
detailsJson: { name: input.name, roundId: input.roundId },
|
||||
})
|
||||
} catch {}
|
||||
|
||||
@@ -1032,7 +909,7 @@ export const fileRouter = router({
|
||||
reorderRequirements: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
stageId: z.string(),
|
||||
roundId: z.string(),
|
||||
orderedIds: z.array(z.string()),
|
||||
})
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user