Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ node_modules
build
tmp

# Test storage (used for local drive in tests)
tests/storage

# Secrets
.env
.env.local
Expand Down
194 changes: 194 additions & 0 deletions app/controllers/hackathons_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ import { createJuryMemberValidator, updateJuryMemberValidator } from '#validator
import { updateHackathonTaskValidator } from '#validators/task'
import type { HttpContext } from '@adonisjs/core/http'
import { ApiOperation, ApiRequest, ApiResponse } from '#openapi/decorators'
import { createHackathonSubmissionValidator, updateHackathonSubmissionValidator } from '#validators/hackathon_submission'
import HackathonTaskSubmission from '#models/hackathon/hackathon_task_submission'
import TaskRegistration from '#models/task/task_registration'
import TeamPolicy from '#policies/team_policy'
import { DateTime } from 'luxon'
import { randomUUID } from 'node:crypto'
import Media from '#models/media'
import drive from '@adonisjs/drive/services/main'
import Team from '#models/team/team'

export default class HackathonsController {
@ApiOperation({ description: 'Get a specific hackathon task by its ID or slug' })
Expand Down Expand Up @@ -172,4 +181,189 @@ export default class HackathonsController {
await juryMember.delete()
return response.noContent()
}

@ApiOperation({ description: 'Create a new submission for a hackathon task' })
@ApiRequest({ validator: createHackathonSubmissionValidator, withResponse: true })
@ApiResponse(201, { description: 'The newly created hackathon submission', data: HackathonTaskSubmission })
@ApiResponse(400, { description: 'Submissions are closed or invalid request or already exists' })
@ApiResponse(403, { description: 'Missing permission to submit for this task' })
@ApiResponse(404, { description: 'Task registration not found' })
async storeHackathonSubmission({ bouncer, request, response }: HttpContext) {
const payload = await request.validateUsing(createHackathonSubmissionValidator)
const taskRegistration = await TaskRegistration.query()
.where('id', payload.taskRegistrationId)
.preload('team', (q) => q.preload('members'))
.firstOrFail()

await bouncer.with(TeamPolicy).authorize('manageSubmissions', taskRegistration.team)

const task = await Task.findByUuidOrSlug(taskRegistration.taskId)

const now = DateTime.now()

if (task.submissionsEndAt && task.submissionsEndAt < now)
return response.badRequest({ message: 'Submissions are closed for this task' })

if (!task.submissionsStartAt || task.submissionsStartAt > now)
return response.badRequest({ message: 'Submissions are not open yet for this task' })

const existingSubmission = await HackathonTaskSubmission.query()
.where('task_registration_id', taskRegistration.id)
.first()

if (existingSubmission)
return response.badRequest({ message: 'A submission already exists for this task registration' })

const submission = await HackathonTaskSubmission.create({
taskRegistrationId: taskRegistration.id,
description: payload.description,
repositoryUrl: payload.repositoryUrl,
demoUrl: payload.demoUrl,
status: payload.status,
})

if (payload.media && payload.media.length > 0)
for (const [index, mediaItem] of payload.media.entries()) {
let mediaUrl: string = ''

if (mediaItem.file) {
if (mediaItem.mediaType === 'VIDEO')
return response.badRequest({ message: 'You cannot upload video files, please use a URL instead' })

const filename = `${randomUUID()}.${mediaItem.file.extname}`
const path = `hackathon-submissions/${submission.id}/${filename}`
await mediaItem.file.moveToDisk(path)
mediaUrl = await drive.use().getUrl(path)
} else if (mediaItem.url)
mediaUrl = mediaItem.url

await Media.create({
relatedId: submission.id,
description: mediaItem.description ?? '',
mediaType: mediaItem.mediaType ?? 'IMAGE',
url: mediaUrl,
galleryIndex: index,
})
}

await submission.load('media')

return response.created(submission)
}

@ApiOperation({ description: 'Get a list of submissions for a hackathon event' })
@ApiResponse(200, { description: 'A list of hackathon submissions', data: [HackathonTaskSubmission] })
@ApiResponse(401, { description: 'Authentication required' })
async indexHackathonSubmissionsByUser({ auth }: HttpContext) {
const user = await auth.getUserOrFail()

return HackathonTaskSubmission.query()
.whereHas('taskRegistration', (q) => {
q.whereHas('team', (r) => {
r.whereHas('members', (s) => {
s.where('user_id', user.id)
})
})
})
.preload('taskRegistration', (q) => q.preload('team').preload('task'))
.preload('media')
}

@ApiOperation({ description: 'Get a list of submissions for a hackathon event' })
@ApiResponse(200, { description: 'A list of hackathon submissions', data: [HackathonTaskSubmission] })
@ApiResponse(403, { description: 'Missing permission to view submissions for this team' })
@ApiResponse(404, { description: 'Team not found' })
async indexHackathonSubmissionsByTeam({ bouncer, params }: HttpContext) {
const team = await Team.findOrFail(params.teamId)

await bouncer.with(TeamPolicy).authorize('viewSubmissions', team)

return HackathonTaskSubmission.query()
.whereHas('taskRegistration', (q) => {
q.where('team_id', team.id)
})
.preload('taskRegistration', (q) => q.preload('team').preload('task'))
.preload('media')
}

@ApiOperation({ description: 'Get a list of submissions for a hackathon event' })
@ApiResponse(200, { description: 'A list of hackathon submissions', data: [HackathonTaskSubmission] })
@ApiResponse(403, { description: 'Missing permission to view submissions for this task' })
@ApiResponse(404, { description: 'Task not found' })
async indexHackathonSubmissionsByTask({ bouncer, params }: HttpContext) {
const task = await Task.findByUuidOrSlug(params.taskId)
const event = await task.related('event').query().firstOrFail()

await bouncer.with(EventPolicy).authorize('manageSubmissions', event)

return HackathonTaskSubmission.query()
.whereHas('taskRegistration', (q) => {
q.where('task_id', task.id)
})
.preload('taskRegistration', (q) => q.preload('team').preload('task'))
.preload('media')
}

@ApiOperation({ description: 'Get a specific hackathon submission by ID' })
@ApiResponse(200, { description: 'The requested hackathon submission', data: HackathonTaskSubmission })
@ApiResponse(403, { description: 'Missing permission to view this submission' })
@ApiResponse(404, { description: 'Hackathon submission not found' })
async showHackathonSubmission({ bouncer, params }: HttpContext) {
const submission = await HackathonTaskSubmission.query()
.where('id', params.id)
.preload('taskRegistration', (q) => q.preload('team', (r) => r.preload('members')))
.preload('media')
.firstOrFail()

await bouncer.with(TeamPolicy).authorize('viewSubmissions', submission.taskRegistration.team)

return submission
}

@ApiOperation({ description: 'Update a hackathon submission by ID' })
@ApiRequest({ validator: updateHackathonSubmissionValidator, withResponse: true })
@ApiResponse(200, { description: 'The updated hackathon submission', data: HackathonTaskSubmission })
@ApiResponse(400, { description: 'Invalid request or submission status transition not allowed' })
@ApiResponse(403, { description: 'Missing permission to manage this submission' })
@ApiResponse(404, { description: 'Hackathon submission not found' })
async updateHackathonSubmission({ bouncer, response, params, request }: HttpContext) {
const submission = await HackathonTaskSubmission.query()
.where('id', params.id)
.preload('taskRegistration', (q) => q.preload('team', (r) => r.preload('members')))
.firstOrFail()

await bouncer.with(TeamPolicy).authorize('manageSubmissions', submission.taskRegistration.team)

const payload = await request.validateUsing(updateHackathonSubmissionValidator)

if (submission.status === 'ARCHIVED')
return response.badRequest({ message: 'Cannot update an archived submission' })

if (submission.status === 'ACTIVE' && payload.status === 'DRAFT')
return response.badRequest({ message: 'Cannot change status of an active submission to draft' })

submission.merge(payload)
await submission.save()
return submission
}

@ApiOperation({ description: 'Delete a hackathon submission by ID' })
@ApiResponse(204, { description: 'Hackathon submission deleted successfully' })
@ApiResponse(400, { description: 'Cannot delete an archived submission' })
@ApiResponse(403, { description: 'Missing permission to manage this submission' })
@ApiResponse(404, { description: 'Hackathon submission not found' })
async destroyHackathonSubmission({ bouncer, response, params }: HttpContext) {
const submission = await HackathonTaskSubmission.query()
.where('id', params.id)
.preload('taskRegistration', (q) => q.preload('team', (r) => r.preload('members')))
.firstOrFail()

await bouncer.with(TeamPolicy).authorize('manageSubmissions', submission.taskRegistration.team)

if (submission.status === 'ARCHIVED')
return response.badRequest({ message: 'Cannot delete an archived submission' })

await submission.delete()
return response.noContent()
}
}
12 changes: 10 additions & 2 deletions app/models/hackathon/hackathon_task_submission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@

