Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,12 @@ public function rsvp(): static
'enrollment_method' => EnrollmentMethod::Rsvp,
]);
}

public function application(?array $schema = null): static
{
return $this->state(fn (): array => [
'enrollment_method' => EnrollmentMethod::Application,
'application_schema' => $schema,
]);
}
}
36 changes: 36 additions & 0 deletions app-modules/events/database/seeders/EventsSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ private function matrix(): array
'minimum_days' => 2,
'cancellation_deadline_hours' => 48,
'xp' => [200, 300, 1000],
'application_schema' => [
['type' => 'text', 'label' => 'Por que quer participar da He4rt Conf?', 'required' => true],
['type' => 'select', 'label' => 'Nível de experiência em desenvolvimento', 'required' => true, 'options' => ['Iniciante', 'Intermediário', 'Avançado']],
['type' => 'textarea', 'label' => 'Descreva um projeto pessoal ou open source relevante', 'required' => false],
['type' => 'checkbox', 'label' => 'Em quais dias você pode comparecer?', 'required' => false, 'options' => ['Dia 1', 'Dia 2', 'Dia 3']],
],
'enrollments' => [
EnrollmentStatus::Pending->value => 6,
EnrollmentStatus::Confirmed->value => 12,
Expand Down Expand Up @@ -258,6 +264,10 @@ private function matrix(): array
'minimum_days' => null,
'cancellation_deadline_hours' => 48,
'xp' => [100, 200, 800],
'application_schema' => [
['type' => 'text', 'label' => 'Qual ideia de projeto você traria para o hackathon?', 'required' => true],
['type' => 'select', 'label' => 'Stack principal', 'required' => true, 'options' => ['PHP', 'JavaScript', 'Python', 'Go', 'Rust', 'Outra']],
],
'enrollments' => [
EnrollmentStatus::Cancelled->value => 8,
],
Expand All @@ -279,6 +289,7 @@ private function seedPolicy(Event $event, array $spec): void
'xp_on_confirmed' => $spec['xp'][0],
'xp_on_checked_in' => $spec['xp'][1],
'xp_on_attended' => $spec['xp'][2],
'application_schema' => $spec['application_schema'] ?? null,
]);
}

Expand Down Expand Up @@ -307,6 +318,7 @@ private function seedEnrollments(Event $event, array $spec, Collection $particip
'status' => $status,
'waitlist_position' => $status === EnrollmentStatus::Waitlisted ? $waitlistPosition++ : null,
'rejection_reason' => $status === EnrollmentStatus::Rejected ? 'Vagas esgotadas.' : null,
'application_data' => $this->fakeApplicationData($spec['application_schema'] ?? null),
...$this->enrollmentTimestamps($status),
]);
}
Expand Down Expand Up @@ -407,6 +419,30 @@ private function enrollmentTimestamps(EnrollmentStatus $status): array
};
}

/**
* @param array<int, array<string, mixed>>|null $schema
* @return array<int, mixed>|null
*/
private function fakeApplicationData(?array $schema): ?array
{
if ($schema === null) {
return null;
}

$data = [];

foreach ($schema as $index => $field) {
$data[$index] = match ($field['type'] ?? 'text') {
'select' => fake()->randomElement($field['options'] ?? ['Opção A']),
'checkbox' => blank($field['options']) ? [] : fake()->randomElements($field['options'], fake()->numberBetween(1, count($field['options']))),
'textarea' => fake()->paragraph(),
default => fake()->sentence(),
};
}

return $data;
}

