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']);