import { randomUUID } from 'node:crypto'
import { DateTime } from 'luxon'
import { BaseModel, beforeCreate, belongsTo, column, hasOne } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasOne } from '@adonisjs/lucid/types/relations'
import { BaseModel, beforeCreate, belongsTo, column, hasMany, hasOne } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany, HasOne } from '@adonisjs/lucid/types/relations'
import TaskRegistration from '#models/task/task_registration'
import HackathonSubmissionResult from '#models/hackathon/hackathon_submission_result'
import { ApiColumn } from '#openapi/decorators'
import Media from '#models/media'

export default class HackathonTaskSubmission extends BaseModel {
@column({ isPrimary: true })
Expand All @@ -46,6 +47,10 @@ export default class HackathonTaskSubmission extends BaseModel {
@ApiColumn(String, { required: false, example: 'https://github.com/user/repo' })
declare repositoryUrl: string | null

@column()
@ApiColumn(String, { required: false, example: 'https://example.com/demo' })
declare demoUrl: string | null

@column()
@ApiColumn(String, { example: 'ACTIVE' })
declare status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED'
Expand All @@ -58,6 +63,9 @@ export default class HackathonTaskSubmission extends BaseModel {
@ApiColumn(String, { format: 'date-time' })
declare updatedAt: DateTime | null

@hasMany(() => Media, { foreignKey: 'relatedId' })
declare media: HasMany<typeof Media>

@belongsTo(() => TaskRegistration)
declare taskRegistration: BelongsTo<typeof TaskRegistration>

Expand Down
11 changes: 11 additions & 0 deletions app/policies/event_policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,15 @@ export default class EventPolicy extends BasePolicy {

return EventAdminGuard.can(eventAdmin, 'MANAGE_JURY_MEMBERS')
}

async manageSubmissions(user: User, event: Event): Promise<AuthorizerResponse> {
if (UserGuard.can(user, 'MANAGE_ALL_EVENTS'))
return true

const eventAdmin = await event.related('administrators').query().where('user_id', user.id).first()
if (!eventAdmin)
return false

return EventAdminGuard.can(eventAdmin, 'MANAGE_SUBMISSIONS')
}
}
32 changes: 32 additions & 0 deletions app/policies/team_policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,38 @@ export default class TeamPolicy extends BasePolicy {
return TeamMemberGuard.can(member, 'REGISTER_TASK')
}

async manageSubmissions(user: User, team: Team): Promise<AuthorizerResponse> {
if (UserGuard.can(user, 'MANAGE_ALL_TASKS'))
return true

const member = await team
.related('members')
.query()
.where('user_id', user.id)
.first()

if (!member)
return false

return TeamMemberGuard.can(member, 'MANAGE_TASK_SUBMISSIONS')
}

async viewSubmissions(user: User, team: Team): Promise<AuthorizerResponse> {
if (UserGuard.can(user, 'MANAGE_ALL_TASKS'))
return true

const member = await team
.related('members')
.query()
.where('user_id', user.id)
.first()

if (!member)
return false

return TeamMemberGuard.can(member, 'VIEW_TASK_SUBMISSIONS')
}

async kick(user: User, team: Team, targetMemberId: string): Promise<AuthorizerResponse> {
const member = await TeamMember.findOrFail(targetMemberId)
if (TeamMemberGuard.can(member, 'IS_OWNER'))
Expand Down
6 changes: 4 additions & 2 deletions app/utils/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ export const UserGuard = PermissionsGuard(UserPermissions)
export enum TeamMemberPermissions {
MANAGE_MEMBERS = 1 << 0, // Whether a member can invite, remove and change other member permissions
REGISTER_TASK = 1 << 1, // Whether a member can register the team for tasks
SUBMIT_TASK = 1 << 2, // Whether member can submit tasks
MANAGE_TEAM = 1 << 3, // Whether a user can edit and/or delete the team
MANAGE_TASK_SUBMISSIONS = 1 << 2, // Whether a member can view and manage task submissions (submit, update, delete)
VIEW_TASK_SUBMISSIONS = 1 << 3, // Whether a member can view task submissions
MANAGE_TEAM = 1 << 4, // Whether a user can edit and/or delete the team
IS_OWNER = 1 << 30, // Whether a user is a team owner (leftmost bit for signed 32-bit integer)
}

Expand All @@ -79,5 +80,6 @@ export enum EventAdminPermissions {
MANAGE_SPONSORS = 1 << 6, // Whether a user can manage sponsors for the event
MANAGE_JURY_MEMBERS = 1 << 7, // Whether a user can manage jury members for the events tasks
EDIT_TASK = 1 << 8, // Whether a user can edit tasks (except for those with MANAGE_ALL_TASKS)
MANAGE_SUBMISSIONS = 1 << 9, // Whether a user can manage (view/update/delete) all submissions for the event's tasks
}
export const EventAdminGuard = PermissionsGuard(EventAdminPermissions)
61 changes: 61 additions & 0 deletions app/validators/hackathon_submission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* ______ __ __
* _ __/ ____/___ ____ / /____ _____/ /_
* | |/_/ / / __ \/ __ \/ __/ _ \/ ___/ __/
* _> </ /___/ /_/ / / / / /_/ __(__ ) /_
* /_/|_|\____/\____/_/ /_/\__/\___/____/\__/
* Copyright (C) 2026 xContest Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/

import vine from '@vinejs/vine'

const mediaItemSchema = vine.object({
description: vine.string().trim().escape().maxLength(500).optional(),
mediaType: vine.enum(['IMAGE', 'VIDEO', 'DOCUMENT', 'LINK']),
url: vine.string().url().maxLength(2048).optional(),
file: vine.file({
size: '20mb',
extnames: ['png', 'jpg', 'jpeg', 'gif', 'pdf', 'doc', 'docx'],
}).optional(),
})

const hackathonSubmissionSchema = {
description: vine.string().trim().escape().maxLength(5000).optional(),
repositoryUrl: vine.string().url().maxLength(500).optional(),
demoUrl: vine.string().url().maxLength(500).optional(),
status: vine.enum(['DRAFT', 'ACTIVE', 'ARCHIVED'] as const),
media: vine.array(mediaItemSchema).optional(),
}

/**
* Validator to validate the payload when creating
* a new hackathon submission.
*/
export const createHackathonSubmissionValidator = vine.create(
vine.object({
...hackathonSubmissionSchema,
taskRegistrationId: vine.string().uuid(),
}),
)

/**
* Validator to validate the payload when updating
* an existing hackathon submission.
*/
export const updateHackathonSubmissionValidator = vine.create(
vine.object(hackathonSubmissionSchema),
)
Loading