private function resolveTenant(): Tenant
{
return Tenant::query()->where('slug', 'he4rt')->first()
Expand Down
2 changes: 2 additions & 0 deletions app-modules/events/lang/en/exceptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@
'override_reason_required' => 'A reason is required when overriding an enrollment status.',
'override_not_allowed' => 'Override from :from to :to is not allowed.',
'override_status_changed' => 'Enrollment status changed from :expected to :actual before the override was saved. Review the current status and try again.',
'enrollment_not_pending' => 'This enrollment is not pending and cannot be approved or rejected.',
'application_data_invalid' => 'The submitted application data is invalid or missing required answers.',
];
19 changes: 19 additions & 0 deletions app-modules/events/lang/en/pages.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,23 @@
'no_check_ins_yet' => 'No check-ins recorded yet.',
'enter_check_in_code_hint' => 'Enter the check-in code announced by the organizer.',
'check_in' => 'Check In',
'apply_hint' => 'Fill in the form below to apply for this event.',
'apply_submit' => 'Submit Application',
'application_submitted' => 'Your application has been submitted and is pending review.',
'application_pending_hint' => 'Your application is under review. You will be notified once it is evaluated.',
'application_your_answers' => 'Your Answers',
'application_rejected_hint' => 'Your application was not accepted.',
'application_rejection_reason' => 'Reason: :reason',
'apply_select_option_placeholder' => 'Select an option',
'admin_approve_application' => 'Approve',
'admin_approve_application_modal_heading' => 'Approve Application',
'admin_approve_application_modal_description' => 'Approve the application from :name?',
'admin_approve_application_success' => 'Application approved.',
'admin_reject_application' => 'Reject',
'admin_reject_application_reason_label' => 'Rejection Reason',
'admin_reject_application_success' => 'Application rejected.',
'admin_application_no_data' => 'No application data available.',
'admin_application_answer_yes' => 'Yes',
'admin_application_answer_no' => 'No',
'admin_application_no_answer' => 'No answer',
];
2 changes: 2 additions & 0 deletions app-modules/events/lang/pt_BR/exceptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@
'override_reason_required' => 'É obrigatório informar um motivo ao sobrescrever o status de uma inscrição.',
'override_not_allowed' => 'Sobrescrita de :from para :to não é permitida.',
'override_status_changed' => 'O status da inscrição mudou de :expected para :actual antes da sobrescrita ser salva. Revise o status atual e tente novamente.',
'enrollment_not_pending' => 'Esta inscrição não está pendente e não pode ser aprovada ou rejeitada.',
'application_data_invalid' => 'Os dados enviados na candidatura são inválidos ou faltam respostas obrigatórias.',
];
19 changes: 19 additions & 0 deletions app-modules/events/lang/pt_BR/pages.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,23 @@
'no_check_ins_yet' => 'Nenhum check-in registrado ainda.',
'enter_check_in_code_hint' => 'Insira o código de check-in informado pelo organizador.',
'check_in' => 'Fazer Check-in',
'apply_hint' => 'Preencha o formulário abaixo para se candidatar a este evento.',
'apply_submit' => 'Enviar Candidatura',
'application_submitted' => 'Sua candidatura foi enviada e está aguardando avaliação.',
'application_pending_hint' => 'Sua candidatura está em análise. Você será notificado quando for avaliada.',
'application_your_answers' => 'Suas Respostas',
'application_rejected_hint' => 'Sua candidatura não foi aceita.',
'application_rejection_reason' => 'Motivo: :reason',
'apply_select_option_placeholder' => 'Selecione uma opção',
'admin_approve_application' => 'Aprovar',
'admin_approve_application_modal_heading' => 'Aprovar Candidatura',
'admin_approve_application_modal_description' => 'Aprovar a candidatura de :name?',
'admin_approve_application_success' => 'Candidatura aprovada.',
'admin_reject_application' => 'Rejeitar',
'admin_reject_application_reason_label' => 'Motivo da Rejeição',
'admin_reject_application_success' => 'Candidatura rejeitada.',
'admin_application_no_data' => 'Nenhum dado de candidatura disponível.',
'admin_application_answer_yes' => 'Sim',
'admin_application_answer_no' => 'Não',
'admin_application_no_answer' => 'Sem resposta',
];
106 changes: 106 additions & 0 deletions app-modules/events/src/Enrollment/Actions/ApproveApplicationAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);

namespace He4rt\Events\Enrollment\Actions;

use He4rt\Events\Enrollment\DTOs\ApproveApplicationDTO;
use He4rt\Events\Enrollment\DTOs\TransitionEnrollmentDTO;
use He4rt\Events\Enrollment\Enums\EnrollmentStatus;
use He4rt\Events\Enrollment\Enums\TriggeredBy;
use He4rt\Events\Enrollment\Events\EnrollmentConfirmed;
use He4rt\Events\Enrollment\Events\EnrollmentWaitlisted;
use He4rt\Events\Enrollment\Exceptions\EnrollmentException;
use He4rt\Events\Enrollment\Models\Enrollment;
use He4rt\Events\Enrollment\Models\EnrollmentPolicy;
use Illuminate\Support\Facades\DB;

