diff --git a/app/Auth/ServiceAccountGuard.php b/app/Auth/ServiceAccountGuard.php
new file mode 100644
index 000000000..002b894f0
--- /dev/null
+++ b/app/Auth/ServiceAccountGuard.php
@@ -0,0 +1,76 @@
+request = $request;
+ }
+
+ public function user(): ?Authenticatable
+ {
+ if ($this->user !== null) {
+ return $this->user;
+ }
+
+ $apiKey = $this->getApiKey();
+ if ($apiKey == null) {
+ return null;
+ }
+
+ return $this->user = User::where('api_key', $apiKey)->first();
+ }
+
+ public function validate(array $credentials = []): bool
+ {
+ // There's no logging in or validating for this guard.
+ return false;
+ }
+
+ protected function getApiKey(): ?string
+ {
+ $header = $this->request->headers->get(self::HEADER);
+ if ($header == null) {
+ return null;
+ }
+
+ $position = strripos($header, self::PREFIX);
+ if ($position === false) {
+ return null;
+ }
+
+ $bearerValue = trim(substr($header, $position + strlen(self::PREFIX)));
+ if (strlen($bearerValue) !== self::API_KEY_LENGTH || $this->isJwt($bearerValue)) {
+ return null;
+ }
+
+ return $bearerValue;
+ }
+
+ protected function isJwt($value): bool
+ {
+ try {
+ return (new TokenValidator())->check($value) != null;
+ } catch (TokenInvalidException $exception) {
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/Console/Commands/CreateServiceAccount.php b/app/Console/Commands/CreateServiceAccount.php
new file mode 100644
index 000000000..8044085e0
--- /dev/null
+++ b/app/Console/Commands/CreateServiceAccount.php
@@ -0,0 +1,62 @@
+argument('email');
+
+ $apiKey = base64_encode(random_bytes(48));
+
+ $data = [
+ 'email_address' => $email,
+ 'api_key' => $apiKey,
+ ];
+ ServiceAccountValidator::validate('CREATE', $data);
+
+ $data['role'] = 'service';
+ $data['email_address_verified_at'] = new DateTime('now', new DateTimeZone('UTC'));
+
+ $user = new User($data);
+ $user->saveOrFail();
+ // TODO Allow other types of service account, when/if necessary.
+ $user->assignRole('greenhouse-service-account');
+
+ $this->info("Created service account $email with API Key: $apiKey");
+ return 0;
+
+ } catch (Exception $exception) {
+ $this->error($exception->getMessage());
+ $this->error('Creation failed');
+ return -1;
+ }
+ }
+}
diff --git a/app/Console/Commands/OneOff/CreateGreenhouseServiceAccountRole.php b/app/Console/Commands/OneOff/CreateGreenhouseServiceAccountRole.php
new file mode 100644
index 000000000..40f8576bb
--- /dev/null
+++ b/app/Console/Commands/OneOff/CreateGreenhouseServiceAccountRole.php
@@ -0,0 +1,46 @@
+first();
+ if ($role == null) {
+ $role = Role::create(['name' => 'greenhouse-service-account']);
+ }
+
+ // Make sure all permissions in config/permissions have been created.
+ $permissionKeys = array_keys(config('wri.permissions'));
+ foreach ($permissionKeys as $key) {
+ if (Permission::where('name', $key)->count() === 0) {
+ Permission::create(['name' => $key]);
+ }
+ }
+
+ $role->syncPermissions(['projects-read', 'polygons-manage', 'media-manage']);
+ }
+}
diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php
index 9a9b2fc49..87e3a9a40 100644
--- a/app/Exceptions/Handler.php
+++ b/app/Exceptions/Handler.php
@@ -8,6 +8,7 @@
use App\Helpers\ErrorHelper;
use App\Helpers\JsonResponseHelper;
use Illuminate\Auth\Access\AuthorizationException;
+use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
@@ -161,9 +162,7 @@ public function report(Throwable $exception)
return;
default:
if (config('app.env') != 'local') {
- if (config('app.env') == 'production') {
- App::make('sentry')->captureException($exception);
- }
+ App::make('sentry')->captureException($exception);
Log::error($exception);
}
@@ -174,6 +173,7 @@ public function report(Throwable $exception)
public function render($request, Throwable $exception)
{
switch (get_class($exception)) {
+ case AuthenticationException::class:
case AuthorizationException::class:
return JsonResponseHelper::error([], 403);
case FailedLoginException::class:
diff --git a/app/Exports/V2/ApplicationExport.php b/app/Exports/V2/ApplicationExport.php
index 87031faff..b6e02be2a 100644
--- a/app/Exports/V2/ApplicationExport.php
+++ b/app/Exports/V2/ApplicationExport.php
@@ -36,6 +36,8 @@ class ApplicationExport extends BaseExportFormSubmission implements WithHeadings
'Headquarters address State/Province',
'Headquarters address Zipcode',
'Proof of local legal registration, incorporation, or right to operate',
+ 'Created At',
+ 'Updated At',
'Website URL (optional)',
'Organization Facebook URL(optional)',
'Organization Instagram URL(optional)',
@@ -120,6 +122,8 @@ public function map($application): array
$organisation->hq_state,
$organisation->hq_zipcode,
$this->handleOrganisationFiles($organisation, 'legal_registration'),
+ $application->created_at,
+ $application->updated_at,
$organisation->web_url,
$organisation->facebook_url,
$organisation->instagram_url,
diff --git a/app/Exports/V2/BaseExportFormSubmission.php b/app/Exports/V2/BaseExportFormSubmission.php
index 0f6118687..94219b524 100644
--- a/app/Exports/V2/BaseExportFormSubmission.php
+++ b/app/Exports/V2/BaseExportFormSubmission.php
@@ -81,7 +81,7 @@ protected function getAnswer(array $field, array $answers): string
return $this->stringifyModel($answer, ['first_name', 'last_name', 'position', 'gender', 'age', 'role']);
case 'fundingType':
- return $this->stringifyModel($answer, ['source', 'amount', 'year']);
+ return $this->stringifyModel($answer, ['type', 'source', 'amount', 'year']);
case 'ownershipStake':
return $this->stringifyModel($answer, ['first_name', 'last_name', 'title', 'gender', 'percent_ownership', 'year_of_birth',]);
diff --git a/app/Helpers/I18nHelper.php b/app/Helpers/I18nHelper.php
index 7c78e1bbd..bcf373e08 100644
--- a/app/Helpers/I18nHelper.php
+++ b/app/Helpers/I18nHelper.php
@@ -12,8 +12,7 @@ public static function generateI18nItem(Model $target, string $property): ?int
$shouldGenerateI18nItem = I18nHelper::shouldGenerateI18nItem($target, $property);
$value = trim(data_get($target, $property, false));
if ($shouldGenerateI18nItem) {
- $short = strlen($value) <= 256;
-
+ $short = strlen($value) < 256;
$i18nItem = I18nItem::create([
'type' => $short ? 'short' : 'long',
'status' => I18nItem::STATUS_DRAFT,
@@ -47,7 +46,7 @@ public static function shouldGenerateI18nItem(Model $target, string $property):
$oldType = $i18nItem->type;
- $short = strlen($value) <= 256;
+ $short = strlen($value) < 256;
$type = $short ? 'short' : 'long';
if ($oldType !== $type) {
diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php
index fdec5727c..fb460ea05 100644
--- a/app/Http/Controllers/AuthController.php
+++ b/app/Http/Controllers/AuthController.php
@@ -11,7 +11,6 @@
use App\Http\Requests\ResendRequest;
use App\Http\Requests\ResetRequest;
use App\Http\Requests\VerifyRequest;
-use App\Http\Resources\V2\Projects\ProjectInviteResource;
use App\Http\Resources\V2\User\MeResource;
use App\Jobs\ResetPasswordJob;
use App\Jobs\UserVerificationJob;
diff --git a/app/Http/Controllers/V2/UpdateRequests/EntityUpdateRequestsController.php b/app/Http/Controllers/V2/UpdateRequests/EntityUpdateRequestsController.php
index 794b6350f..577975fc0 100644
--- a/app/Http/Controllers/V2/UpdateRequests/EntityUpdateRequestsController.php
+++ b/app/Http/Controllers/V2/UpdateRequests/EntityUpdateRequestsController.php
@@ -19,9 +19,7 @@ class EntityUpdateRequestsController extends Controller
public function __invoke(Request $request, EntityModel $entity)
{
$this->authorize('read', $entity);
- $latest = $entity->updateRequests()
- ->orderBy('updated_at', 'DESC')
- ->first();
+ $latest = $entity->updateRequests()->isUnapproved()->orderBy('updated_at', 'DESC')->first();
if (is_null($latest)) {
return new JsonResponse('There is not any update request for this resource', 404);
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index b10ab5e8c..cf908806d 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -38,6 +38,7 @@ class Kernel extends HttpKernel
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
+ 'auth:service-api-key,api',
'bindings',
],
];
@@ -50,7 +51,7 @@ class Kernel extends HttpKernel
* @var array
*/
protected $middlewareAliases = [
- 'auth' => \App\Http\Middleware\Authenticate::class,
+ 'auth' => \Illuminate\Auth\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
@@ -75,7 +76,7 @@ class Kernel extends HttpKernel
protected $middlewarePriority = [
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
- \App\Http\Middleware\Authenticate::class,
+ \Illuminate\Auth\Middleware\Authenticate::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\Illuminate\Auth\Middleware\Authorize::class,
diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php
deleted file mode 100644
index d4ef6447a..000000000
--- a/app/Http/Middleware/Authenticate.php
+++ /dev/null
@@ -1,17 +0,0 @@
-expectsJson() ? null : route('login');
- }
-}
diff --git a/app/Jobs/V2/SendEntityStatusChangeEmailsJob.php b/app/Jobs/V2/SendEntityStatusChangeEmailsJob.php
new file mode 100644
index 000000000..c5932acf0
--- /dev/null
+++ b/app/Jobs/V2/SendEntityStatusChangeEmailsJob.php
@@ -0,0 +1,53 @@
+entity = $entity;
+ }
+
+ public function handle(): void
+ {
+ if ($this->entity->status != EntityStatusStateMachine::APPROVED &&
+ $this->entity->status != EntityStatusStateMachine::NEEDS_MORE_INFORMATION &&
+ $this->entity->update_request_status != EntityStatusStateMachine::NEEDS_MORE_INFORMATION) {
+ return;
+ }
+
+ $emailAddresses = $this->entity->project->users()->pluck('email_address');
+ if (empty($emailAddresses)) {
+ return;
+ }
+
+ // TODO: This is a temporary hack to avoid spamming folks that have a funky role right now. In the future,
+ // they will have a different role, and we can simply skip sending this email to anybody with that role.
+ $skipRecipients = collect(explode(',', getenv('ENTITY_UPDATE_DO_NOT_EMAIL')));
+ foreach ($emailAddresses as $emailAddress) {
+ if ($skipRecipients->contains($emailAddress)) {
+ continue;
+ }
+
+ Mail::to($emailAddress)->send(new EntityStatusChangeMail($this->entity));
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/Mail/EntityStatusChange.php b/app/Mail/EntityStatusChange.php
new file mode 100644
index 000000000..5d68063fa
--- /dev/null
+++ b/app/Mail/EntityStatusChange.php
@@ -0,0 +1,112 @@
+entity = $entity;
+
+ $this->subject = $this->getSubject();
+ $this->title = $this->subject;
+ $this->body = $this->getBodyParagraphs()->join('
');
+ $this->link = $this->entity->getViewLinkPath();
+ $this->cta = 'View ' . $this->getEntityTypeName();
+ $this->transactional = true;
+ }
+
+ private function getEntityTypeName(): string
+ {
+ if ($this->entity instanceof ReportModel) {
+ return 'Report';
+ } else {
+ return explode_pop('\\', get_class($this->entity));
+ }
+ }
+
+ private function getEntityStatus(): ?string
+ {
+ if ($this->entity->status == EntityStatusStateMachine::NEEDS_MORE_INFORMATION ||
+ $this->entity->update_request_status == EntityStatusStateMachine::NEEDS_MORE_INFORMATION) {
+ return EntityStatusStateMachine::NEEDS_MORE_INFORMATION;
+ }
+
+ if ($this->entity->status == EntityStatusStateMachine::APPROVED) {
+ return EntityStatusStateMachine::APPROVED;
+ }
+
+ return null;
+ }
+
+ private function getSubject(): string
+ {
+ return match ($this->getEntityStatus()) {
+ EntityStatusStateMachine::APPROVED =>
+ 'Your ' . $this->getEntityTypeName() . ' Has Been Approved',
+ EntityStatusStateMachine::NEEDS_MORE_INFORMATION =>
+ 'There is More Information Requested About Your ' . $this->getEntityTypeName(),
+ default => '',
+ };
+ }
+
+ private function getFeedback(): ?string
+ {
+ if ($this->entity->update_request_status == EntityStatusStateMachine::APPROVED ||
+ $this->entity->update_request_status == EntityStatusStateMachine::NEEDS_MORE_INFORMATION) {
+ $feedback = $this
+ ->entity
+ ->updateRequests()
+ ->orderBy('updated_at', 'DESC')
+ ->first()
+ ->feedback;
+ } else {
+ $feedback = $this->entity->feedback;
+ }
+
+ if (empty($feedback)) {
+ return null;
+ }
+
+ return str_replace("\n", "
", $feedback);
+ }
+
+ private function getBodyParagraphs(): Collection
+ {
+ $paragraphs = collect();
+ if ($this->entity instanceof ReportModel) {
+ $paragraphs->push('Thank you for submitting your ' .
+ $this->entity->parentEntity()->pluck('name')->first() .
+ ' report.');
+ } else {
+ $paragraphs->push('Thank you for submitting your ' .
+ strtolower($this->getEntityTypeName()) .
+ ' information for ' .
+ $this->entity->name .
+ '.');
+ }
+
+ $paragraphs->push(match ($this->getEntityStatus()) {
+ EntityStatusStateMachine::APPROVED => [
+ 'The information has been reviewed by your project manager and has been approved.',
+ $this->getFeedback(),
+ ],
+ EntityStatusStateMachine::NEEDS_MORE_INFORMATION => [
+ 'The information has been reviewed by your project manager and they would like to see the following updates:',
+ $this->getFeedback() ?? '(No feedback)'
+ ],
+ default => null
+ });
+
+ $paragraphs->push('If you have any additional questions please reach out to your project manager or to info@terramatch.org');
+
+ return $paragraphs->flatten()->filter();
+ }
+}
\ No newline at end of file
diff --git a/app/Mail/Mail.php b/app/Mail/Mail.php
index 6970cde3b..7a4f979d3 100644
--- a/app/Mail/Mail.php
+++ b/app/Mail/Mail.php
@@ -61,4 +61,22 @@ public function build()
'year' => date('Y'),
]);
}
+
+ protected function buildRecipients($message): Mail
+ {
+ $overrideRecipients = collect(explode(',', getenv('EMAIL_RECIPIENTS')));
+ if ($overrideRecipients->isEmpty() || empty($overrideRecipients->first())) {
+ return parent::buildRecipients($message);
+ }
+
+ $originalRecipients = [];
+ foreach(['to', 'cc', 'bcc'] as $type) {
+ $originalRecipients[$type] = $this->{$type};
+ }
+ $message->getHeaders()->addTextHeader('X-Original-Emails', json_encode($originalRecipients));
+
+ $overrideRecipients->each(function ($email) use ($message) { $message->to($email); });
+
+ return $this;
+ }
}
diff --git a/app/Models/Traits/HasEntityStatus.php b/app/Models/Traits/HasEntityStatus.php
index f13027aa8..49759e6fe 100644
--- a/app/Models/Traits/HasEntityStatus.php
+++ b/app/Models/Traits/HasEntityStatus.php
@@ -5,10 +5,12 @@
use App\Events\V2\General\EntityStatusChangeEvent;
use App\StateMachines\EntityStatusStateMachine;
use Asantibanez\LaravelEloquentStateMachines\Traits\HasStateMachines;
-use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Str;
/**
+ * @property string uuid
* @property string $status
+ * @property string $update_request_status
* @property string $feedback
* @property string $feedback_fields
* @property string $name
@@ -18,6 +20,7 @@
trait HasEntityStatus {
use HasStatus;
use HasStateMachines;
+ use HasEntityStatusScopesAndTransitions;
public $stateMachines = [
'status' => EntityStatusStateMachine::class,
@@ -30,37 +33,13 @@ trait HasEntityStatus {
EntityStatusStateMachine::APPROVED => 'Approved',
];
- public function scopeIsApproved(Builder $query): Builder
- {
- return $this->scopeIsStatus($query, EntityStatusStateMachine::APPROVED);
- }
-
- public function isEditable(): bool
- {
- return $this->status == EntityStatusStateMachine::STARTED;
- }
-
- public function approve($feedback): void
- {
- $this->feedback = $feedback;
- $this->feedback_fields = null;
- $this->status()->transitionTo(EntityStatusStateMachine::APPROVED);
- }
-
- public function submitForApproval(): void
- {
- $this->status()->transitionTo(EntityStatusStateMachine::AWAITING_APPROVAL);
- }
-
- public function needsMoreInformation($feedback, $feedbackFields): void
+ public function dispatchStatusChangeEvent($user): void
{
- $this->feedback = $feedback;
- $this->feedback_fields = $feedbackFields;
- $this->status()->transitionTo(EntityStatusStateMachine::NEEDS_MORE_INFORMATION);
+ EntityStatusChangeEvent::dispatch($user, $this, $this->name ?? '', '', $this->readable_status);
}
- public function dispatchStatusChangeEvent($user): void
+ public function getViewLinkPath(): string
{
- EntityStatusChangeEvent::dispatch($user, $this, $this->name ?? '', '', $this->readable_status);
+ return '/' . Str::lower(explode_pop('\\', get_class($this))) . '/' . $this->uuid;
}
}
\ No newline at end of file
diff --git a/app/Models/Traits/HasEntityStatusScopesAndTransitions.php b/app/Models/Traits/HasEntityStatusScopesAndTransitions.php
new file mode 100644
index 000000000..2fc633d77
--- /dev/null
+++ b/app/Models/Traits/HasEntityStatusScopesAndTransitions.php
@@ -0,0 +1,44 @@
+scopeIsStatus($query, EntityStatusStateMachine::APPROVED);
+ }
+
+ public function isEditable(): bool
+ {
+ return $this->status == EntityStatusStateMachine::STARTED;
+ }
+
+ public function approve($feedback): void
+ {
+ $this->feedback = $feedback;
+ $this->feedback_fields = null;
+
+ if ($this->status == EntityStatusStateMachine::APPROVED) {
+ // If we were already approved, this may have been called because an update request got approved, and
+ // we need to make sure the transition hooks execute, so fake us into awaiting-approval first.
+ $this->status = EntityStatusStateMachine::AWAITING_APPROVAL;
+ }
+
+ $this->status()->transitionTo(EntityStatusStateMachine::APPROVED);
+ }
+
+ public function submitForApproval(): void
+ {
+ $this->status()->transitionTo(EntityStatusStateMachine::AWAITING_APPROVAL);
+ }
+
+ public function needsMoreInformation($feedback, $feedbackFields): void
+ {
+ $this->feedback = $feedback;
+ $this->feedback_fields = $feedbackFields;
+ $this->status()->transitionTo(EntityStatusStateMachine::NEEDS_MORE_INFORMATION);
+ }
+}
\ No newline at end of file
diff --git a/app/Models/Traits/HasReportStatus.php b/app/Models/Traits/HasReportStatus.php
index f606d498c..ce55c6201 100644
--- a/app/Models/Traits/HasReportStatus.php
+++ b/app/Models/Traits/HasReportStatus.php
@@ -7,10 +7,13 @@
use Asantibanez\LaravelEloquentStateMachines\Traits\HasStateMachines;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Str;
/**
* @property \Illuminate\Support\Carbon $submitted_at
+ * @property string $uuid
* @property string $status
+ * @property string $update_request_status
* @property string $feedback
* @property string $feedback_fields
* @property bool $nothing_to_report
@@ -21,6 +24,10 @@
trait HasReportStatus {
use HasStatus;
use HasStateMachines;
+ use HasEntityStatusScopesAndTransitions {
+ approve as entityStatusApprove;
+ submitForApproval as entityStatusSubmitForApproval;
+ }
public $stateMachines = [
'status' => ReportStatusStateMachine::class,
@@ -50,11 +57,6 @@ public function supportsNothingToReport(): bool
ReportStatusStateMachine::APPROVED,
];
- public function scopeIsApproved(Builder $query): Builder
- {
- return $this->scopeIsStatus($query, ReportStatusStateMachine::APPROVED);
- }
-
public function scopeIsIncomplete(Builder $query): Builder
{
return $query->whereNotIn('status', self::COMPLETE_STATUSES);
@@ -132,16 +134,7 @@ public function updateInProgress(bool $isAdmin = false): void
public function approve($feedback): void
{
$this->setCompletion();
- $this->feedback = $feedback;
- $this->feedback_fields = null;
- $this->status()->transitionTo(ReportStatusStateMachine::APPROVED);
- }
-
- public function needsMoreInformation($feedback, $feedbackFields): void
- {
- $this->feedback = $feedback;
- $this->feedback_fields = $feedbackFields;
- $this->status()->transitionTo(ReportStatusStateMachine::NEEDS_MORE_INFORMATION);
+ $this->entityStatusApprove($feedback);
}
public function submitForApproval(): void
@@ -150,7 +143,7 @@ public function submitForApproval(): void
$this->completion = 100;
$this->submitted_at = now();
}
- $this->status()->transitionTo(ReportStatusStateMachine::AWAITING_APPROVAL);
+ $this->entityStatusSubmitForApproval();
}
public function setCompletion(): void
@@ -171,4 +164,9 @@ public function dispatchStatusChangeEvent($user): void
{
EntityStatusChangeEvent::dispatch($user, $this);
}
+
+ public function getViewLinkPath(): string
+ {
+ return '/reports/' . Str::kebab(explode_pop('\\', get_class($this))) . '/' . $this->uuid;
+ }
}
\ No newline at end of file
diff --git a/app/Models/V2/EntityModel.php b/app/Models/V2/EntityModel.php
index e5541903d..f1aab28db 100644
--- a/app/Models/V2/EntityModel.php
+++ b/app/Models/V2/EntityModel.php
@@ -28,4 +28,6 @@ public function createSchemaResource(): JsonResource;
public function isEditable(): bool;
public function dispatchStatusChangeEvent($user): void;
+
+ public function getViewLinkPath(): string;
}
\ No newline at end of file
diff --git a/app/Models/V2/Nurseries/NurseryReport.php b/app/Models/V2/Nurseries/NurseryReport.php
index 8b54a0f14..12b7a3665 100644
--- a/app/Models/V2/Nurseries/NurseryReport.php
+++ b/app/Models/V2/Nurseries/NurseryReport.php
@@ -11,7 +11,9 @@
use App\Models\Traits\HasUuid;
use App\Models\Traits\HasV2MediaCollections;
use App\Models\Traits\UsesLinkedFields;
+use App\Models\V2\Organisation;
use App\Models\V2\Polygon;
+use App\Models\V2\Projects\Project;
use App\Models\V2\ReportModel;
use App\Models\V2\Tasks\Task;
use App\Models\V2\TreeSpecies\TreeSpecies;
@@ -28,6 +30,8 @@
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
+use Znck\Eloquent\Relations\BelongsToThrough;
+use Znck\Eloquent\Traits\BelongsToThrough as BelongsToThroughTrait;
class NurseryReport extends Model implements HasMedia, AuditableContract, ReportModel
{
@@ -44,6 +48,7 @@ class NurseryReport extends Model implements HasMedia, AuditableContract, Report
use Auditable;
use HasUpdateRequests;
use HasEntityResources;
+ use BelongsToThroughTrait;
protected $auditInclude = [
'status',
@@ -139,9 +144,13 @@ public function nursery(): BelongsTo
return $this->belongsTo(Nursery::class);
}
- public function project(): BelongsTo
+ public function project(): BelongsToThrough
{
- return empty($this->nursery) ? $this->nursery : $this->nursery->project();
+ return $this->belongsToThrough(
+ Project::class,
+ Nursery::class,
+ foreignKeyLookup: [Project::class => 'project_id', Nursery::class => 'nursery_id']
+ );
}
public function task(): BelongsTo
@@ -149,9 +158,13 @@ public function task(): BelongsTo
return $this->belongsTo(Task::class);
}
- public function organisation(): BelongsTo
+ public function organisation(): BelongsToThrough
{
- return empty($this->project) ? $this->project : $this->project->organisation();
+ return $this->belongsToThrough(
+ Organisation::class,
+ [Project::class, Nursery::class],
+ foreignKeyLookup: [Project::class => 'project_id', Nursery::class => 'nurseyr_id']
+ );
}
public function createdBy(): BelongsTo
@@ -172,8 +185,8 @@ public function approvedBy(): BelongsTo
public function toSearchableArray()
{
return [
- 'project_name' => $this->nursery->project?->name,
- 'organisation_name' => $this->nursery->project?->organisation?->name,
+ 'project_name' => $this->project?->name,
+ 'organisation_name' => $this->organisation?->name,
];
}
@@ -241,4 +254,9 @@ public function supportsNothingToReport(): bool
{
return true;
}
+
+ public function parentEntity(): BelongsTo
+ {
+ return $this->nursery();
+ }
}
diff --git a/app/Models/V2/Projects/Project.php b/app/Models/V2/Projects/Project.php
index 7c253b96f..60285dc95 100644
--- a/app/Models/V2/Projects/Project.php
+++ b/app/Models/V2/Projects/Project.php
@@ -417,6 +417,12 @@ public function scopeHasMonitoringData(Builder $query, $hasMonitoringData): Buil
: $query->doesntHave('monitoring');
}
+ // All Entities are expected to have a project attribute.
+ public function getProjectAttribute(): Project
+ {
+ return $this;
+ }
+
/** SEARCH */
public function toSearchableArray()
{
@@ -431,6 +437,8 @@ public function toSearchableArray()
*/
private function submittedSiteReports(): HasManyThrough
{
+ // scopes that use status don't work on the HasManyThrough because both Site and SiteReport have
+ // a status field.
return $this
->siteReports()
->where('v2_sites.status', EntityStatusStateMachine::APPROVED)
@@ -443,8 +451,6 @@ private function submittedSiteReports(): HasManyThrough
*/
private function submittedSiteReportIds(): array
{
- // scopes that use status don't work on the HasManyThrough because both Site and SiteReport have
- // a status field.
return $this->submittedSiteReports()->pluck('v2_site_reports.id')->toArray();
}
}
diff --git a/app/Models/V2/Projects/ProjectReport.php b/app/Models/V2/Projects/ProjectReport.php
index 375056c7f..dbe2e24ed 100644
--- a/app/Models/V2/Projects/ProjectReport.php
+++ b/app/Models/V2/Projects/ProjectReport.php
@@ -13,6 +13,7 @@
use App\Models\Traits\UsesLinkedFields;
use App\Models\V2\Nurseries\Nursery;
use App\Models\V2\Nurseries\NurseryReport;
+use App\Models\V2\Organisation;
use App\Models\V2\Polygon;
use App\Models\V2\ReportModel;
use App\Models\V2\Sites\Site;
@@ -32,6 +33,8 @@
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
+use Znck\Eloquent\Relations\BelongsToThrough;
+use Znck\Eloquent\Traits\BelongsToThrough as BelongsToThroughTrait;
class ProjectReport extends Model implements HasMedia, AuditableContract, ReportModel
{
@@ -48,6 +51,7 @@ class ProjectReport extends Model implements HasMedia, AuditableContract, Report
use Auditable;
use HasUpdateRequests;
use HasEntityResources;
+ use BelongsToThroughTrait;
protected $auditInclude = [
'status',
@@ -206,9 +210,13 @@ public function task(): BelongsTo
return $this->belongsTo(Task::class);
}
- public function organisation(): BelongsTo
+ public function organisation(): BelongsToThrough
{
- return empty($this->project) ? $this->project : $this->project->organisation();
+ return $this->belongsToThrough(
+ Organisation::class,
+ Project::class,
+ foreignKeyLookup: [Project::class => 'project_id']
+ );
}
public function createdBy(): BelongsTo
@@ -431,4 +439,9 @@ public function scopeParentId(Builder $query, string $id): Builder
{
return $query->where('project_id', $id);
}
+
+ public function parentEntity(): BelongsTo
+ {
+ return $this->project();
+ }
}
diff --git a/app/Models/V2/ReportModel.php b/app/Models/V2/ReportModel.php
index 56185fac4..21504280d 100644
--- a/app/Models/V2/ReportModel.php
+++ b/app/Models/V2/ReportModel.php
@@ -3,6 +3,7 @@
namespace App\Models\V2;
use App\Models\V2\Tasks\Task;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property Task task
@@ -12,4 +13,6 @@ interface ReportModel extends EntityModel
public function nothingToReport();
public function updateInProgress();
+
+ public function parentEntity(): BelongsTo;
}
diff --git a/app/Models/V2/Sites/SiteReport.php b/app/Models/V2/Sites/SiteReport.php
index f8381d7d7..7ca16a376 100644
--- a/app/Models/V2/Sites/SiteReport.php
+++ b/app/Models/V2/Sites/SiteReport.php
@@ -14,7 +14,9 @@
use App\Models\Traits\UsesLinkedFields;
use App\Models\V2\Disturbance;
use App\Models\V2\Invasive;
+use App\Models\V2\Organisation;
use App\Models\V2\Polygon;
+use App\Models\V2\Projects\Project;
use App\Models\V2\ReportModel;
use App\Models\V2\Seeding;
use App\Models\V2\Tasks\Task;
@@ -35,6 +37,8 @@
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
+use Znck\Eloquent\Relations\BelongsToThrough;
+use Znck\Eloquent\Traits\BelongsToThrough as BelongsToThroughTrait;
class SiteReport extends Model implements HasMedia, AuditableContract, ReportModel
{
@@ -51,6 +55,7 @@ class SiteReport extends Model implements HasMedia, AuditableContract, ReportMod
use Auditable;
use HasUpdateRequests;
use HasEntityResources;
+ use BelongsToThroughTrait;
protected $auditInclude = [
'status',
@@ -158,9 +163,13 @@ public function site(): BelongsTo
return $this->belongsTo(Site::class);
}
- public function project(): BelongsTo
+ public function project(): BelongsToThrough
{
- return empty($this->site) ? $this->site : $this->site->project();
+ return $this->belongsToThrough(
+ Project::class,
+ Site::class,
+ foreignKeyLookup: [Project::class => 'project_id', Site::class => 'site_id']
+ );
}
public function task(): BelongsTo
@@ -168,9 +177,13 @@ public function task(): BelongsTo
return $this->belongsTo(Task::class);
}
- public function organisation(): BelongsTo
+ public function organisation(): BelongsToThrough
{
- return empty($this->project) ? $this->project : $this->project->organisation();
+ return $this->belongsToThrough(
+ Organisation::class,
+ [Project::class, Site::class],
+ foreignKeyLookup: [Project::class => 'project_id', Site::class => 'site_id']
+ );
}
public function polygons(): MorphMany
@@ -306,8 +319,8 @@ public function getTaskUuidAttribute(): ?string
public function toSearchableArray()
{
return [
- 'project_name' => $this->site->project->name,
- 'organisation_name' => $this->organisation->name,
+ 'project_name' => $this->project?->name,
+ 'organisation_name' => $this->organisation?->name,
];
}
@@ -350,4 +363,14 @@ public function createResource(): JsonResource
{
return new SiteReportResource($this);
}
+
+ public function supportsNothingToReport(): bool
+ {
+ return true;
+ }
+
+ public function parentEntity(): BelongsTo
+ {
+ return $this->site();
+ }
}
diff --git a/app/Models/V2/User.php b/app/Models/V2/User.php
index 99b65afb9..27a3808b0 100644
--- a/app/Models/V2/User.php
+++ b/app/Models/V2/User.php
@@ -66,6 +66,7 @@ class User extends Authenticatable implements JWTSubject
'is_subscribed',
'has_consented',
'banners',
+ 'api_key',
];
protected $casts = [
diff --git a/app/Policies/AuthPolicy.php b/app/Policies/AuthPolicy.php
index b8e303261..00198272b 100644
--- a/app/Policies/AuthPolicy.php
+++ b/app/Policies/AuthPolicy.php
@@ -54,6 +54,9 @@ public function change(?UserModel $user, $model = null): bool
public function me(?UserModel $user, $model = null): bool
{
- return $this->isUser($user) || $this->isAdmin($user) || $this->isTerrafundAdmin($user);
+ return $this->isUser($user) ||
+ $this->isAdmin($user) ||
+ $this->isTerrafundAdmin($user) ||
+ $this->isServiceAccount($user);
}
}
diff --git a/app/Policies/Policy.php b/app/Policies/Policy.php
index 44c49df7c..26f5df35b 100644
--- a/app/Policies/Policy.php
+++ b/app/Policies/Policy.php
@@ -37,6 +37,11 @@ protected function isAdmin(?UserModel $user): bool
return ! $this->isGuest($user) && $user->role == 'admin';
}
+ protected function isServiceAccount(?UserModel $user): bool
+ {
+ return !$this->isGuest($user) && $user->role == 'service';
+ }
+
protected function isOrphanedUser(?UserModel $user): bool
{
return $this->isUser($user) && ! ((bool) $user->organisation_id) && (count($user->all_my_organisations) == 0);
diff --git a/app/Policies/V2/Projects/ProjectPolicy.php b/app/Policies/V2/Projects/ProjectPolicy.php
index 808fb1a07..0418ac7c4 100644
--- a/app/Policies/V2/Projects/ProjectPolicy.php
+++ b/app/Policies/V2/Projects/ProjectPolicy.php
@@ -20,6 +20,10 @@ public function read(?User $user, ?Project $project = null): bool
return true;
}
+ if ($user->can('projects-read')) {
+ return true;
+ }
+
return false;
}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 37ee36b1d..7b05f1ea4 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -2,6 +2,9 @@
namespace App\Providers;
+use App\Auth\ServiceAccountGuard;
+use Illuminate\Contracts\Foundation\Application;
+use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
@@ -22,5 +25,9 @@ public function boot()
if ($this->app->environment(['production', 'staging', 'test', 'development', 'dev', 'demo'])) {
URL::forceScheme('https');
}
+
+ Auth::extend('service-account', function (Application $app, string $name, array $config) {
+ return new ServiceAccountGuard($app['request']);
+ });
}
}
diff --git a/app/StateMachines/EntityStatusStateMachine.php b/app/StateMachines/EntityStatusStateMachine.php
index 2c3233fef..5427c1daf 100644
--- a/app/StateMachines/EntityStatusStateMachine.php
+++ b/app/StateMachines/EntityStatusStateMachine.php
@@ -2,6 +2,7 @@
namespace App\StateMachines;
+use App\Jobs\V2\SendEntityStatusChangeEmailsJob;
use Asantibanez\LaravelEloquentStateMachines\StateMachines\StateMachine;
class EntityStatusStateMachine extends StateMachine
@@ -30,4 +31,15 @@ public function defaultState(): ?string
{
return self::STARTED;
}
+
+ public function afterTransitionHooks(): array
+ {
+ $hooks = parent::afterTransitionHooks();
+
+ $sendStatusChangeEmail = fn ($fromStatus, $model) => SendEntityStatusChangeEmailsJob::dispatch($model);
+ $hooks[self::NEEDS_MORE_INFORMATION][] = $sendStatusChangeEmail;
+ $hooks[self::APPROVED][] = $sendStatusChangeEmail;
+
+ return $hooks;
+ }
}
diff --git a/app/StateMachines/ReportStatusStateMachine.php b/app/StateMachines/ReportStatusStateMachine.php
index 30218fb61..4ef3619bf 100644
--- a/app/StateMachines/ReportStatusStateMachine.php
+++ b/app/StateMachines/ReportStatusStateMachine.php
@@ -45,11 +45,18 @@ public function validatorForTransition($from, $to, $model): ?Validator
public function afterTransitionHooks(): array
{
+ $hooks = parent::afterTransitionHooks();
+
$updateTaskStatus = fn ($fromStatus, $model) => $model->task?->checkStatus();
- return [
- self::NEEDS_MORE_INFORMATION => [$updateTaskStatus],
- self::AWAITING_APPROVAL => [$updateTaskStatus],
- self::APPROVED => [$updateTaskStatus],
- ];
+ $hooks[self::NEEDS_MORE_INFORMATION][] = $updateTaskStatus;
+ $hooks[self::AWAITING_APPROVAL][] = $updateTaskStatus;
+ $hooks[self::APPROVED][] = $updateTaskStatus;
+
+ return $hooks;
+ }
+
+ private function addHook ($hooks, $status, $hook)
+ {
+ $hooks[$status] = [$hook];
}
}
diff --git a/app/StateMachines/UpdateRequestStatusStateMachine.php b/app/StateMachines/UpdateRequestStatusStateMachine.php
index 2e236df19..31754711c 100644
--- a/app/StateMachines/UpdateRequestStatusStateMachine.php
+++ b/app/StateMachines/UpdateRequestStatusStateMachine.php
@@ -2,6 +2,7 @@
namespace App\StateMachines;
+use App\Jobs\V2\SendEntityStatusChangeEmailsJob;
use App\Models\V2\EntityModel;
use App\Models\V2\ReportModel;
use App\Models\V2\UpdateRequests\UpdateRequest;
@@ -35,7 +36,9 @@ public function defaultState(): ?string
public function afterTransitionHooks(): array
{
- $updateTaskStatus = function (string $fromStatus, UpdateRequest $updateRequest) {
+ $hooks = parent::afterTransitionHooks();
+
+ $updateEntityStatus = function (string $fromStatus, UpdateRequest $updateRequest) {
/** @var EntityModel $model */
$model = $updateRequest->updaterequestable;
if (in_array('update_request_status', $model->getFillable())) {
@@ -50,10 +53,13 @@ public function afterTransitionHooks(): array
$model->task->checkStatus();
}
};
- return [
- self::NEEDS_MORE_INFORMATION => [$updateTaskStatus],
- self::AWAITING_APPROVAL => [$updateTaskStatus],
- self::APPROVED => [$updateTaskStatus],
- ];
+
+ $hooks[self::NEEDS_MORE_INFORMATION][] = $updateEntityStatus;
+ $hooks[self::NEEDS_MORE_INFORMATION][] = fn (string $fromStatus, UpdateRequest $updateRequest) =>
+ SendEntityStatusChangeEmailsJob::dispatch($updateRequest->updaterequestable);
+ $hooks[self::AWAITING_APPROVAL][] = $updateEntityStatus;
+ $hooks[self::APPROVED][] = $updateEntityStatus;
+
+ return $hooks;
}
}
diff --git a/app/Validators/ServiceAccountValidator.php b/app/Validators/ServiceAccountValidator.php
new file mode 100644
index 000000000..b4d9aecb8
--- /dev/null
+++ b/app/Validators/ServiceAccountValidator.php
@@ -0,0 +1,11 @@
+ 'required|string|email|between:1,255|unique:users,email_address',
+ 'api_key' => 'required|string|size:64|unique:users,api_key',
+ ];
+}
\ No newline at end of file
diff --git a/composer.json b/composer.json
index 5b9b7f6c3..c9b1ca1f3 100644
--- a/composer.json
+++ b/composer.json
@@ -29,6 +29,7 @@
"spatie/laravel-query-builder": "^5.1",
"spatie/laravel-tags": "^4.3",
"spatie/temporary-directory": "^2.0",
+ "staudenmeir/belongs-to-through": "^2.5",
"swaggest/json-diff": "^3.7",
"symfony/intl": "^6.2",
"symfony/yaml": "^6.2",
diff --git a/composer.lock b/composer.lock
index 7a38a64fe..954c389f0 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "23a6f39e237d46838969535ac510da42",
+ "content-hash": "a8ecce18a17e4ae23d1c89bf7fff3058",
"packages": [
{
"name": "asantibanez/laravel-eloquent-state-machines",
@@ -6641,6 +6641,71 @@
],
"time": "2023-09-25T07:13:36+00:00"
},
+ {
+ "name": "staudenmeir/belongs-to-through",
+ "version": "v2.15.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/staudenmeir/belongs-to-through.git",
+ "reference": "002b2eab60c03a41c0be709710300d22776b07a5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/staudenmeir/belongs-to-through/zipball/002b2eab60c03a41c0be709710300d22776b07a5",
+ "reference": "002b2eab60c03a41c0be709710300d22776b07a5",
+ "shasum": ""
+ },
+ "require": {
+ "illuminate/database": "^10.0",
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "barryvdh/laravel-ide-helper": "^2.13",
+ "orchestra/testbench": "^8.17",
+ "phpstan/phpstan": "^1.10",
+ "phpunit/phpunit": "^10.1"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Staudenmeir\\BelongsToThrough\\IdeHelperServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Znck\\Eloquent\\": "src/",
+ "Staudenmeir\\BelongsToThrough\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Rahul Kadyan",
+ "email": "hi@znck.me"
+ },
+ {
+ "name": "Jonas Staudenmeir",
+ "email": "mail@jonas-staudenmeir.de"
+ }
+ ],
+ "description": "Laravel Eloquent BelongsToThrough relationships",
+ "support": {
+ "issues": "https://github.com/staudenmeir/belongs-to-through/issues",
+ "source": "https://github.com/staudenmeir/belongs-to-through/tree/v2.15.1"
+ },
+ "funding": [
+ {
+ "url": "https://paypal.me/JonasStaudenmeir",
+ "type": "custom"
+ }
+ ],
+ "time": "2023-12-19T11:58:06+00:00"
+ },
{
"name": "swaggest/json-diff",
"version": "v3.10.4",
diff --git a/config/auth.php b/config/auth.php
index ee46d2547..76f82d4c1 100644
--- a/config/auth.php
+++ b/config/auth.php
@@ -45,6 +45,11 @@
'driver' => 'jwt',
'provider' => 'users'
],
+
+ 'service-api-key' => [
+ 'driver' => 'service-account',
+ 'provider' => 'users',
+ ],
],
/*
diff --git a/config/wri/permissions.php b/config/wri/permissions.php
index 194e13042..3a81863d2 100644
--- a/config/wri/permissions.php
+++ b/config/wri/permissions.php
@@ -7,5 +7,8 @@
'users-manage' => 'Manage users',
'monitoring-manage' => 'Manage monitoring',
'reports-manage' => 'Manage Reports',
- 'manage-own' => 'Manage own'
+ 'manage-own' => 'Manage own',
+ 'projects-read' => 'Read all projects',
+ 'polygons-manage' => 'Manage polygons',
+ 'media-manage' => 'Manage media',
];
diff --git a/database/migrations/2024_01_08_160230_seed_options_list_siting_strategy.php b/database/migrations/2024_01_08_160230_seed_options_list_siting_strategy.php
index 9a20523a6..f80af3230 100644
--- a/database/migrations/2024_01_08_160230_seed_options_list_siting_strategy.php
+++ b/database/migrations/2024_01_08_160230_seed_options_list_siting_strategy.php
@@ -7,7 +7,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Str;
-class SeedOptionsListSitingStrategy extends Migration
+return new class extends Migration
{
/**
* This migration seeds the default options
@@ -55,4 +55,4 @@ private function generateIfMissingI18nItem(Model $target, string $property): ?in
return data_get($target, $property . '_id');
}
-}
+};
diff --git a/database/migrations/2024_03_11_132400_seed_options_list_siting_strategy.php b/database/migrations/2024_03_11_132400_seed_options_list_siting_strategy.php
index 7c4d8a367..017e80c99 100644
--- a/database/migrations/2024_03_11_132400_seed_options_list_siting_strategy.php
+++ b/database/migrations/2024_03_11_132400_seed_options_list_siting_strategy.php
@@ -6,7 +6,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Str;
-class SeedOptionsListSitingStrategy extends Migration
+return new class extends Migration
{
/**
* This migration seeds the default options
@@ -36,4 +36,4 @@ public function up()
$option->save();
}
}
-}
+};
diff --git a/database/migrations/2024_03_29_230737_add_api_key_to_user.php b/database/migrations/2024_03_29_230737_add_api_key_to_user.php
new file mode 100644
index 000000000..241b061bd
--- /dev/null
+++ b/database/migrations/2024_03_29_230737_add_api_key_to_user.php
@@ -0,0 +1,28 @@
+string('api_key', length: 64)->nullable();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->dropColumn('api_key');
+ });
+ }
+};
diff --git a/database/migrations/2024_03_29_233951_add_service_to_users_role.php b/database/migrations/2024_03_29_233951_add_service_to_users_role.php
new file mode 100644
index 000000000..1645196dc
--- /dev/null
+++ b/database/migrations/2024_03_29_233951_add_service_to_users_role.php
@@ -0,0 +1,27 @@
+enum(...)->change() is not supported
+ // https://github.com/laravel/framework/issues/35096
+ DB::statement("ALTER TABLE users MODIFY COLUMN role enum('user', 'admin', 'terrafund_admin', 'service')");
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ DB::statement("ALTER TABLE users MODIFY COLUMN role enum('user', 'admin', 'terrafund_admin')");
+ }
+};
diff --git a/routes/api.php b/routes/api.php
index 43924cf11..ca9b3cc3d 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -112,19 +112,24 @@
Route::pattern('id', '[0-9]+');
-Route::middleware('throttle:30,1')->group(function () {
- Route::post('/auth/login', [AuthController::class, 'loginAction']);
- Route::get('/auth/resend', [AuthController::class, 'resendAction']);
- Route::post('/auth/reset', [AuthController::class, 'resetAction']);
- Route::patch('/auth/change', [AuthController::class, 'changeAction']);
- Route::patch('/v2/auth/verify', [AuthController::class, 'verifyUnauthorizedAction']);
+Route::withoutMiddleware('auth:service-api-key,api')->group(function () {
+ Route::middleware('throttle:30,1')->group(function () {
+ Route::post('/auth/login', [AuthController::class, 'loginAction']);
+ Route::get('/auth/resend', [AuthController::class, 'resendAction']);
+ Route::post('/auth/reset', [AuthController::class, 'resetAction']);
+ Route::patch('/auth/change', [AuthController::class, 'changeAction']);
+ Route::patch('/v2/auth/verify', [AuthController::class, 'verifyUnauthorizedAction']);
+ });
+
+ Route::get('/auth/logout', [AuthController::class, 'logoutAction']);
+ Route::get('/auth/refresh', [AuthController::class, 'refreshAction']);
+
+ Route::post('/users', [UsersController::class, 'createAction']);
});
-Route::get('/auth/logout', [AuthController::class, 'logoutAction']);
-Route::get('/auth/refresh', [AuthController::class, 'refreshAction']);
Route::patch('/auth/verify', [AuthController::class, 'verifyAction']);
-Route::get('/auth/me', [AuthController::class, 'meAction']);
Route::delete('/auth/delete_me', [AuthController::class, 'deleteMeAction']);
+Route::get('/auth/me', [AuthController::class, 'meAction']);
Route::post('/uploads', [UploadsController::class, 'createAction']);
Route::put('/uploads/{upload}/update', [UploadsController::class, 'updateAction']);
@@ -138,7 +143,6 @@
Route::post('/uploads/site_programme_media', [MediaUploadController::class, 'createAction']);
Route::get('/organisations/{id}/users', [UsersController::class, 'readAllByOrganisationAction']);
-Route::post('/users', [UsersController::class, 'createAction']);
Route::get('/users/all', [UsersController::class, 'readAllAction']);
Route::get('/users/unverified', [UsersController::class, 'readAllUnverifiedAction']);
Route::post('/users/invite', [UsersController::class, 'inviteAction']);