Added services

This commit moves most controller logic onto Services. Services are part of the Service-Repository pattern. The models act as repositories.

Services are easily testable and are needed for the upcoming API, in order to avoid duplicated code and to maintain a single source of "truth".

 The User, Vacancy and Vote controllers still need their logic moved onto services.
This commit is contained in:
2021-07-25 22:54:15 +01:00
parent c739933668
commit 8942623bde
44 changed files with 1308 additions and 691 deletions

View File

@@ -0,0 +1,158 @@
<?php
namespace App\Services;
use ContextAwareValidator;
use App\Application;
use App\Events\ApplicationDeniedEvent;
use App\Exceptions\ApplicationNotFoundException;
use App\Exceptions\IncompleteApplicationException;
use App\Exceptions\UnavailableApplicationException;
use App\Exceptions\VacancyNotFoundException;
use App\Notifications\ApplicationMoved;
use App\Notifications\NewApplicant;
use App\Response;
use App\User;
use App\Vacancy;
use Illuminate\Auth\Authenticatable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class ApplicationService
{
public function renderForm($vacancySlug)
{
$vacancyWithForm = Vacancy::with('forms')->where('vacancySlug', $vacancySlug)->get();
$firstVacancy = $vacancyWithForm->first();
if (!$vacancyWithForm->isEmpty() && $firstVacancy->vacancyCount !== 0 && $firstVacancy->vacancyStatus == 'OPEN') {
return view('dashboard.application-rendering.apply')
->with([
'vacancy' => $vacancyWithForm->first(),
'preprocessedForm' => json_decode($vacancyWithForm->first()->forms->formStructure, true),
]);
} else {
throw new ApplicationNotFoundException('The application you\'re looking for could not be found or it is currently unavailable.', 404);
}
}
/**
* Fills a vacancy's form with submitted data.
*
* @throws UnavailableApplicationException Thrown when the application has no vacancies or is closed
* @throws VacancyNotFoundException Thrown when the associated vacancy is not found
* @throws IncompleteApplicationException Thrown when there are missing fields
*/
public function fillForm(Authenticatable $applicant, array $formData, $vacancySlug): bool
{
$vacancy = Vacancy::with('forms')->where('vacancySlug', $vacancySlug)->get();
if ($vacancy->isEmpty()) {
throw new VacancyNotFoundException('This vacancy doesn\'t exist; Please use the proper buttons to apply to one.', 404);
}
if ($vacancy->first()->vacancyCount == 0 || $vacancy->first()->vacancyStatus !== 'OPEN') {
throw new UnavailableApplicationException("This application is unavailable.");
}
Log::info('Processing new application!');
$formStructure = json_decode($vacancy->first()->forms->formStructure, true);
$responseValidation = ContextAwareValidator::getResponseValidator($formData, $formStructure);
Log::info('Built response & validator structure!');
if (!$responseValidation->get('validator')->fails()) {
$response = Response::create([
'responseFormID' => $vacancy->first()->forms->id,
'associatedVacancyID' => $vacancy->first()->id, // Since a form can be used by multiple vacancies, we can only know which specific vacancy this response ties to by using a vacancy ID
'responseData' => $responseValidation->get('responseStructure'),
]);
Log::info('Registered form response!', [
'applicant' => $applicant->name,
'vacancy' => $vacancy->first()->vacancyName
]);
$application = Application::create([
'applicantUserID' => $applicant->id,
'applicantFormResponseID' => $response->id,
'applicationStatus' => 'STAGE_SUBMITTED',
]);
Log::info('Submitted an application!', [
'responseID' => $response->id,
'applicant' => $applicant->name
]);
foreach (User::all() as $user) {
if ($user->hasRole('admin')) {
$user->notify((new NewApplicant($application, $vacancy->first()))->delay(now()->addSeconds(10)));
}
}
return true;
}
Log::warning('Application form for ' . $applicant->name . ' contained errors, resetting!');
throw new IncompleteApplicationException('There are one or more errors in your application. Please make sure none of your fields are empty, since they are all required.');
}
public function updateStatus(Application $application, $newStatus)
{
switch ($newStatus) {
case 'deny':
event(new ApplicationDeniedEvent($application));
$message = __("Application denied successfully.");
break;
case 'interview':
Log::info(' Moved application ID ' . $application->id . 'to interview stage!');
$message = __('Application moved to interview stage!');
$application->setStatus('STAGE_INTERVIEW');
$application->user->notify(new ApplicationMoved());
break;
default:
throw new \LogicException("Wrong status parameter. Please notify a developer.");
}
return $message;
}
/**
* @throws \Exception
*/
public function delete(Application $application): ?bool
{
return $application->delete();
}
public function canVote($votes): bool
{
$allvotes = collect([]);
foreach ($votes as $vote) {
if ($vote->userID == Auth::user()->id) {
$allvotes->push($vote);
}
}
return !(($allvotes->count() == 1));
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Services;
use App\Application;
use App\Appointment;
use App\Exceptions\InvalidAppointmentStatusException;
use App\Notifications\ApplicationMoved;
use App\Notifications\AppointmentScheduled;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class AppointmentService
{
private $allowedPlatforms = [
'ZOOM',
'DISCORD',
'SKYPE',
'MEET',
'TEAMSPEAK',
];
public function createAppointment(Application $application, Carbon $appointmentDate, $appointmentDescription, $appointmentLocation)
{
$appointment = Appointment::create([
'appointmentDescription' => $appointmentDescription,
'appointmentDate' => $appointmentDate->toDateTimeString(),
'applicationID' => $application->id,
'appointmentLocation' => (in_array($appointmentLocation, $this->allowedPlatforms)) ? $appointmentLocation : 'DISCORD',
]);
$application->setStatus('STAGE_INTERVIEW_SCHEDULED');
Log::info('User '.Auth::user()->name.' has scheduled an appointment with '.$application->user->name.' for application ID'.$application->id, [
'datetime' => $appointmentDate->toDateTimeString(),
'scheduled' => now(),
]);
$application->user->notify(new AppointmentScheduled($appointment));
return true;
}
/**
* Updates the appointment with the new $status.
* It also sets the application's status to peer approval.
*
* Set $updateApplication to false to only update its status
*
* @throws InvalidAppointmentStatusException
*/
public function updateAppointment(Application $application, $status, $updateApplication = true)
{
$validStatuses = [
'SCHEDULED',
'CONCLUDED',
];
if ($status == 'SCHEDULED' || $status == 'CONCLUDED')
{
$application->appointment->appointmentStatus = strtoupper($status);
$application->appointment->save();
if ($updateApplication)
{
$application->setStatus('STAGE_PEERAPPROVAL');
$application->user->notify(new ApplicationMoved());
}
}
else
{
throw new InvalidAppointmentStatusException("Invalid appointment status!");
}
}
/**
* @return string[]
*/
public function getAllowedPlatforms(): array
{
return $this->allowedPlatforms;
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Services;
use App\Application;
use App\Comment;
use Illuminate\Support\Facades\Auth;
class CommentService
{
public function addComment(Application $application, $comment): Comment {
return Comment::create([
'authorID' => Auth::user()->id,
'applicationID' => $application->id,
'text' => $comment,
]);
}
public function deleteComment(Comment $comment): ?bool
{
return $comment->delete();
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Services;
use App\Exceptions\InvalidGamePreferenceException;
use App\Exceptions\OptionNotFoundException;
use App\Facades\Options;
use Illuminate\Auth\Authenticatable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class ConfigurationService
{
/**
* @throws OptionNotFoundException|\Exception
*
*/
public function saveConfiguration($configuration) {
foreach ($configuration as $optionName => $option) {
try {
Log::debug('Going through option '.$optionName);
if (Options::optionExists($optionName)) {
Log::debug('Option exists, updating to new values', [
'opt' => $optionName,
'new_value' => $option,
]);
Options::changeOption($optionName, $option);
}
} catch (\Exception $ex) {
Log::error('Unable to update options!', [
'msg' => $ex->getMessage(),
'trace' => $ex->getTraceAsString(),
]);
// Let service caller handle this without failing here
throw $ex;
}
}
}
/**
* Saves the chosen game integration
*
* @throws InvalidGamePreferenceException
* @returns bool
*/
public function saveGameIntegration($gamePreference): bool
{
// TODO: Find solution to dynamically support games
$supportedGames = [
'RUST',
'MINECRAFT',
'SE',
'GMOD'
];
if (!is_null($gamePreference) && in_array($gamePreference, $supportedGames))
{
Options::changeOption('currentGame', $gamePreference);
return true;
}
throw new InvalidGamePreferenceException("Unsupported game " . $gamePreference);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Services;
use App\Exceptions\FailedCaptchaException;
use App\Notifications\NewContact;
use App\User;
use Illuminate\Support\Facades\Http;
class ContactService
{
/**
* Sends a message to all admins.
*
* @throws FailedCaptchaException
*/
public function sendMessage($ipAddress, $message, $email, $challenge)
{
// TODO: now: add middleware for this verification, move to invisible captcha
$verifyrequest = Http::asForm()->post(config('recaptcha.verify.apiurl'), [
'secret' => config('recaptcha.keys.secret'),
'response' => $challenge,
'remoteip' => $ipAddress,
]);
$response = json_decode($verifyrequest->getBody(), true);
if (! $response['success']) {
throw new FailedCaptchaException('Beep beep boop... Robot? Submission failed.');
}
foreach (User::all() as $user) {
if ($user->hasRole('admin')) {
$user->notify(new NewContact(collect([
'message' => $message,
'ip' => $ipAddress,
'email' => $email,
])));
}
}
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Services;
use App\Exceptions\EmptyFormException;
use App\Exceptions\FormHasConstraintsException;
use App\Form;
use ContextAwareValidator;
class FormManagementService
{
public function addForm($fields) {
if (count($fields) == 2) {
// form is probably empty, since forms with fields will always have more than 2 items
throw new EmptyFormException('Sorry, but you may not create empty forms.');
}
$contextValidation = ContextAwareValidator::getValidator($fields, true, true);
if (! $contextValidation->get('validator')->fails()) {
$storableFormStructure = $contextValidation->get('structure');
Form::create(
[
'formName' => $fields['formName'],
'formStructure' => $storableFormStructure,
'formStatus' => 'ACTIVE',
]
);
return true;
}
return $contextValidation->get('validator')->errors()->getMessages();
}
public function deleteForm(Form $form) {
$deletable = true;
if (! is_null($form) && ! is_null($form->vacancies) && $form->vacancies->count() !== 0 || ! is_null($form->responses)) {
$deletable = false;
}
if ($deletable) {
$form->delete();
return true;
} else {
throw new FormHasConstraintsException(__('You cannot delete this form because it\'s tied to one or more applications and ranks, or because it doesn\'t exist.'));
}
}
public function updateForm(Form $form, $fields) {
$contextValidation = ContextAwareValidator::getValidator($fields, true);
if (! $contextValidation->get('validator')->fails()) {
// Add the new structure into the form. New, subsquent fields will be identified by the "new" prefix
// This prefix doesn't actually change the app's behavior when it receives applications.
// Additionally, old applications won't of course display new and updated fields, because we can't travel into the past and get data for them
$form->formStructure = $contextValidation->get('structure');
$form->save();
return $form;
} else {
return $contextValidation->get('validator')->errors()->getMessages();
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Services;
use App\Application;
use App\Exceptions\InvalidAppointmentException;
class MeetingNoteService
{
/**
* Adds meeting notes to an application.
*
* @param Application $application
* @param $noteText
* @return bool
* @throws InvalidAppointmentException Thrown when an application doesn't have an appointment to save notes to
*/
public function addToApplication(Application $application, $noteText): bool {
if (! is_null($application)) {
$application->load('appointment');
$application->appointment->meetingNotes = $noteText;
$application->appointment->save();
return true;
} else {
throw new InvalidAppointmentException('There\'s no appointment to save notes to!');
}
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Services;
use App\Exceptions\ProfileNotFoundException;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ProfileService
{
/**
* @throws ProfileNotFoundException
*/
public function updateProfile($userID, Request $request) {
$profile = User::find($userID)->profile;
$social = [];
if (! is_null($profile)) {
switch ($request->avatarPref) {
case 'MOJANG':
$avatarPref = 'crafatar';
break;
case 'GRAVATAR':
$avatarPref = strtolower($request->avatarPref);
break;
}
$social['links']['github'] = $request->socialGithub;
$social['links']['twitter'] = $request->socialTwitter;
$social['links']['insta'] = $request->socialInsta;
$social['links']['discord'] = $request->socialDiscord;
$profile->profileShortBio = $request->shortBio;
$profile->profileAboutMe = $request->aboutMe;
$profile->avatarPreference = $avatarPref;
$profile->socialLinks = json_encode($social);
return $profile->save();
}
throw new ProfileNotFoundException("This profile does not exist.");
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Services;
use App\Facades\Options;
use Illuminate\Support\Facades\Log;
class SecuritySettingsService
{
/**
* Saves the app security settings.
*
* @param $policy
* @param array $options
* @return bool
*/
public function save($policy, $options = []) {
$validPolicies = [
'off',
'low',
'medium',
'high'
];
if (in_array($policy, $validPolicies))
{
Options::changeOption('pw_security_policy', $policy);
Log::debug('[Options] Changing option pw_security_policy', [
'new_value' => $policy
]);
}
else
{
Log::debug('[WARN] Ignoring bogus policy', [
'avaliable' => $validPolicies,
'given' => $policy
]);
}
Options::changeOption('graceperiod', $options['graceperiod']);
Options::changeOption('password_expiry', $options['pwexpiry']);
Options::changeOption('force2fa', $options['enforce2fa']);
Options::changeOption('requireGameLicense', $options['requirePMC']);
return true;
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Services;
use App\Exceptions\FileUploadException;
use App\TeamFile;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
class TeamFileService
{
public function addFile(UploadedFile $upload, $uploader, $team, $caption, $description) {
$file = $upload->store('uploads');
$originalFileName = $upload->getClientOriginalName();
$originalFileExtension = $upload->extension();
$originalFileSize = $upload->getSize();
$fileEntry = TeamFile::create([
'uploaded_by' => $uploader,
'team_id' => $team,
'name' => $originalFileName,
'caption' => $caption,
'description' => $description,
'fs_location' => $file,
'extension' => $originalFileExtension,
'size' => $originalFileSize
]);
if ($fileEntry && !is_bool($file))
{
return $fileEntry;
}
throw new FileUploadException("There was an unknown error whilst trying to upload your file.");
}
}

View File

@@ -0,0 +1,165 @@
<?php
namespace App\Services;
use App\Exceptions\InvalidInviteException;
use App\Exceptions\PublicTeamInviteException;
use App\Exceptions\UserAlreadyInvitedException;
use App\Mail\InviteToTeam;
use App\Team;
use App\User;
use Illuminate\Auth\Authenticatable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Mpociot\Teamwork\Facades\Teamwork;
use Mpociot\Teamwork\TeamInvite;
class TeamService
{
/**
* Create a team
*
* @param $teamName
* @param $ownerID
* @return Team
*/
public function createTeam($teamName, $ownerID): Team {
$team = Team::create([
'name' => $teamName,
'owner_id' => $ownerID,
]);
Auth::user()->teams()->attach($team->id);
return $team;
}
public function updateTeam(Team $team, $teamDescription, $joinType): bool
{
$team->description = $teamDescription;
$team->openJoin = $joinType;
return $team->save();
}
/**
* Invites a user to a $team.
*
* @throws PublicTeamInviteException Thrown when trying to invite a user to a public team
* @throws UserAlreadyInvitedException Thrown when a user is already invited
*/
public function inviteUser(Team $team, $userID): bool
{
$user = User::findOrFail($userID);
if (! $team->openJoin) {
if (! Teamwork::hasPendingInvite($user->email, $team)) {
Teamwork::inviteToTeam($user, $team, function (TeamInvite $invite) use ($user) {
Mail::to($user)->send(new InviteToTeam($invite));
});
return true;
} else {
throw new UserAlreadyInvitedException('This user has already been invited.');
}
} else {
throw new PublicTeamInviteException('You can\'t invite users to public teams.');
}
}
/**
* Accepts or denies a user invite
*
* @param Authenticatable $user
* @param $action
* @param $token
* @return bool True on success or exception on failure
* @throws InvalidInviteException Thrown when the invite code / url is invalid
*/
public function processInvite(Authenticatable $user, $action, $token): bool {
switch ($action) {
case 'accept':
$invite = Teamwork::getInviteFromAcceptToken($token);
if ($invite && $invite->user->is($user)) {
Teamwork::acceptInvite($invite);
} else {
throw new InvalidInviteException('Invalid or expired invite URL.');
}
break;
case 'deny':
$invite = Teamwork::getInviteFromDenyToken($token);
if ($invite && $invite->user->is($user)) {
Teamwork::denyInvite($invite);
} else {
throw new InvalidInviteException('Invalid or expired invite URL.');
}
break;
default:
throw new InvalidInviteException('Sorry, but the invite URL you followed was malformed.');
}
return true;
}
/**
* @param Team $team
* @param $associatedVacancies
* @return string The success message, exception/bool if error
*/
public function updateVacancies(Team $team, $associatedVacancies): string
{
// P.S. To future developers
// This method gave me a lot of trouble lol. It's hard to write code when you're half asleep.
// There may be an n+1 query in the view and I don't think there's a way to avoid that without writing a lot of extra code.
$requestVacancies = $associatedVacancies;
$currentVacancies = $team->vacancies->pluck('id')->all();
if (is_null($requestVacancies)) {
foreach ($team->vacancies as $vacancy) {
$team->vacancies()->detach($vacancy->id);
}
return 'Removed all vacancy associations.';
}
$vacancyDiff = array_diff($requestVacancies, $currentVacancies);
$deselectedDiff = array_diff($currentVacancies, $requestVacancies);
if (! empty($vacancyDiff) || ! empty($deselectedDiff)) {
foreach ($vacancyDiff as $selectedVacancy) {
$team->vacancies()->attach($selectedVacancy);
}
foreach ($deselectedDiff as $deselectedVacancy) {
$team->vacancies()->detach($deselectedVacancy);
}
} else {
$team->vacancies()->attach($requestVacancies);
}
return 'Assignments changed successfully.';
}
}