From 23c5ccd587feaf01d6bc0deededb473ff450aedf Mon Sep 17 00:00:00 2001 From: Oliwier Michalik Date: Tue, 10 Mar 2026 15:39:49 +0100 Subject: [PATCH] feat: implement password reset feature with emails --- .env.example | 2 +- app/controllers/auth_controller.ts | 65 +++++++- app/models/user.ts | 6 + app/validators/auth.ts | 50 +++--- .../1771616940946_create_users_table.ts | 3 + docs/spec/openapi.json | 2 +- resources/views/events/invite.edge | 10 +- resources/views/user/password_reset.edge | 146 ++++++++++++++++++ start/mail.ts | 13 +- start/routes.ts | 3 + tests/functional/auth/login.spec.ts | 69 ++++++++- workers/mail.ts | 17 +- 12 files changed, 339 insertions(+), 47 deletions(-) create mode 100644 resources/views/user/password_reset.edge diff --git a/.env.example b/.env.example index 1584004..6126564 100644 --- a/.env.example +++ b/.env.example @@ -31,4 +31,4 @@ SMTP_USERNAME=noreply@local.host SMTP_PASSWORD=localpassword REDIS_HOST=redis REDIS_PORT=6379 -REDIS_PASSWORD=thisisaverysecurepassword \ No newline at end of file +REDIS_PASSWORD=thisisaverysecurepassword diff --git a/app/controllers/auth_controller.ts b/app/controllers/auth_controller.ts index e9f635c..65c2621 100644 --- a/app/controllers/auth_controller.ts +++ b/app/controllers/auth_controller.ts @@ -23,13 +23,23 @@ import User from '#models/user' import { UserGuard } from '#utils/permissions' -import { loginValidator, providerParamValidator, registerValidator } from '#validators/auth' +import { + forgotPasswordValidator, + loginValidator, + providerParamValidator, + registerValidator, + resetPasswordValidator, +} from '#validators/auth' import type { HttpContext } from '@adonisjs/core/http' import { ApiOperation, ApiRequest, ApiResponse, } from '#openapi/decorators' +import { generateSecureToken } from '#utils/teams' +import { DateTime } from 'luxon' +import mail from '@adonisjs/mail/services/main' +import env from '#start/env' export default class AuthController { @ApiOperation({ description: 'Redirects to the social provider for authentication' }) @@ -123,4 +133,57 @@ export default class AuthController { await auth.use('web').logout() return response.redirect('/') } + + @ApiOperation({ description: 'Send password reset request for a user' }) + @ApiRequest({ validator: forgotPasswordValidator, withResponse: true }) + @ApiResponse(200, { description: 'Password reset email sent' }) + @ApiResponse(404, { description: 'User not found or uses social login' }) + public async forgotPassword({ request, response }: HttpContext) { + const { email } = await request.validateUsing(forgotPasswordValidator) + const user = await User.findBy('email', email) + + if (!user) + return response.notFound({ message: 'User not found' }) + + user.passwordResetToken = generateSecureToken() + user.passwordResetExpires = DateTime.now().plus({ minutes: 15 }) + await user.save() + + await mail.sendLater((message) => { + message + .to(user.email) + .subject('Password Reset Request') + .htmlView('user/password_reset', { + user: { name: user.nickname }, + resetLink: `${env.get('WEBSITE')}reset-password?token=${user.passwordResetToken}`, + linkExpiryTime: user.passwordResetExpires!.toLocal().toLocaleString(DateTime.DATETIME_MED), + }) + }) + + return response.ok({ message: 'Password reset email sent' }) + } + + @ApiOperation({ description: 'Resets user password using a reset token' }) + @ApiRequest({ validator: resetPasswordValidator, withResponse: true }) + @ApiResponse(200, { description: 'Password reset successful' }) + @ApiResponse(400, { description: 'Invalid or expired token' }) + public async resetPassword({ request, response }: HttpContext) { + const { qs, newPassword } = await request.validateUsing(resetPasswordValidator, { + data: { + ...request.body(), + qs: request.qs(), + }, + }) + + const user = await User.findBy('passwordResetToken', qs.token) + if (!user || user.passwordResetExpires!.diffNow().as('milliseconds') < 0) + return response.badRequest({ message: 'Invalid or expired token' }) + + user.password = newPassword + user.passwordResetToken = null + user.passwordResetExpires = null + await user.save() + + return response.ok({ message: 'Password reset successful! You can now login.' }) + } } diff --git a/app/models/user.ts b/app/models/user.ts index 8da7c8c..2c0b0bd 100644 --- a/app/models/user.ts +++ b/app/models/user.ts @@ -66,6 +66,12 @@ export default class User extends compose(BaseModel, AuthFinder) { @column({ serializeAs: null }) declare password: string | null + @column({ serializeAs: null }) + declare passwordResetToken: string | null + + @column.dateTime({ serializeAs: null }) + declare passwordResetExpires: DateTime | null + @column.dateTime({ autoCreate: true }) @ApiColumn(String, { format: 'date-time' }) declare createdAt: DateTime diff --git a/app/validators/auth.ts b/app/validators/auth.ts index 863e9e9..d1e270d 100644 --- a/app/validators/auth.ts +++ b/app/validators/auth.ts @@ -23,30 +23,36 @@ import vine from '@vinejs/vine' -export const providerParamValidator = vine.create( - vine.object({ - params: vine.object({ - provider: vine.enum(['discord', 'github']), - }), +export const providerParamValidator = vine.create({ + params: vine.object({ + provider: vine.enum(['discord', 'github']), }), -) +}) -export const registerValidator = vine.create( - vine.object({ - nickname: vine.string().trim().minLength(3).maxLength(16), - name: vine.string().trim().minLength(3).maxLength(32).optional(), - surname: vine.string().trim().minLength(3).maxLength(32).optional(), - email: vine.string().email().trim().unique(async (db, value) => { - const user = await db.from('users').where('email', value).first() - return !user - }), - password: vine.string().minLength(8).confirmed(), +export const registerValidator = vine.create({ + nickname: vine.string().trim().minLength(3).maxLength(16), + name: vine.string().trim().minLength(3).maxLength(32).optional(), + surname: vine.string().trim().minLength(3).maxLength(32).optional(), + email: vine.string().email().trim().unique(async (db, value) => { + const user = await db.from('users').where('email', value).first() + return !user }), -) + password: vine.string().minLength(8).confirmed(), +}) -export const loginValidator = vine.create( - vine.object({ - email: vine.string().email().trim(), - password: vine.string(), +export const loginValidator = vine.create({ + email: vine.string().email().trim(), + password: vine.string(), +}) + +export const forgotPasswordValidator = vine.create({ + email: vine.string().email().trim(), +}) + +export const resetPasswordValidator = vine.create({ + qs: vine.object({ + token: vine.string().trim(), }), -) + newPassword: vine.string().confirmed({ as: 'newPasswordConfirm' }), + newPasswordConfirm: vine.string(), +}) diff --git a/database/migrations/1771616940946_create_users_table.ts b/database/migrations/1771616940946_create_users_table.ts index b05a834..168af59 100644 --- a/database/migrations/1771616940946_create_users_table.ts +++ b/database/migrations/1771616940946_create_users_table.ts @@ -37,6 +37,9 @@ export default class extends BaseSchema { table.integer('permissions').notNullable() table.string('password').nullable() + table.string('password_reset_token').nullable() + table.dateTime('password_reset_expires').nullable() + table.timestamp('created_at').notNullable() table.timestamp('updated_at').nullable() }) diff --git a/docs/spec/openapi.json b/docs/spec/openapi.json index a0dc53f..647331b 100644 --- a/docs/spec/openapi.json +++ b/docs/spec/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"xContest REST API","description":"Specification document for REST API component of xContest platform","version":"1.0.0"},"paths":{"/auth/{provider}/redirect":{"get":{"operationId":"AuthController.redirect","tags":["Auth"],"description":"Redirects to the social provider for authentication","responses":{"307":{"description":"Redirects to the social provider"}},"parameters":[{"name":"provider","in":"path","required":true,"schema":{"enum":["discord","github"]}}]}},"/auth/{provider}/callback":{"get":{"operationId":"AuthController.callback","tags":["Auth"],"description":"Handles the callback from the social provider","responses":{"307":{"description":"Redirects to the status page"}},"parameters":[{"name":"provider","in":"path","required":true,"schema":{"enum":["discord","github"]}}]}},"/auth/register":{"post":{"operationId":"AuthController.register","tags":["Auth"],"description":"Registers a new user with email and password","responses":{"201":{"description":"User registered successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}}}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"nickname":{"type":"string","minLength":3,"maxLength":16},"name":{"type":"string","minLength":3,"maxLength":32},"surname":{"type":"string","minLength":3,"maxLength":32},"email":{"type":"string","format":"email"},"password":{"type":"string","minLength":8}},"required":["nickname","email","password"],"additionalProperties":false}}}}}},"/auth/login":{"post":{"operationId":"AuthController.login","tags":["Auth"],"description":"Logs in a user with email and password","responses":{"200":{"description":"User logged in successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}}}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email"},"password":{"type":"string"}},"required":["email","password"],"additionalProperties":false}}}}}},"/auth/logout":{"post":{"operationId":"AuthController.logout","tags":["Auth"],"description":"Logs out the current user","responses":{"200":{"description":"User logged out successfully"}},"security":[{"session":[]}]}},"/events":{"get":{"operationId":"EventsController.index","tags":["Events"],"description":"Get a list of all active events","responses":{"200":{"description":"A list of active events","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Event"}}}}}}},"post":{"operationId":"EventsController.store","tags":["Events"],"description":"Create a new event","responses":{"201":{"description":"The newly created event","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event"}}}},"403":{"description":"Missing permission to create events"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","minLength":3,"maxLength":255},"description":{"type":"string"},"status":{"enum":["DRAFT","ACTIVE","ARCHIVED"]},"minTeamSize":{"type":"number","minimum":1},"maxTeamSize":{"type":"number","minimum":1},"slug":{"type":"string","pattern":"^[a-z0-9-]+$"}},"required":["title","description","status","minTeamSize","maxTeamSize","slug"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/events/{id}":{"get":{"operationId":"EventsController.show","tags":["Events"],"description":"Get a specific event by its slug or UUID","responses":{"200":{"description":"The requested event","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event"}}}},"403":{"description":"Missing permission to view this event"},"404":{"description":"Event not found"}}},"put":{"operationId":"EventsController.update","tags":["Events"],"description":"Update an existing event by its slug or UUID","responses":{"200":{"description":"The updated event","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event"}}}},"403":{"description":"Missing permission to edit this event"},"404":{"description":"Event not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","minLength":3,"maxLength":255},"description":{"type":"string"},"status":{"enum":["DRAFT","ACTIVE","ARCHIVED"]},"minTeamSize":{"type":"number","minimum":1},"maxTeamSize":{"type":"number","minimum":1},"slug":{"type":"string"}},"required":[],"additionalProperties":false}}}},"security":[{"session":[]}]},"patch":{"operationId":"EventsController.update","tags":["Events"],"description":"Update an existing event by its slug or UUID","responses":{"200":{"description":"The updated event","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event"}}}},"403":{"description":"Missing permission to edit this event"},"404":{"description":"Event not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","minLength":3,"maxLength":255},"description":{"type":"string"},"status":{"enum":["DRAFT","ACTIVE","ARCHIVED"]},"minTeamSize":{"type":"number","minimum":1},"maxTeamSize":{"type":"number","minimum":1},"slug":{"type":"string"}},"required":[],"additionalProperties":false}}}},"security":[{"session":[]}]},"delete":{"operationId":"EventsController.destroy","tags":["Events"],"description":"Delete an event by its slug or UUID. Requires confirmation of the event slug.","responses":{"204":{"description":"Event deleted successfully"},"403":{"description":"Missing permission to delete this event"},"404":{"description":"Event not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"confirmation":{"type":"string"}},"required":["confirmation"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/events/{id}/administrators":{"get":{"operationId":"EventsController.indexAdministrators","tags":["Events"],"description":"Get a list of all administrators for an event","responses":{"200":{"description":"A list of administrators for the event","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/EventAdministrator"}}}}},"403":{"description":"Missing permission to view administrators for this event"},"404":{"description":"Event not found"}},"security":[{"session":[]}]},"post":{"operationId":"EventsController.storeAdministrator","tags":["Events"],"description":"Create a new administrator for an event","responses":{"201":{"description":"The newly created administrator","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventAdministrator"}}}},"403":{"description":"Missing permission to manage administrators for this event"},"404":{"description":"Event not found"},"409":{"description":"User is already an administrator of this event"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"userId":{"type":"number","minimum":0},"permissions":{"type":"number","minimum":0}},"required":["userId"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/events/{id}/administrators/{adminId}":{"put":{"operationId":"EventsController.updateAdministrator","tags":["Events"],"description":"Update permissions for an event administrator","responses":{"200":{"description":"The updated administrator","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventAdministrator"}}}},"403":{"description":"Missing permission to manage administrators for this event"},"404":{"description":"Event or administrator not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"permissions":{"type":"number","minimum":0}},"required":["permissions"],"additionalProperties":false}}}},"security":[{"session":[]}]},"delete":{"operationId":"EventsController.destroyAdministrator","tags":["Events"],"description":"Remove administrator from an event","responses":{"204":{"description":"Administrator removed successfully"},"403":{"description":"Missing permission to manage administrators for this event"},"404":{"description":"Event or administrator not found"}},"security":[{"session":[]}]}},"/teams/{id}":{"get":{"operationId":"TeamsController.show","tags":["Teams"],"description":"Get a specific team by ID","responses":{"200":{"description":"The requested team","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Team"},{"type":"object","properties":{"members":{"type":"array","items":{"$ref":"#/components/schemas/TeamMember"}}},"required":["members"]}]}}}},"403":{"description":"Missing permission to view this event"},"404":{"description":"Team not found"}}},"put":{"operationId":"TeamsController.update","tags":["Teams"],"description":"Update a team by ID","responses":{"200":{"description":"The updated team","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Team"}}}},"403":{"description":"Missing permission to edit this team"},"404":{"description":"Team not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"],"additionalProperties":false}}}},"security":[{"session":[]}]},"patch":{"operationId":"TeamsController.update","tags":["Teams"],"description":"Update a team by ID","responses":{"200":{"description":"The updated team","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Team"}}}},"403":{"description":"Missing permission to edit this team"},"404":{"description":"Team not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"],"additionalProperties":false}}}},"security":[{"session":[]}]},"delete":{"operationId":"TeamsController.destroy","tags":["Teams"],"description":"Delete a team by ID. Requires confirmation of the team name.","responses":{"204":{"description":"Team deleted successfully"},"403":{"description":"Missing permission to delete this team"},"404":{"description":"Team not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"confirmation":{"type":"string"}},"required":["confirmation"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/events/{event_id}/teams":{"post":{"operationId":"TeamsController.store","tags":["Teams"],"description":"Create a new team in an event","responses":{"201":{"description":"The newly created team","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Team"}}}},"403":{"description":"Missing permission or invalid access code"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"accessCode":{"type":"string"}},"required":["name"],"additionalProperties":false}}}}},"get":{"operationId":"TeamsController.index","tags":["Teams"],"description":"Get a list of teams for an event","responses":{"200":{"description":"A list of teams","content":{"application/json":{"schema":{"type":"object","properties":{"meta":{"$ref":"#/components/schemas/PaginationMeta"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Team"}}},"required":["meta","data"]}}}},"403":{"description":"Missing permission to view this event"}},"parameters":[{"name":"page","in":"query","required":false,"schema":{"type":"number","minimum":0}},{"name":"limit","in":"query","required":false,"schema":{"type":"number","minimum":1,"maximum":100}},{"name":"search","in":"query","required":false,"schema":{"type":"string","minLength":2}},{"name":"orderBy","in":"query","required":false,"schema":{"type":"string"}},{"name":"orderDirection","in":"query","required":false,"schema":{"enum":["asc","desc"]}},{"name":"filter","in":"query","required":false,"schema":{"type":"array","items":{"type":"string","pattern":"^[^:]+:[><=!~]{1,2}[^:]+$"},"description":"Custom filter in a format key:[op]value.\n For example name:~Adam will find every name that contains Adam.\n Supported ops: <, >, <=, >=, =, !, ~"}}]}},"/teams/{id}/invites":{"post":{"operationId":"TeamsController.storeInvite","tags":["Teams"],"description":"Create a new team invitation","responses":{"201":{"description":"The newly created invitation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamInvitation"}}}},"403":{"description":"Missing permission to invite to this team"},"404":{"description":"Team not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email"},"validFor":{"enum":["1 hour","4 hours","12 hours","1 day","3 days","1 week"]}},"required":["validFor"],"additionalProperties":false}}}},"security":[{"session":[]}]},"get":{"operationId":"TeamsController.indexInvites","tags":["Teams"],"description":"Get a list of team invitations for a team","responses":{"200":{"description":"A list of team invitations","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TeamInvitation"}}}}},"403":{"description":"Missing permission to edit this team"},"404":{"description":"Team not found"}},"security":[{"session":[]}]}},"/teams/{id}/kick":{"post":{"operationId":"TeamsController.kickMember","tags":["Teams"],"description":"Kick a member from a team","responses":{"204":{"description":"Team member removed successfully"},"403":{"description":"Missing permission to kick this member"},"404":{"description":"Team or member not found"}},"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"member":{"type":"string","format":"uuid"}},"required":["member"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/invitations/{id}":{"get":{"operationId":"TeamsController.respondToInvite","tags":["Teams"],"description":"Accept or decline a team invitation","responses":{"200":{"description":"Invitation responded successfully","content":{"application/json":{"schema":{"type":"object","properties":{"invitation":{"$ref":"#/components/schemas/TeamInvitation"},"member":{"$ref":"#/components/schemas/TeamMember"}},"required":["invitation","member"]}}}},"403":{"description":"Missing permission to respond to this invitation"},"404":{"description":"Invitation not found"}},"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","minLength":16,"maxLength":45}},{"name":"action","in":"query","required":false,"schema":{"enum":["ACCEPT","REJECT"]}}],"security":[{"session":[]}]}},"/invitations":{"get":{"operationId":"TeamsController.indexUserInvites","tags":["Teams"],"description":"Get a list of pending team invitations for the current user","responses":{"200":{"description":"A list of team invitations for the user","content":{"application/json":{"schema":{"type":"object","properties":{"meta":{"$ref":"#/components/schemas/PaginationMeta"},"data":{"type":"array","items":{"$ref":"#/components/schemas/TeamInvitation"}}},"required":["meta","data"]}}}}},"parameters":[{"name":"page","in":"query","required":false,"schema":{"type":"number","minimum":0}},{"name":"limit","in":"query","required":false,"schema":{"type":"number","minimum":1,"maximum":100}},{"name":"search","in":"query","required":false,"schema":{"type":"string","minLength":2}},{"name":"orderBy","in":"query","required":false,"schema":{"type":"string"}},{"name":"orderDirection","in":"query","required":false,"schema":{"enum":["asc","desc"]}},{"name":"filter","in":"query","required":false,"schema":{"type":"array","items":{"type":"string","pattern":"^[^:]+:[><=!~]{1,2}[^:]+$"},"description":"Custom filter in a format key:[op]value.\n For example name:~Adam will find every name that contains Adam.\n Supported ops: <, >, <=, >=, =, !, ~"}}],"security":[{"session":[]}]}},"/events/{event_id}/organizations":{"get":{"operationId":"OrganizationsController.index","tags":["Organizations"],"description":"Get a list of organizations for an event","responses":{"200":{"description":"A list of organizations","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Organization"}}}}},"403":{"description":"Missing permission to view this event"}}},"post":{"operationId":"OrganizationsController.store","tags":["Organizations"],"description":"Create a new organization for an event","responses":{"201":{"description":"The newly created organization","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}},"403":{"description":"Missing permission to manage organizations"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":3,"maxLength":255},"description":{"type":"string","maxLength":2000},"logoUrl":{"type":"string","format":"uri"},"websiteUrl":{"type":"string","format":"uri"}},"required":["name"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/events/{event_id}/organizations/{id}":{"get":{"operationId":"OrganizationsController.show","tags":["Organizations"],"description":"Get a specific organization by ID","responses":{"200":{"description":"The requested organization","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}},"403":{"description":"Missing permission to view this event"},"404":{"description":"Organization not found"}}},"put":{"operationId":"OrganizationsController.update","tags":["Organizations"],"description":"Update an organization by ID","responses":{"200":{"description":"The updated organization","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}},"403":{"description":"Missing permission to manage organizations"},"404":{"description":"Organization not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":3,"maxLength":255},"description":{"type":"string","maxLength":2000},"logoUrl":{"type":"string","format":"uri"},"websiteUrl":{"type":"string","format":"uri"}},"required":["name"],"additionalProperties":false}}}},"security":[{"session":[]}]},"patch":{"operationId":"OrganizationsController.update","tags":["Organizations"],"description":"Update an organization by ID","responses":{"200":{"description":"The updated organization","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}},"403":{"description":"Missing permission to manage organizations"},"404":{"description":"Organization not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":3,"maxLength":255},"description":{"type":"string","maxLength":2000},"logoUrl":{"type":"string","format":"uri"},"websiteUrl":{"type":"string","format":"uri"}},"required":["name"],"additionalProperties":false}}}},"security":[{"session":[]}]},"delete":{"operationId":"OrganizationsController.destroy","tags":["Organizations"],"description":"Delete an organization by ID","responses":{"204":{"description":"Organization deleted successfully"},"403":{"description":"Missing permission to manage organizations"},"404":{"description":"Organization not found"}},"security":[{"session":[]}]}},"/tasks/{id}/sponsors":{"get":{"operationId":"OrganizationsController.indexSponsors","tags":["Organizations"],"description":"Get a list of sponsors for a task","responses":{"200":{"description":"A list of sponsors for the task","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Sponsor"}}}}},"404":{"description":"Task not found"}},"security":[{"session":[]}]},"post":{"operationId":"OrganizationsController.storeSponsor","tags":["Organizations"],"description":"Create a new sponsor for a task","responses":{"201":{"description":"The newly created sponsor","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sponsor"}}}},"400":{"description":"Organization does not belong to the same event"},"403":{"description":"Missing permission to manage sponsors"},"404":{"description":"Task not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"organizationId":{"type":"string","format":"uuid"}},"required":["organizationId"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/tasks/{id}/sponsors/{sponsorId}":{"get":{"operationId":"OrganizationsController.showSponsor","tags":["Organizations"],"description":"Get a specific sponsor by ID","responses":{"200":{"description":"The requested sponsor","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sponsor"}}}},"403":{"description":"Missing permission to view this event"},"404":{"description":"Sponsor not found"}},"security":[{"session":[]}]},"delete":{"operationId":"OrganizationsController.destroySponsor","tags":["Organizations"],"description":"Delete a sponsor by ID","responses":{"204":{"description":"Sponsor deleted successfully"},"403":{"description":"Missing permission to manage sponsors"},"404":{"description":"Sponsor not found"}},"security":[{"session":[]}]}},"/tasks/{id}":{"get":{"operationId":"TasksController.show","tags":["Tasks"],"description":"Get a specific task by its ID or slug","responses":{"200":{"description":"The requested task","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Task"}}}},"403":{"description":"Missing permission to view this task"},"404":{"description":"Task not found"}}},"put":{"operationId":"TasksController.update","tags":["Tasks"],"description":"Update a task by its ID or slug","responses":{"200":{"description":"The updated task","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Task"}}}},"403":{"description":"Missing permission to edit this task"},"404":{"description":"Task not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","minLength":3,"maxLength":255},"description":{"type":"string"},"taskType":{"enum":["HACKATHON","CTF","ALGO"]},"status":{"enum":["DRAFT","ACTIVE","ARCHIVED"]},"autoregister":{"type":"boolean"},"slug":{"type":"string","pattern":"^[a-z0-9-]+$"},"resultsPublishedAt":{"type":"null"},"registrationStartAt":{"type":"null"},"registrationEndAt":{"type":"null"},"detailsRevealAt":{"type":"null"},"submissionsStartAt":{"type":"null"},"submissionsEndAt":{"type":"null"}},"required":[],"additionalProperties":false}}}},"security":[{"session":[]}]},"patch":{"operationId":"TasksController.update","tags":["Tasks"],"description":"Update a task by its ID or slug","responses":{"200":{"description":"The updated task","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Task"}}}},"403":{"description":"Missing permission to edit this task"},"404":{"description":"Task not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","minLength":3,"maxLength":255},"description":{"type":"string"},"taskType":{"enum":["HACKATHON","CTF","ALGO"]},"status":{"enum":["DRAFT","ACTIVE","ARCHIVED"]},"autoregister":{"type":"boolean"},"slug":{"type":"string","pattern":"^[a-z0-9-]+$"},"resultsPublishedAt":{"type":"null"},"registrationStartAt":{"type":"null"},"registrationEndAt":{"type":"null"},"detailsRevealAt":{"type":"null"},"submissionsStartAt":{"type":"null"},"submissionsEndAt":{"type":"null"}},"required":[],"additionalProperties":false}}}},"security":[{"session":[]}]},"delete":{"operationId":"TasksController.destroy","tags":["Tasks"],"description":"Delete a task by its ID or slug","responses":{"204":{"description":"Task deleted successfully"},"403":{"description":"Missing permission to delete this task"},"404":{"description":"Task not found"}},"security":[{"session":[]}]}},"/tasks/{taskId}/registrations":{"post":{"operationId":"TasksController.storeTaskRegistration","tags":["Tasks"],"description":"Register a team to a task","responses":{"201":{"description":"The newly created task registration","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskRegistration"}}}},"400":{"description":"Team does not belong to the same event or registration is not open"},"403":{"description":"Missing permission to register team"},"404":{"description":"Task or team not found"},"409":{"description":"Team is already registered to the task"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"teamId":{"type":"string","format":"uuid"}},"required":["teamId"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/registrations/{id}":{"delete":{"operationId":"TasksController.destroyTaskRegistration","tags":["Tasks"],"description":"Unregister a team from a task","responses":{"204":{"description":"Task registration deleted successfully"},"403":{"description":"Missing permission to unregister team"},"404":{"description":"Task registration not found"}},"security":[{"session":[]}]}},"/hackathon/tasks/{id}":{"put":{"operationId":"HackathonController.updateTask","tags":["Hackathons"],"description":"Update a hackathon task by its ID or slug","responses":{"200":{"description":"The updated hackathon task","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HackathonTask"}}}},"403":{"description":"Missing permission to edit this task"},"404":{"description":"Hackathon task not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"requirementsDocumentUrl":{"type":"string","format":"uri"}},"required":["requirementsDocumentUrl"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/hackathon/tasks/{id}/jury":{"get":{"operationId":"HackathonController.indexJuryMembers","tags":["Hackathons"],"description":"Get a list of jury members for a hackathon task","responses":{"200":{"description":"A list of jury members for the task","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/JuryMember"}}}}},"404":{"description":"Task not found"}},"security":[{"session":[]}]},"post":{"operationId":"HackathonController.storeJuryMember","tags":["Hackathons"],"description":"Create a new jury member for a hackathon task","responses":{"201":{"description":"The newly created jury member","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JuryMember"}}}},"400":{"description":"Invalid request or jury members can only be assigned to hackathon tasks"},"403":{"description":"Missing permission to manage jury members"},"404":{"description":"Task not found"},"409":{"description":"User is already a jury member"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"description":{"type":"string","maxLength":2000},"organizationId":{"type":["string","null"],"format":"uuid"},"userId":{"type":"number","minimum":0}},"required":["userId"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/hackathon/tasks/{id}/jury/{juryMemberId}":{"get":{"operationId":"HackathonController.showJuryMember","tags":["Hackathons"],"description":"Get a specific jury member by ID","responses":{"200":{"description":"The requested jury member","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JuryMember"}}}},"403":{"description":"Missing permission to view this event"},"404":{"description":"Jury member not found"}},"security":[{"session":[]}]},"put":{"operationId":"HackathonController.updateJuryMember","tags":["Hackathons"],"description":"Update a jury member by ID","responses":{"200":{"description":"The updated jury member","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JuryMember"}}}},"403":{"description":"Missing permission to manage jury members"},"404":{"description":"Jury member not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"description":{"type":"string","maxLength":2000},"organizationId":{"type":["string","null"],"format":"uuid"}},"required":[],"additionalProperties":false}}}},"security":[{"session":[]}]},"delete":{"operationId":"HackathonController.destroyJuryMember","tags":["Hackathons"],"description":"Delete a jury member by ID","responses":{"204":{"description":"Jury member deleted successfully"},"403":{"description":"Missing permission to manage jury members"},"404":{"description":"Jury member not found"}},"security":[{"session":[]}]}},"/event/{event_id}/tasks":{"get":{"operationId":"TasksController.index","tags":["Tasks"],"description":"Get a list of tasks for an event","responses":{"200":{"description":"A list of tasks","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Task"}}}}},"403":{"description":"Missing permission to view this event"}}}},"/event/{event_id}/task":{"post":{"operationId":"TasksController.store","tags":["Tasks"],"description":"Create a new task for an event","responses":{"201":{"description":"The newly created task","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Task"}}}},"403":{"description":"Missing permission to create tasks"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","minLength":3,"maxLength":255},"description":{"type":"string"},"taskType":{"enum":["HACKATHON","CTF","ALGO"]},"status":{"enum":["DRAFT","ACTIVE","ARCHIVED"]},"autoregister":{"enum":["1",1,"true",true,"on","0",0,"false",false]},"slug":{"type":"string","pattern":"^[a-z0-9-]+$"},"resultsPublishedAt":{"type":"null"},"registrationStartAt":{"type":"null"},"registrationEndAt":{"type":"null"},"detailsRevealAt":{"type":"null"},"submissionsStartAt":{"type":"null"},"submissionsEndAt":{"type":"null"},"requirementsDocumentUrl":{"type":"string","format":"uri"}},"required":["title","description","taskType","slug"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/tasks/{task_id}/scores":{"get":{"operationId":"ScoresController.indexCriteria","tags":["Scores"],"description":"Get a list of scoring criteria for a task","responses":{"200":{"description":"A list of scoring criteria","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ScoringCriterion"}}}}},"403":{"description":"Missing permission to view this task"},"404":{"description":"Task not found"}},"security":[{"session":[]}]},"post":{"operationId":"ScoresController.storeCriteria","tags":["Scores"],"description":"Create a new scoring criterion for a task","responses":{"201":{"description":"The newly created scoring criterion","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScoringCriterion"}}}},"403":{"description":"Missing permission to edit this task"},"404":{"description":"Task not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"category":{"type":"string","minLength":1,"maxLength":255},"maximumScore":{"type":"integer","minimum":1},"weight":{"type":"number","minimum":0,"maximum":100},"description":{"type":["string","null"]}},"required":["category","maximumScore","weight"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/tasks/{task_id}/scores/{id}":{"get":{"operationId":"ScoresController.showCriteria","tags":["Scores"],"description":"Get a specific scoring criterion by ID","responses":{"200":{"description":"The requested scoring criterion","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScoringCriterion"}}}},"403":{"description":"Missing permission to view this task"},"404":{"description":"Scoring criterion not found"}},"security":[{"session":[]}]},"put":{"operationId":"ScoresController.updateCriteria","tags":["Scores"],"description":"Update a scoring criterion by ID","responses":{"200":{"description":"The updated scoring criterion","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScoringCriterion"}}}},"403":{"description":"Missing permission to edit this task"},"404":{"description":"Scoring criterion not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"category":{"type":"string","minLength":1,"maxLength":255},"maximumScore":{"type":"integer","minimum":1},"weight":{"type":"number","minimum":0,"maximum":100},"description":{"type":["string","null"]}},"required":[],"additionalProperties":false}}}},"security":[{"session":[]}]},"delete":{"operationId":"ScoresController.destroyCriteria","tags":["Scores"],"description":"Delete a scoring criterion by ID","responses":{"204":{"description":"Scoring criterion deleted successfully"},"403":{"description":"Missing permission to edit this task"},"404":{"description":"Scoring criterion not found"}},"security":[{"session":[]}]}}},"components":{"schemas":{"User":{"type":"object","properties":{"id":{"type":"number"},"nickname":{"example":"JohnnyBravo","type":"string"},"name":{"example":"John","type":"string"},"surname":{"example":"Bravo","type":"string"},"email":{"example":"johnny.bravo@example.com","type":"string"},"avatarUrl":{"example":"https://example.com/avatar.png","type":"string"},"permissions":{"format":"binary","type":"number"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","nickname","email","permissions","createdAt","updatedAt"]},"Event":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"slug":{"example":"best-hackathon-ever","type":"string"},"title":{"example":"Awesome Hackathon","type":"string"},"description":{"example":"A first ever hackathon using a modern platform","type":"string"},"status":{"example":"ACTIVE","enum":["DRAFT","ACTIVE","ARCHIVED"]},"minTeamSize":{"type":"number"},"maxTeamSize":{"example":4,"type":"number"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","slug","title","description","status","minTeamSize","maxTeamSize","createdAt","updatedAt"]},"EventAdministrator":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08a","type":"string"},"eventId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"userId":{"type":"number"},"permissions":{"format":"binary","type":"number"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","eventId","userId","permissions","createdAt","updatedAt"]},"Team":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"eventId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"name":{"example":"Team Awesome","type":"string"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","eventId","name","createdAt","updatedAt"]},"TeamMember":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"teamId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"userId":{"example":1,"type":"number"},"permissions":{"format":"binary","type":"number"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","teamId","userId","permissions","createdAt","updatedAt"]},"PaginationMeta":{"type":"object","properties":{"total":{"type":"number"},"perPage":{"example":25,"type":"number"},"currentPage":{"type":"number"},"lastPage":{"example":3,"type":"number"},"firstPage":{"type":"number"},"firstPageUrl":{"example":"/data?page=1","type":"string"},"lastPageUrl":{"example":"/data?page=3","type":"string"},"nextPageUrl":{"example":"/data?page=2","type":"string"},"previousPageUrl":{"example":null,"type":"string"}},"required":["total","perPage","currentPage","lastPage","firstPage","firstPageUrl","lastPageUrl"]},"TeamInvitation":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"teamId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"inviterId":{"example":1,"type":"number"},"inviteeEmail":{"example":"user@example.com","type":"string"},"token":{"example":"secure-token-here","type":"string"},"status":{"example":"PENDING","enum":["PENDING","ACCEPTED","DECLINED","FAILED","EXPIRED"]},"expiresAt":{"format":"date-time","type":"string"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","teamId","inviterId","token","status","createdAt","updatedAt"]},"Organization":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"name":{"example":"Acme Corporation","type":"string"},"description":{"example":"A leading corporation","type":"string"},"logoUrl":{"example":"https://example.com/logo.png","type":"string"},"websiteUrl":{"example":"https://example.com","type":"string"},"eventId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","name","description","eventId","createdAt","updatedAt"]},"Sponsor":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"taskId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"organizationId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"createdAt":{"format":"date-time","type":"string"}},"required":["id","taskId","organizationId","createdAt"]},"Task":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"eventId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"slug":{"example":"awesome-hackathon-task","type":"string"},"title":{"example":"Awesome Hackathon Task","type":"string"},"description":{"example":"Build an awesome application","type":"string"},"taskType":{"example":"HACKATHON","type":"string"},"status":{"example":"ACTIVE","type":"string"},"autoregister":{"example":false,"type":"boolean"},"resultsPublishedAt":{"format":"date-time","type":"string"},"registrationStartAt":{"format":"date-time","type":"string"},"registrationEndAt":{"format":"date-time","type":"string"},"detailsRevealAt":{"format":"date-time","type":"string"},"submissionsStartAt":{"format":"date-time","type":"string"},"submissionsEndAt":{"format":"date-time","type":"string"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","eventId","slug","title","description","taskType","status","autoregister","createdAt","updatedAt"]},"TaskRegistration":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"teamId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"taskId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"createdAt":{"format":"date-time","type":"string"}},"required":["id","teamId","taskId","createdAt"]},"HackathonTask":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"taskId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"requirementsDocumentUrl":{"example":"https://example.com/requirements.pdf","type":"string"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","taskId","requirementsDocumentUrl","createdAt","updatedAt"]},"JuryMember":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"description":{"example":"Senior judge with 10 years experience","type":"string"},"userId":{"example":1,"type":"number"},"taskId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"organizationId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","description","userId","taskId","createdAt","updatedAt"]},"ScoringCriterion":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"taskId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"category":{"example":"Code Quality","type":"string"},"description":{"example":"Assessment of code quality and best practices","type":"string"},"maximumScore":{"example":100,"type":"number"},"weight":{"example":1.5,"type":"number"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","taskId","category","maximumScore","weight","createdAt","updatedAt"]}},"responses":{},"parameters":{},"examples":{},"requestBodies":{},"headers":{},"securitySchemes":{"session":{"type":"apiKey","in":"cookie","name":"xcontest-session"}},"links":{},"callbacks":{}},"tags":[{"name":"Auth","description":"Endpoints provided by AuthController"},{"name":"Events","description":"Endpoints provided by EventsController"},{"name":"Teams","description":"Endpoints provided by TeamsController"},{"name":"Organizations","description":"Endpoints provided by OrganizationsController"},{"name":"Tasks","description":"Endpoints provided by TasksController"},{"name":"Hackathons","description":"Endpoints provided by HackathonsController"},{"name":"Scores","description":"Endpoints provided by ScoresController"}],"servers":[]} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"xContest REST API","description":"Specification document for REST API component of xContest platform","version":"1.0.0"},"paths":{"/auth/{provider}/redirect":{"get":{"operationId":"AuthController.redirect","tags":["Auth"],"description":"Redirects to the social provider for authentication","responses":{"307":{"description":"Redirects to the social provider"}},"parameters":[{"name":"provider","in":"path","required":true,"schema":{"enum":["discord","github"]}}]}},"/auth/{provider}/callback":{"get":{"operationId":"AuthController.callback","tags":["Auth"],"description":"Handles the callback from the social provider","responses":{"307":{"description":"Redirects to the status page"}},"parameters":[{"name":"provider","in":"path","required":true,"schema":{"enum":["discord","github"]}}]}},"/auth/register":{"post":{"operationId":"AuthController.register","tags":["Auth"],"description":"Registers a new user with email and password","responses":{"201":{"description":"User registered successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}}}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"nickname":{"type":"string","minLength":3,"maxLength":16},"name":{"type":"string","minLength":3,"maxLength":32},"surname":{"type":"string","minLength":3,"maxLength":32},"email":{"type":"string","format":"email"},"password":{"type":"string","minLength":8}},"required":["nickname","email","password"],"additionalProperties":false}}}}}},"/auth/login":{"post":{"operationId":"AuthController.login","tags":["Auth"],"description":"Logs in a user with email and password","responses":{"200":{"description":"User logged in successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}}}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email"},"password":{"type":"string"}},"required":["email","password"],"additionalProperties":false}}}}}},"/auth/logout":{"post":{"operationId":"AuthController.logout","tags":["Auth"],"description":"Logs out the current user","responses":{"200":{"description":"User logged out successfully"}},"security":[{"session":[]}]}},"/events":{"get":{"operationId":"EventsController.index","tags":["Events"],"description":"Get a list of all active events","responses":{"200":{"description":"A list of active events","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Event"}}}}}}},"post":{"operationId":"EventsController.store","tags":["Events"],"description":"Create a new event","responses":{"201":{"description":"The newly created event","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event"}}}},"403":{"description":"Missing permission to create events"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","minLength":3,"maxLength":255},"description":{"type":"string"},"status":{"enum":["DRAFT","ACTIVE","ARCHIVED"]},"minTeamSize":{"type":"number","minimum":1},"maxTeamSize":{"type":"number","minimum":1},"slug":{"type":"string","pattern":"^[a-z0-9-]+$"}},"required":["title","description","status","minTeamSize","maxTeamSize","slug"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/events/{id}":{"get":{"operationId":"EventsController.show","tags":["Events"],"description":"Get a specific event by its slug or UUID","responses":{"200":{"description":"The requested event","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event"}}}},"403":{"description":"Missing permission to view this event"},"404":{"description":"Event not found"}}},"put":{"operationId":"EventsController.update","tags":["Events"],"description":"Update an existing event by its slug or UUID","responses":{"200":{"description":"The updated event","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event"}}}},"403":{"description":"Missing permission to edit this event"},"404":{"description":"Event not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","minLength":3,"maxLength":255},"description":{"type":"string"},"status":{"enum":["DRAFT","ACTIVE","ARCHIVED"]},"minTeamSize":{"type":"number","minimum":1},"maxTeamSize":{"type":"number","minimum":1},"slug":{"type":"string"}},"required":[],"additionalProperties":false}}}},"security":[{"session":[]}]},"patch":{"operationId":"EventsController.update","tags":["Events"],"description":"Update an existing event by its slug or UUID","responses":{"200":{"description":"The updated event","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event"}}}},"403":{"description":"Missing permission to edit this event"},"404":{"description":"Event not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","minLength":3,"maxLength":255},"description":{"type":"string"},"status":{"enum":["DRAFT","ACTIVE","ARCHIVED"]},"minTeamSize":{"type":"number","minimum":1},"maxTeamSize":{"type":"number","minimum":1},"slug":{"type":"string"}},"required":[],"additionalProperties":false}}}},"security":[{"session":[]}]},"delete":{"operationId":"EventsController.destroy","tags":["Events"],"description":"Delete an event by its slug or UUID. Requires confirmation of the event slug.","responses":{"204":{"description":"Event deleted successfully"},"403":{"description":"Missing permission to delete this event"},"404":{"description":"Event not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"confirmation":{"type":"string"}},"required":["confirmation"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/events/{id}/administrators":{"get":{"operationId":"EventsController.indexAdministrators","tags":["Events"],"description":"Get a list of all administrators for an event","responses":{"200":{"description":"A list of administrators for the event","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/EventAdministrator"}}}}},"403":{"description":"Missing permission to view administrators for this event"},"404":{"description":"Event not found"}},"security":[{"session":[]}]},"post":{"operationId":"EventsController.storeAdministrator","tags":["Events"],"description":"Create a new administrator for an event","responses":{"201":{"description":"The newly created administrator","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventAdministrator"}}}},"403":{"description":"Missing permission to manage administrators for this event"},"404":{"description":"Event not found"},"409":{"description":"User is already an administrator of this event"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"userId":{"type":"number","minimum":0},"permissions":{"type":"number","minimum":0}},"required":["userId"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/events/{id}/administrators/{adminId}":{"put":{"operationId":"EventsController.updateAdministrator","tags":["Events"],"description":"Update permissions for an event administrator","responses":{"200":{"description":"The updated administrator","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventAdministrator"}}}},"403":{"description":"Missing permission to manage administrators for this event"},"404":{"description":"Event or administrator not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"permissions":{"type":"number","minimum":0}},"required":["permissions"],"additionalProperties":false}}}},"security":[{"session":[]}]},"patch":{"operationId":"EventsController.updateAdministrator","tags":["Events"],"description":"Update permissions for an event administrator","responses":{"200":{"description":"The updated administrator","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventAdministrator"}}}},"403":{"description":"Missing permission to manage administrators for this event"},"404":{"description":"Event or administrator not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"permissions":{"type":"number","minimum":0}},"required":["permissions"],"additionalProperties":false}}}},"security":[{"session":[]}]},"delete":{"operationId":"EventsController.destroyAdministrator","tags":["Events"],"description":"Remove administrator from an event","responses":{"204":{"description":"Administrator removed successfully"},"403":{"description":"Missing permission to manage administrators for this event"},"404":{"description":"Event or administrator not found"}},"security":[{"session":[]}]}},"/teams/{id}":{"get":{"operationId":"TeamsController.show","tags":["Teams"],"description":"Get a specific team by ID","responses":{"200":{"description":"The requested team","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Team"},{"type":"object","properties":{"members":{"type":"array","items":{"$ref":"#/components/schemas/TeamMember"}}},"required":["members"]}]}}}},"403":{"description":"Missing permission to view this event"},"404":{"description":"Team not found"}}},"put":{"operationId":"TeamsController.update","tags":["Teams"],"description":"Update a team by ID","responses":{"200":{"description":"The updated team","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Team"}}}},"403":{"description":"Missing permission to edit this team"},"404":{"description":"Team not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"],"additionalProperties":false}}}},"security":[{"session":[]}]},"patch":{"operationId":"TeamsController.update","tags":["Teams"],"description":"Update a team by ID","responses":{"200":{"description":"The updated team","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Team"}}}},"403":{"description":"Missing permission to edit this team"},"404":{"description":"Team not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"],"additionalProperties":false}}}},"security":[{"session":[]}]},"delete":{"operationId":"TeamsController.destroy","tags":["Teams"],"description":"Delete a team by ID. Requires confirmation of the team name.","responses":{"204":{"description":"Team deleted successfully"},"403":{"description":"Missing permission to delete this team"},"404":{"description":"Team not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"confirmation":{"type":"string"}},"required":["confirmation"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/events/{event_id}/teams":{"post":{"operationId":"TeamsController.store","tags":["Teams"],"description":"Create a new team in an event","responses":{"201":{"description":"The newly created team","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Team"}}}},"403":{"description":"Missing permission or invalid access code"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"accessCode":{"type":"string"}},"required":["name"],"additionalProperties":false}}}}},"get":{"operationId":"TeamsController.index","tags":["Teams"],"description":"Get a list of teams for an event","responses":{"200":{"description":"A list of teams","content":{"application/json":{"schema":{"type":"object","properties":{"meta":{"$ref":"#/components/schemas/PaginationMeta"},"data":{"type":"array","items":{"$ref":"#/components/schemas/Team"}}},"required":["meta","data"]}}}},"403":{"description":"Missing permission to view this event"}},"parameters":[{"name":"page","in":"query","required":false,"schema":{"type":"number","minimum":0}},{"name":"limit","in":"query","required":false,"schema":{"type":"number","minimum":1,"maximum":100}},{"name":"search","in":"query","required":false,"schema":{"type":"string","minLength":2}},{"name":"orderBy","in":"query","required":false,"schema":{"type":"string"}},{"name":"orderDirection","in":"query","required":false,"schema":{"enum":["asc","desc"]}},{"name":"filter","in":"query","required":false,"schema":{"type":"array","items":{"type":"string","pattern":"^[^:]+:[><=!~]{1,2}[^:]+$"},"description":"Custom filter in a format key:[op]value.\n For example name:~Adam will find every name that contains Adam.\n Supported ops: <, >, <=, >=, =, !, ~"}}]}},"/teams/{id}/invites":{"post":{"operationId":"TeamsController.storeInvite","tags":["Teams"],"description":"Create a new team invitation","responses":{"201":{"description":"The newly created invitation","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamInvitation"}}}},"403":{"description":"Missing permission to invite to this team"},"404":{"description":"Team not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string","format":"email"},"validFor":{"enum":["1 hour","4 hours","12 hours","1 day","3 days","1 week"]}},"required":["validFor"],"additionalProperties":false}}}},"security":[{"session":[]}]},"get":{"operationId":"TeamsController.indexInvites","tags":["Teams"],"description":"Get a list of team invitations for a team","responses":{"200":{"description":"A list of team invitations","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TeamInvitation"}}}}},"403":{"description":"Missing permission to edit this team"},"404":{"description":"Team not found"}},"security":[{"session":[]}]}},"/teams/{id}/kick":{"post":{"operationId":"TeamsController.kickMember","tags":["Teams"],"description":"Kick a member from a team","responses":{"204":{"description":"Team member removed successfully"},"403":{"description":"Missing permission to kick this member"},"404":{"description":"Team or member not found"}},"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"member":{"type":"string","format":"uuid"}},"required":["member"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/invitations/{id}":{"get":{"operationId":"TeamsController.respondToInvite","tags":["Teams"],"description":"Accept or decline a team invitation","responses":{"200":{"description":"Invitation responded successfully","content":{"application/json":{"schema":{"type":"object","properties":{"invitation":{"$ref":"#/components/schemas/TeamInvitation"},"member":{"$ref":"#/components/schemas/TeamMember"}},"required":["invitation","member"]}}}},"403":{"description":"Missing permission to respond to this invitation"},"404":{"description":"Invitation not found"}},"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","minLength":16,"maxLength":45}},{"name":"action","in":"query","required":false,"schema":{"enum":["ACCEPT","REJECT"]}}],"security":[{"session":[]}]}},"/invitations":{"get":{"operationId":"TeamsController.indexUserInvites","tags":["Teams"],"description":"Get a list of pending team invitations for the current user","responses":{"200":{"description":"A list of team invitations for the user","content":{"application/json":{"schema":{"type":"object","properties":{"meta":{"$ref":"#/components/schemas/PaginationMeta"},"data":{"type":"array","items":{"$ref":"#/components/schemas/TeamInvitation"}}},"required":["meta","data"]}}}}},"parameters":[{"name":"page","in":"query","required":false,"schema":{"type":"number","minimum":0}},{"name":"limit","in":"query","required":false,"schema":{"type":"number","minimum":1,"maximum":100}},{"name":"search","in":"query","required":false,"schema":{"type":"string","minLength":2}},{"name":"orderBy","in":"query","required":false,"schema":{"type":"string"}},{"name":"orderDirection","in":"query","required":false,"schema":{"enum":["asc","desc"]}},{"name":"filter","in":"query","required":false,"schema":{"type":"array","items":{"type":"string","pattern":"^[^:]+:[><=!~]{1,2}[^:]+$"},"description":"Custom filter in a format key:[op]value.\n For example name:~Adam will find every name that contains Adam.\n Supported ops: <, >, <=, >=, =, !, ~"}}],"security":[{"session":[]}]}},"/events/{event_id}/organizations":{"get":{"operationId":"OrganizationsController.index","tags":["Organizations"],"description":"Get a list of organizations for an event","responses":{"200":{"description":"A list of organizations","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Organization"}}}}},"403":{"description":"Missing permission to view this event"}}},"post":{"operationId":"OrganizationsController.store","tags":["Organizations"],"description":"Create a new organization for an event","responses":{"201":{"description":"The newly created organization","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}},"403":{"description":"Missing permission to manage organizations"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":3,"maxLength":255},"description":{"type":"string","maxLength":2000},"logoUrl":{"type":"string","format":"uri"},"websiteUrl":{"type":"string","format":"uri"}},"required":["name"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/events/{event_id}/organizations/{id}":{"get":{"operationId":"OrganizationsController.show","tags":["Organizations"],"description":"Get a specific organization by ID","responses":{"200":{"description":"The requested organization","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}},"403":{"description":"Missing permission to view this event"},"404":{"description":"Organization not found"}}},"put":{"operationId":"OrganizationsController.update","tags":["Organizations"],"description":"Update an organization by ID","responses":{"200":{"description":"The updated organization","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}},"403":{"description":"Missing permission to manage organizations"},"404":{"description":"Organization not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":3,"maxLength":255},"description":{"type":"string","maxLength":2000},"logoUrl":{"type":"string","format":"uri"},"websiteUrl":{"type":"string","format":"uri"}},"required":["name"],"additionalProperties":false}}}},"security":[{"session":[]}]},"patch":{"operationId":"OrganizationsController.update","tags":["Organizations"],"description":"Update an organization by ID","responses":{"200":{"description":"The updated organization","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}},"403":{"description":"Missing permission to manage organizations"},"404":{"description":"Organization not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":3,"maxLength":255},"description":{"type":"string","maxLength":2000},"logoUrl":{"type":"string","format":"uri"},"websiteUrl":{"type":"string","format":"uri"}},"required":["name"],"additionalProperties":false}}}},"security":[{"session":[]}]},"delete":{"operationId":"OrganizationsController.destroy","tags":["Organizations"],"description":"Delete an organization by ID","responses":{"204":{"description":"Organization deleted successfully"},"403":{"description":"Missing permission to manage organizations"},"404":{"description":"Organization not found"}},"security":[{"session":[]}]}},"/tasks/{id}/sponsors":{"get":{"operationId":"OrganizationsController.indexSponsors","tags":["Organizations"],"description":"Get a list of sponsors for a task","responses":{"200":{"description":"A list of sponsors for the task","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Sponsor"}}}}},"404":{"description":"Task not found"}},"security":[{"session":[]}]},"post":{"operationId":"OrganizationsController.storeSponsor","tags":["Organizations"],"description":"Create a new sponsor for a task","responses":{"201":{"description":"The newly created sponsor","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sponsor"}}}},"400":{"description":"Organization does not belong to the same event"},"403":{"description":"Missing permission to manage sponsors"},"404":{"description":"Task not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"organizationId":{"type":"string","format":"uuid"}},"required":["organizationId"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/tasks/{id}/sponsors/{sponsorId}":{"get":{"operationId":"OrganizationsController.showSponsor","tags":["Organizations"],"description":"Get a specific sponsor by ID","responses":{"200":{"description":"The requested sponsor","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Sponsor"}}}},"403":{"description":"Missing permission to view this event"},"404":{"description":"Sponsor not found"}},"security":[{"session":[]}]},"delete":{"operationId":"OrganizationsController.destroySponsor","tags":["Organizations"],"description":"Delete a sponsor by ID","responses":{"204":{"description":"Sponsor deleted successfully"},"403":{"description":"Missing permission to manage sponsors"},"404":{"description":"Sponsor not found"}},"security":[{"session":[]}]}},"/tasks/{id}":{"get":{"operationId":"TasksController.show","tags":["Tasks"],"description":"Get a specific task by its ID or slug","responses":{"200":{"description":"The requested task","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Task"}}}},"403":{"description":"Missing permission to view this task"},"404":{"description":"Task not found"}}},"put":{"operationId":"TasksController.update","tags":["Tasks"],"description":"Update a task by its ID or slug","responses":{"200":{"description":"The updated task","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Task"}}}},"403":{"description":"Missing permission to edit this task"},"404":{"description":"Task not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","minLength":3,"maxLength":255},"description":{"type":"string"},"taskType":{"enum":["HACKATHON","CTF","ALGO"]},"status":{"enum":["DRAFT","ACTIVE","ARCHIVED"]},"autoregister":{"type":"boolean"},"slug":{"type":"string","pattern":"^[a-z0-9-]+$"},"resultsPublishedAt":{"type":"null"},"registrationStartAt":{"type":"null"},"registrationEndAt":{"type":"null"},"detailsRevealAt":{"type":"null"},"submissionsStartAt":{"type":"null"},"submissionsEndAt":{"type":"null"}},"required":[],"additionalProperties":false}}}},"security":[{"session":[]}]},"patch":{"operationId":"TasksController.update","tags":["Tasks"],"description":"Update a task by its ID or slug","responses":{"200":{"description":"The updated task","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Task"}}}},"403":{"description":"Missing permission to edit this task"},"404":{"description":"Task not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","minLength":3,"maxLength":255},"description":{"type":"string"},"taskType":{"enum":["HACKATHON","CTF","ALGO"]},"status":{"enum":["DRAFT","ACTIVE","ARCHIVED"]},"autoregister":{"type":"boolean"},"slug":{"type":"string","pattern":"^[a-z0-9-]+$"},"resultsPublishedAt":{"type":"null"},"registrationStartAt":{"type":"null"},"registrationEndAt":{"type":"null"},"detailsRevealAt":{"type":"null"},"submissionsStartAt":{"type":"null"},"submissionsEndAt":{"type":"null"}},"required":[],"additionalProperties":false}}}},"security":[{"session":[]}]},"delete":{"operationId":"TasksController.destroy","tags":["Tasks"],"description":"Delete a task by its ID or slug","responses":{"204":{"description":"Task deleted successfully"},"403":{"description":"Missing permission to delete this task"},"404":{"description":"Task not found"}},"security":[{"session":[]}]}},"/tasks/{taskId}/registrations":{"post":{"operationId":"TasksController.storeTaskRegistration","tags":["Tasks"],"description":"Register a team to a task","responses":{"201":{"description":"The newly created task registration","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskRegistration"}}}},"400":{"description":"Team does not belong to the same event or registration is not open"},"403":{"description":"Missing permission to register team"},"404":{"description":"Task or team not found"},"409":{"description":"Team is already registered to the task"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"teamId":{"type":"string","format":"uuid"}},"required":["teamId"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/registrations/{id}":{"delete":{"operationId":"TasksController.destroyTaskRegistration","tags":["Tasks"],"description":"Unregister a team from a task","responses":{"204":{"description":"Task registration deleted successfully"},"403":{"description":"Missing permission to unregister team"},"404":{"description":"Task registration not found"}},"security":[{"session":[]}]}},"/hackathon/tasks/{id}":{"put":{"operationId":"HackathonController.updateTask","tags":["Hackathons"],"description":"Update a hackathon task by its ID or slug","responses":{"200":{"description":"The updated hackathon task","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HackathonTask"}}}},"403":{"description":"Missing permission to edit this task"},"404":{"description":"Hackathon task not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"requirementsDocumentUrl":{"type":"string","format":"uri"}},"required":["requirementsDocumentUrl"],"additionalProperties":false}}}},"security":[{"session":[]}]},"patch":{"operationId":"HackathonController.updateTask","tags":["Hackathons"],"description":"Update a hackathon task by its ID or slug","responses":{"200":{"description":"The updated hackathon task","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HackathonTask"}}}},"403":{"description":"Missing permission to edit this task"},"404":{"description":"Hackathon task not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"requirementsDocumentUrl":{"type":"string","format":"uri"}},"required":["requirementsDocumentUrl"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/hackathon/submissions":{"post":{"operationId":"HackathonController.storeHackathonSubmission","tags":["Hackathons"],"description":"Create a new submission for a hackathon task","responses":{"201":{"description":"The newly created hackathon submission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HackathonTaskSubmission"}}}},"400":{"description":"Submissions are closed or invalid request or already exists"},"403":{"description":"Missing permission to submit for this task"},"404":{"description":"Task registration not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"description":{"type":"string","maxLength":5000},"repositoryUrl":{"type":"string","format":"uri","maxLength":500},"demoUrl":{"type":"string","format":"uri","maxLength":500},"status":{"enum":["DRAFT","ACTIVE","ARCHIVED"]},"media":{"type":"array","items":{"type":"object","properties":{"description":{"type":"string","maxLength":500},"mediaType":{"enum":["IMAGE","VIDEO","DOCUMENT","LINK"]},"url":{"type":"string","format":"uri","maxLength":2048},"file":{}},"required":["mediaType"],"additionalProperties":false}},"taskRegistrationId":{"type":"string","format":"uuid"}},"required":["status","taskRegistrationId"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/hackathon/submissions/user":{"get":{"operationId":"HackathonController.indexHackathonSubmissionsByUser","tags":["Hackathons"],"description":"Get a list of submissions for a hackathon event","responses":{"200":{"description":"A list of hackathon submissions","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/HackathonTaskSubmission"}}}}},"401":{"description":"Authentication required"}},"security":[{"session":[]}]}},"/hackathon/submissions/team/{teamId}":{"get":{"operationId":"HackathonController.indexHackathonSubmissionsByTeam","tags":["Hackathons"],"description":"Get a list of submissions for a hackathon event","responses":{"200":{"description":"A list of hackathon submissions","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/HackathonTaskSubmission"}}}}},"403":{"description":"Missing permission to view submissions for this team"},"404":{"description":"Team not found"}},"security":[{"session":[]}]}},"/hackathon/submissions/task/{taskId}":{"get":{"operationId":"HackathonController.indexHackathonSubmissionsByTask","tags":["Hackathons"],"description":"Get a list of submissions for a hackathon event","responses":{"200":{"description":"A list of hackathon submissions","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/HackathonTaskSubmission"}}}}},"403":{"description":"Missing permission to view submissions for this task"},"404":{"description":"Task not found"}},"security":[{"session":[]}]}},"/hackathon/submissions/{id}":{"get":{"operationId":"HackathonController.showHackathonSubmission","tags":["Hackathons"],"description":"Get a specific hackathon submission by ID","responses":{"200":{"description":"The requested hackathon submission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HackathonTaskSubmission"}}}},"403":{"description":"Missing permission to view this submission"},"404":{"description":"Hackathon submission not found"}},"security":[{"session":[]}]},"put":{"operationId":"HackathonController.updateHackathonSubmission","tags":["Hackathons"],"description":"Update a hackathon submission by ID","responses":{"200":{"description":"The updated hackathon submission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HackathonTaskSubmission"}}}},"400":{"description":"Invalid request or submission status transition not allowed"},"403":{"description":"Missing permission to manage this submission"},"404":{"description":"Hackathon submission not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"description":{"type":"string","maxLength":5000},"repositoryUrl":{"type":"string","format":"uri","maxLength":500},"demoUrl":{"type":"string","format":"uri","maxLength":500},"status":{"enum":["DRAFT","ACTIVE","ARCHIVED"]},"media":{"type":"array","items":{"type":"object","properties":{"description":{"type":"string","maxLength":500},"mediaType":{"enum":["IMAGE","VIDEO","DOCUMENT","LINK"]},"url":{"type":"string","format":"uri","maxLength":2048},"file":{}},"required":["mediaType"],"additionalProperties":false}}},"required":["status"],"additionalProperties":false}}}},"security":[{"session":[]}]},"patch":{"operationId":"HackathonController.updateHackathonSubmission","tags":["Hackathons"],"description":"Update a hackathon submission by ID","responses":{"200":{"description":"The updated hackathon submission","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HackathonTaskSubmission"}}}},"400":{"description":"Invalid request or submission status transition not allowed"},"403":{"description":"Missing permission to manage this submission"},"404":{"description":"Hackathon submission not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"description":{"type":"string","maxLength":5000},"repositoryUrl":{"type":"string","format":"uri","maxLength":500},"demoUrl":{"type":"string","format":"uri","maxLength":500},"status":{"enum":["DRAFT","ACTIVE","ARCHIVED"]},"media":{"type":"array","items":{"type":"object","properties":{"description":{"type":"string","maxLength":500},"mediaType":{"enum":["IMAGE","VIDEO","DOCUMENT","LINK"]},"url":{"type":"string","format":"uri","maxLength":2048},"file":{}},"required":["mediaType"],"additionalProperties":false}}},"required":["status"],"additionalProperties":false}}}},"security":[{"session":[]}]},"delete":{"operationId":"HackathonController.destroyHackathonSubmission","tags":["Hackathons"],"description":"Delete a hackathon submission by ID","responses":{"204":{"description":"Hackathon submission deleted successfully"},"400":{"description":"Cannot delete an archived submission"},"403":{"description":"Missing permission to manage this submission"},"404":{"description":"Hackathon submission not found"}},"security":[{"session":[]}]}},"/hackathon/tasks/{id}/jury":{"get":{"operationId":"HackathonController.indexJuryMembers","tags":["Hackathons"],"description":"Get a list of jury members for a hackathon task","responses":{"200":{"description":"A list of jury members for the task","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/JuryMember"}}}}},"404":{"description":"Task not found"}},"security":[{"session":[]}]},"post":{"operationId":"HackathonController.storeJuryMember","tags":["Hackathons"],"description":"Create a new jury member for a hackathon task","responses":{"201":{"description":"The newly created jury member","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JuryMember"}}}},"400":{"description":"Invalid request or jury members can only be assigned to hackathon tasks"},"403":{"description":"Missing permission to manage jury members"},"404":{"description":"Task not found"},"409":{"description":"User is already a jury member"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"description":{"type":"string","maxLength":2000},"organizationId":{"type":["string","null"],"format":"uuid"},"userId":{"type":"number","minimum":0}},"required":["userId"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/hackathon/tasks/{id}/jury/{juryMemberId}":{"get":{"operationId":"HackathonController.showJuryMember","tags":["Hackathons"],"description":"Get a specific jury member by ID","responses":{"200":{"description":"The requested jury member","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JuryMember"}}}},"403":{"description":"Missing permission to view this event"},"404":{"description":"Jury member not found"}},"security":[{"session":[]}]},"put":{"operationId":"HackathonController.updateJuryMember","tags":["Hackathons"],"description":"Update a jury member by ID","responses":{"200":{"description":"The updated jury member","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JuryMember"}}}},"403":{"description":"Missing permission to manage jury members"},"404":{"description":"Jury member not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"description":{"type":"string","maxLength":2000},"organizationId":{"type":["string","null"],"format":"uuid"}},"required":[],"additionalProperties":false}}}},"security":[{"session":[]}]},"patch":{"operationId":"HackathonController.updateJuryMember","tags":["Hackathons"],"description":"Update a jury member by ID","responses":{"200":{"description":"The updated jury member","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JuryMember"}}}},"403":{"description":"Missing permission to manage jury members"},"404":{"description":"Jury member not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"description":{"type":"string","maxLength":2000},"organizationId":{"type":["string","null"],"format":"uuid"}},"required":[],"additionalProperties":false}}}},"security":[{"session":[]}]},"delete":{"operationId":"HackathonController.destroyJuryMember","tags":["Hackathons"],"description":"Delete a jury member by ID","responses":{"204":{"description":"Jury member deleted successfully"},"403":{"description":"Missing permission to manage jury members"},"404":{"description":"Jury member not found"}},"security":[{"session":[]}]}},"/event/{event_id}/tasks":{"get":{"operationId":"TasksController.index","tags":["Tasks"],"description":"Get a list of tasks for an event","responses":{"200":{"description":"A list of tasks","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Task"}}}}},"403":{"description":"Missing permission to view this event"}}}},"/event/{event_id}/task":{"post":{"operationId":"TasksController.store","tags":["Tasks"],"description":"Create a new task for an event","responses":{"201":{"description":"The newly created task","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Task"}}}},"403":{"description":"Missing permission to create tasks"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","minLength":3,"maxLength":255},"description":{"type":"string"},"taskType":{"enum":["HACKATHON","CTF","ALGO"]},"status":{"enum":["DRAFT","ACTIVE","ARCHIVED"]},"autoregister":{"enum":["1",1,"true",true,"on","0",0,"false",false]},"slug":{"type":"string","pattern":"^[a-z0-9-]+$"},"resultsPublishedAt":{"type":"null"},"registrationStartAt":{"type":"null"},"registrationEndAt":{"type":"null"},"detailsRevealAt":{"type":"null"},"submissionsStartAt":{"type":"null"},"submissionsEndAt":{"type":"null"},"requirementsDocumentUrl":{"type":"string","format":"uri"}},"required":["title","description","taskType","slug"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/tasks/{task_id}/scores":{"get":{"operationId":"ScoresController.indexCriteria","tags":["Scores"],"description":"Get a list of scoring criteria for a task","responses":{"200":{"description":"A list of scoring criteria","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ScoringCriterion"}}}}},"403":{"description":"Missing permission to view this task"},"404":{"description":"Task not found"}},"security":[{"session":[]}]},"post":{"operationId":"ScoresController.storeCriteria","tags":["Scores"],"description":"Create a new scoring criterion for a task","responses":{"201":{"description":"The newly created scoring criterion","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScoringCriterion"}}}},"403":{"description":"Missing permission to edit this task"},"404":{"description":"Task not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"category":{"type":"string","minLength":1,"maxLength":255},"maximumScore":{"type":"integer","minimum":1},"weight":{"type":"number","minimum":0,"maximum":100},"description":{"type":["string","null"]}},"required":["category","maximumScore","weight"],"additionalProperties":false}}}},"security":[{"session":[]}]}},"/tasks/{task_id}/scores/{id}":{"get":{"operationId":"ScoresController.showCriteria","tags":["Scores"],"description":"Get a specific scoring criterion by ID","responses":{"200":{"description":"The requested scoring criterion","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScoringCriterion"}}}},"403":{"description":"Missing permission to view this task"},"404":{"description":"Scoring criterion not found"}},"security":[{"session":[]}]},"put":{"operationId":"ScoresController.updateCriteria","tags":["Scores"],"description":"Update a scoring criterion by ID","responses":{"200":{"description":"The updated scoring criterion","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScoringCriterion"}}}},"403":{"description":"Missing permission to edit this task"},"404":{"description":"Scoring criterion not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"category":{"type":"string","minLength":1,"maxLength":255},"maximumScore":{"type":"integer","minimum":1},"weight":{"type":"number","minimum":0,"maximum":100},"description":{"type":["string","null"]}},"required":[],"additionalProperties":false}}}},"security":[{"session":[]}]},"patch":{"operationId":"ScoresController.updateCriteria","tags":["Scores"],"description":"Update a scoring criterion by ID","responses":{"200":{"description":"The updated scoring criterion","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScoringCriterion"}}}},"403":{"description":"Missing permission to edit this task"},"404":{"description":"Scoring criterion not found"}},"parameters":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"category":{"type":"string","minLength":1,"maxLength":255},"maximumScore":{"type":"integer","minimum":1},"weight":{"type":"number","minimum":0,"maximum":100},"description":{"type":["string","null"]}},"required":[],"additionalProperties":false}}}},"security":[{"session":[]}]},"delete":{"operationId":"ScoresController.destroyCriteria","tags":["Scores"],"description":"Delete a scoring criterion by ID","responses":{"204":{"description":"Scoring criterion deleted successfully"},"403":{"description":"Missing permission to edit this task"},"404":{"description":"Scoring criterion not found"}},"security":[{"session":[]}]}}},"components":{"schemas":{"User":{"type":"object","properties":{"id":{"type":"number"},"nickname":{"example":"JohnnyBravo","type":"string"},"name":{"example":"John","type":"string"},"surname":{"example":"Bravo","type":"string"},"email":{"example":"johnny.bravo@example.com","type":"string"},"avatarUrl":{"example":"https://example.com/avatar.png","type":"string"},"permissions":{"format":"binary","type":"number"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","nickname","email","permissions","createdAt","updatedAt"]},"Event":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"slug":{"example":"best-hackathon-ever","type":"string"},"title":{"example":"Awesome Hackathon","type":"string"},"description":{"example":"A first ever hackathon using a modern platform","type":"string"},"status":{"example":"ACTIVE","enum":["DRAFT","ACTIVE","ARCHIVED"]},"minTeamSize":{"type":"number"},"maxTeamSize":{"example":4,"type":"number"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","slug","title","description","status","minTeamSize","maxTeamSize","createdAt","updatedAt"]},"EventAdministrator":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08a","type":"string"},"eventId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"userId":{"type":"number"},"permissions":{"format":"binary","type":"number"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","eventId","userId","permissions","createdAt","updatedAt"]},"Team":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"eventId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"name":{"example":"Team Awesome","type":"string"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","eventId","name","createdAt","updatedAt"]},"TeamMember":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"teamId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"userId":{"example":1,"type":"number"},"permissions":{"format":"binary","type":"number"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","teamId","userId","permissions","createdAt","updatedAt"]},"PaginationMeta":{"type":"object","properties":{"total":{"type":"number"},"perPage":{"example":25,"type":"number"},"currentPage":{"type":"number"},"lastPage":{"example":3,"type":"number"},"firstPage":{"type":"number"},"firstPageUrl":{"example":"/data?page=1","type":"string"},"lastPageUrl":{"example":"/data?page=3","type":"string"},"nextPageUrl":{"example":"/data?page=2","type":"string"},"previousPageUrl":{"example":null,"type":"string"}},"required":["total","perPage","currentPage","lastPage","firstPage","firstPageUrl","lastPageUrl"]},"TeamInvitation":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"teamId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"inviterId":{"example":1,"type":"number"},"inviteeEmail":{"example":"user@example.com","type":"string"},"token":{"example":"secure-token-here","type":"string"},"status":{"example":"PENDING","enum":["PENDING","ACCEPTED","DECLINED","FAILED","EXPIRED"]},"expiresAt":{"format":"date-time","type":"string"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","teamId","inviterId","token","status","createdAt","updatedAt"]},"Organization":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"name":{"example":"Acme Corporation","type":"string"},"description":{"example":"A leading corporation","type":"string"},"logoUrl":{"example":"https://example.com/logo.png","type":"string"},"websiteUrl":{"example":"https://example.com","type":"string"},"eventId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","name","description","eventId","createdAt","updatedAt"]},"Sponsor":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"taskId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"organizationId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"createdAt":{"format":"date-time","type":"string"}},"required":["id","taskId","organizationId","createdAt"]},"Task":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"eventId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"slug":{"example":"awesome-hackathon-task","type":"string"},"title":{"example":"Awesome Hackathon Task","type":"string"},"description":{"example":"Build an awesome application","type":"string"},"taskType":{"example":"HACKATHON","type":"string"},"status":{"example":"ACTIVE","type":"string"},"autoregister":{"example":false,"type":"boolean"},"resultsPublishedAt":{"format":"date-time","type":"string"},"registrationStartAt":{"format":"date-time","type":"string"},"registrationEndAt":{"format":"date-time","type":"string"},"detailsRevealAt":{"format":"date-time","type":"string"},"submissionsStartAt":{"format":"date-time","type":"string"},"submissionsEndAt":{"format":"date-time","type":"string"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","eventId","slug","title","description","taskType","status","autoregister","createdAt","updatedAt"]},"TaskRegistration":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"teamId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"taskId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"createdAt":{"format":"date-time","type":"string"}},"required":["id","teamId","taskId","createdAt"]},"HackathonTask":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"taskId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"requirementsDocumentUrl":{"example":"https://example.com/requirements.pdf","type":"string"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","taskId","requirementsDocumentUrl","createdAt","updatedAt"]},"HackathonTaskSubmission":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"taskRegistrationId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"description":{"example":"This is our submission description","type":"string"},"repositoryUrl":{"example":"https://github.com/user/repo","type":"string"},"demoUrl":{"example":"https://example.com/demo","type":"string"},"status":{"example":"ACTIVE","type":"string"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","taskRegistrationId","status","createdAt","updatedAt"]},"JuryMember":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"description":{"example":"Senior judge with 10 years experience","type":"string"},"userId":{"example":1,"type":"number"},"taskId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"organizationId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","description","userId","taskId","createdAt","updatedAt"]},"ScoringCriterion":{"type":"object","properties":{"id":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"taskId":{"example":"d62a1715-6b7a-4b40-8bdd-4a10f2ceb08c","type":"string"},"category":{"example":"Code Quality","type":"string"},"description":{"example":"Assessment of code quality and best practices","type":"string"},"maximumScore":{"example":100,"type":"number"},"weight":{"example":1.5,"type":"number"},"createdAt":{"format":"date-time","type":"string"},"updatedAt":{"format":"date-time","type":"string"}},"required":["id","taskId","category","maximumScore","weight","createdAt","updatedAt"]}},"responses":{},"parameters":{},"examples":{},"requestBodies":{},"headers":{},"securitySchemes":{"session":{"type":"apiKey","in":"cookie","name":"xcontest-session"}},"links":{},"callbacks":{}},"tags":[{"name":"Auth","description":"Endpoints provided by AuthController"},{"name":"Events","description":"Endpoints provided by EventsController"},{"name":"Teams","description":"Endpoints provided by TeamsController"},{"name":"Organizations","description":"Endpoints provided by OrganizationsController"},{"name":"Tasks","description":"Endpoints provided by TasksController"},{"name":"Hackathons","description":"Endpoints provided by HackathonsController"},{"name":"Scores","description":"Endpoints provided by ScoresController"}],"servers":[]} \ No newline at end of file diff --git a/resources/views/events/invite.edge b/resources/views/events/invite.edge index 45b693b..a2f3d09 100644 --- a/resources/views/events/invite.edge +++ b/resources/views/events/invite.edge @@ -155,10 +155,10 @@

{{branding.name}}

- +

You've been invited

- +

Hi {{ invitee.name }},

{{ inviter.name }} has invited you to participate in an upcoming event on xContest.

@@ -204,9 +204,9 @@
- \ No newline at end of file + diff --git a/resources/views/user/password_reset.edge b/resources/views/user/password_reset.edge new file mode 100644 index 0000000..61f2240 --- /dev/null +++ b/resources/views/user/password_reset.edge @@ -0,0 +1,146 @@ + + + + + + + + +
+
+
+

{{ branding.name }}

+
+ +
+

Reset your password

+ +
+

Hi {{ user.name }},

+

We received a request to reset the password for your {{ branding.name }} account. Click the button below to choose a new one.

+
+ +
+ Reset Password +
+ This link will expire on {{ linkExpiryTime }}. +
+
+ +
+ Didn't request this? If you didn't mean to reset your password, you can safely ignore this email. Your password will remain unchanged. +
+
+
+ + +
+ + diff --git a/start/mail.ts b/start/mail.ts index 06f7a37..9b0d011 100644 --- a/start/mail.ts +++ b/start/mail.ts @@ -5,20 +5,20 @@ * _> ({ async queue(mailMessage, config) { + if (app.inTest) + return + await emailsQueue.add('send_email', { mailMessage, config, @@ -50,4 +53,4 @@ mail.setMessenger((mailer) => ({ app.terminating(async () => { await emailsQueue.close() -}) \ No newline at end of file +}) diff --git a/start/routes.ts b/start/routes.ts index c8b76a7..36a0b45 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -79,6 +79,9 @@ router.group(() => { router.post('login', [AuthController, 'login']) router.post('logout', [AuthController, 'logout']).use(middleware.auth()) + + router.post('forgot-password', [AuthController, 'forgotPassword']) + router.post('reset-password', [AuthController, 'resetPassword']) }).prefix('auth') const EventsController = () => import('#controllers/events_controller') diff --git a/tests/functional/auth/login.spec.ts b/tests/functional/auth/login.spec.ts index ef3088e..6f7ece1 100644 --- a/tests/functional/auth/login.spec.ts +++ b/tests/functional/auth/login.spec.ts @@ -23,24 +23,27 @@ import testUtils from '@adonisjs/core/services/test_utils' import { test } from '@japa/runner' +import User from '#models/user' +import { generateSecureToken } from '#utils/teams' +import { DateTime } from 'luxon' test.group('Auth login', (group) => { group.each.setup(() => testUtils.db().seed()) group.each.teardown(() => testUtils.db().truncate()) - test('logs in successfully with correct credentials', async ({ client, assert }) => { + test('Logs in successfully with correct credentials', async ({ client, assert }) => { const response = await client.post('/auth/login').json({ email: 'user@local.host', password: 'userpassword', }) - response.assertStatus(200) + response.assertOk() assert.equal(response.body().message, 'Login successful') assert.exists(response.body().user) assert.exists(response.cookie('xcontest-session')) }) - test('fails with incorrect password', async ({ client }) => { + test('Fails with incorrect password', async ({ client }) => { const response = await client.post('/auth/login').json({ email: 'user@local.host', password: 'wrongpassword', @@ -49,7 +52,7 @@ test.group('Auth login', (group) => { response.assertStatus(400) }) - test('fails with non-existent email', async ({ client }) => { + test('Fails with non-existent email', async ({ client }) => { const response = await client.post('/auth/login').json({ email: 'user@no.host', password: 'testtesttest', @@ -58,10 +61,64 @@ test.group('Auth login', (group) => { response.assertStatus(400) }) - test('fails when required fields are missing', async ({ client }) => { + test('Fails when required fields are missing', async ({ client }) => { const response = await client.post('/auth/login').json({ email: '', }) - response.assertStatus(422) + response.assertUnprocessableEntity() + }) + + test('User can request password change and receive token', async ({ client }) => { + const response = await client.post('/auth/forgot-password').json({ + email: 'user@local.host', + }) + + response.assertOk() + }) + + test('User can change password using valid token', async ({ assert, client }) => { + const user = await User.findByOrFail('email', 'user@local.host') + user.passwordResetToken = generateSecureToken() + user.passwordResetExpires = DateTime.now().plus({ hours: 1 }) + await user.save() + + const response = await client.post(`/auth/reset-password?token=${user.passwordResetToken}`).json({ + newPassword: 'abcdef#', + newPasswordConfirm: 'abcdef#', + }) + + response.assertOk() + const verified = await User.verifyCredentials(user.email, 'abcdef#') + assert.exists(verified) + }) + + test('User cannot change password using invalid token', async ({ assert, client }) => { + const user = await User.findByOrFail('email', 'user@local.host') + user.passwordResetToken = generateSecureToken() + user.passwordResetExpires = DateTime.now().plus({ hours: 1 }) + await user.save() + + const response = await client.post('/auth/reset-password?token=helloworld').json({ + newPassword: 'abcdef#', + newPasswordConfirm: 'abcdef#', + }) + + response.assertBadRequest() + await assert.rejects(() => User.verifyCredentials(user.email, 'abcdef#')) + }) + + test('User cannot change password using expired token', async ({ assert, client }) => { + const user = await User.findByOrFail('email', 'user@local.host') + user.passwordResetToken = generateSecureToken() + user.passwordResetExpires = DateTime.now().minus({ minutes: 1 }) + await user.save() + + const response = await client.post(`/auth/reset-password?token=${user.passwordResetToken}`).json({ + newPassword: 'abcdef#', + newPasswordConfirm: 'abcdef#', + }) + + response.assertBadRequest() + await assert.rejects(() => User.verifyCredentials(user.email, 'abcdef#')) }) }) diff --git a/workers/mail.ts b/workers/mail.ts index 50ab30a..746109c 100644 --- a/workers/mail.ts +++ b/workers/mail.ts @@ -5,26 +5,27 @@ * _> { if (job.name === 'send_email') { const { mailMessage, config, mailerName } = job.data - await mail.use(mailerName).sendCompiled(mailMessage, config) + try { + await mail.use(mailerName).sendCompiled(mailMessage, config) + } catch (error) { + logger.error(error) + } } }, { connection: redisConfig.connections.main }, @@ -43,4 +48,4 @@ const worker = new Worker( app.terminating(async () => { await worker.close() -}) \ No newline at end of file +})