final readonly class ApproveApplicationAction
{
public function __construct(
private TransitionEnrollmentAction $transitionEnrollment,
) {}

public function handle(ApproveApplicationDTO $dto): Enrollment
{
return DB::transaction(function () use ($dto): Enrollment {
$enrollment = Enrollment::query()
->whereKey($dto->enrollmentId)
->lockForUpdate()
->firstOrFail();

throw_unless(
$enrollment->status === EnrollmentStatus::Pending,
EnrollmentException::enrollmentNotPending(),
);

$policy = EnrollmentPolicy::query()
->where('event_id', $enrollment->event_id)
->lockForUpdate()
->firstOrFail();

$toStatus = $this->resolveApprovalStatus($enrollment->event_id, $policy);

$waitlistPosition = null;
if ($toStatus === EnrollmentStatus::Waitlisted) {
$waitlistPosition = (int) Enrollment::query()
->where('event_id', $enrollment->event_id)
->waitlisted()
->max('waitlist_position') + 1;

$enrollment->update(['waitlist_position' => $waitlistPosition]);
}

$this->transitionEnrollment->handle(new TransitionEnrollmentDTO(
enrollment: $enrollment,
toStatus: $toStatus,
triggeredBy: TriggeredBy::Admin,
actorId: $dto->actorId,
));

if ($toStatus->isConfirmed()) {
event(new EnrollmentConfirmed(
enrollmentId: $enrollment->id,
eventId: $enrollment->event_id,
userId: $enrollment->user_id,
xpRewardOnConfirmed: $policy->xp_on_confirmed ?? 0,
));
}

if ($toStatus->isWaitlisted()) {
event(new EnrollmentWaitlisted(
enrollmentId: $enrollment->id,
eventId: $enrollment->event_id,
userId: $enrollment->user_id,
waitlistPosition: $waitlistPosition,
));
}

return $enrollment->fresh(['event.enrollmentPolicy']);
});
}

private function resolveApprovalStatus(string $eventId, EnrollmentPolicy $policy): EnrollmentStatus
{
$capacity = $policy->capacity;

if ($capacity === null) {
return EnrollmentStatus::Confirmed;
}

$occupiedCount = Enrollment::query()
->where('event_id', $eventId)
->active()
->count();

if ($occupiedCount < $capacity) {
return EnrollmentStatus::Confirmed;
}

if ($policy->has_waitlist) {
return EnrollmentStatus::Waitlisted;
}

throw EnrollmentException::eventFull();
}
}
73 changes: 69 additions & 4 deletions app-modules/events/src/Enrollment/Actions/EnrollUserAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public function handle(EnrollUserDTO $dto): Enrollment
return DB::transaction(function () use ($dto): Enrollment {
[$event, $policy] = $this->loadLockedEnrollmentContext($dto->eventId);

$this->validate($event, $policy, $dto->userId);
$this->validate($event, $policy, $dto->userId, $dto->applicationData);

$initial = $this->resolveInitialEnrollment($dto->eventId, $policy);

Expand All @@ -39,6 +39,7 @@ public function handle(EnrollUserDTO $dto): Enrollment
'enrolled_at' => now(),
'confirmed_at' => $initial['confirmedAt'],
'waitlist_position' => $initial['waitlistPosition'],
'application_data' => $dto->applicationData,
]);

EnrollmentTransition::query()->create([
Expand Down Expand Up @@ -103,6 +104,14 @@ private function loadLockedEnrollmentContext(string $eventId): array
*/
private function resolveInitialEnrollment(string $eventId, ?EnrollmentPolicy $policy): array
{
if ($policy?->enrollment_method === EnrollmentMethod::Application) {
return [
'status' => EnrollmentStatus::Pending,
'waitlistPosition' => null,
'confirmedAt' => null,
];
}

$capacity = $policy?->capacity;

if ($capacity === null) {
Expand Down Expand Up @@ -142,7 +151,10 @@ private function resolveInitialEnrollment(string $eventId, ?EnrollmentPolicy $po
throw EnrollmentException::eventFull();
}

private function validate(Event $event, ?EnrollmentPolicy $policy, string $userId): void
/**
* @param array<int, mixed>|null $applicationData
*/
private function validate(Event $event, ?EnrollmentPolicy $policy, string $userId, ?array $applicationData): void
{
throw_unless(
$event->status === EventStatus::Published,
Expand All @@ -154,10 +166,12 @@ private function validate(Event $event, ?EnrollmentPolicy $policy, string $userI
EnrollmentException::eventPast(),
);

$method = $policy?->enrollment_method;

throw_unless(
in_array(
$policy?->enrollment_method,
[EnrollmentMethod::Rsvp, EnrollmentMethod::RsvpCheckin],
$method,
[EnrollmentMethod::Rsvp, EnrollmentMethod::RsvpCheckin, EnrollmentMethod::Application],
strict: true,
),
EnrollmentException::invalidEnrollmentMethod(),
Expand All @@ -170,5 +184,56 @@ private function validate(Event $event, ?EnrollmentPolicy $policy, string $userI
->exists(),
EnrollmentException::alreadyEnrolled(),
);

if ($method === EnrollmentMethod::Application) {
throw_if(
$applicationData === null,
EnrollmentException::applicationDataInvalid(),
);

$this->validateApplicationData($applicationData, $policy->application_schema ?? []);
}
}

/**
* @param array<int, mixed> $data
* @param array<int, array<string, mixed>> $schema
*/
private function validateApplicationData(array $data, array $schema): void
{
foreach ($schema as $index => $field) {
$value = $data[$index] ?? null;
$type = $field['type'] ?? '';

if ($type === 'checkbox') {
$selected = is_array($value) ? $value : [];

throw_if(
($field['required'] ?? false) && $selected === [],
EnrollmentException::applicationDataInvalid(),
);

$options = $field['options'] ?? [];
foreach ($selected as $option) {
throw_unless(
in_array($option, $options, strict: true),
EnrollmentException::applicationDataInvalid(),
);
}

continue;
}

if (($field['required'] ?? false) && (in_array($value, [null, '', false], true))) {
throw EnrollmentException::applicationDataInvalid();
}

if ($type === 'select' && $value !== null) {
$options = $field['options'] ?? [];
if (!in_array($value, $options, strict: true)) {
throw EnrollmentException::applicationDataInvalid();
}
}
}
}
}
Loading