diff --git a/app/Audit/SponsorExtraQuestionDeleteAuditLog.php b/app/Audit/SponsorExtraQuestionDeleteAuditLog.php new file mode 100644 index 000000000..7a2f46f59 --- /dev/null +++ b/app/Audit/SponsorExtraQuestionDeleteAuditLog.php @@ -0,0 +1,142 @@ +getId(), + $summit->getId(), + $by->getEmail(), + $by->getId(), + $reason, + $answers_deleted, + $answers_modified + ); + + $request = request(); + + $data = [ + 'audit.action' => IAuditStrategy::ACTION_DELETE, + 'audit.event_type' => IAuditStrategy::EVENT_ENTITY_DELETION, + 'audit.entity' => $is_question ? 'SummitSponsorExtraQuestionType' : 'ExtraQuestionTypeValue', + 'audit.entity_id' => (string)$target_id, + 'audit.entity_class' => $is_question ? SummitSponsorExtraQuestionType::class : ExtraQuestionTypeValue::class, + 'audit.timestamp' => now()->toISOString(), + 'audit.summit_id' => (string)$summit->getId(), + 'audit.sponsor_id' => (string)$sponsor->getId(), + 'audit.reason' => $reason, + 'audit.target_label' => $target_label, + 'audit.answers_deleted' => $answers_deleted, + 'audit.answers_modified' => $answers_modified, + 'audit.description' => $description, + 'auth.user.id' => $by->getId(), + 'auth.user.email' => $by->getEmail(), + 'auth.user.first_name' => $by->getFirstName(), + 'auth.user.last_name' => $by->getLastName(), + 'http.route' => $request?->path(), + 'http.method' => $request?->method(), + 'client.ip' => $request?->ip(), + 'user_agent' => $request?->userAgent(), + 'elasticsearch.index' => config('opentelemetry.logs.elasticsearch_index', 'logs-audit'), + ]; + + // Metadata is a varchar(255), the full description (reason included) goes into Action + $repository->add(new SummitAuditLog( + $by, + $description, + $summit, + sprintf('sponsor:%s %s:%s', $sponsor->getId(), $target_type, $target_id) + )); + + return $data; + } + + /** + * Ships the audit data to the application log and, when enabled, to the OTLP pipeline. + * Call it once the delete transaction has committed. + * @param array $data as returned by record() + */ + public static function emit(array $data): void + { + Log::warning(self::LogMessage, $data); + + if (config('opentelemetry.enabled', false)) { + JobDispatcher::withDbFallback(job: new EmitAuditLogJob(self::LogMessage, $data)); + } + } +} diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSponsorApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSponsorApiController.php index b121c8e78..d9bc5c8a2 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSponsorApiController.php +++ b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitSponsorApiController.php @@ -27,6 +27,7 @@ use models\summit\ISummitRepository; use models\summit\Sponsor; use models\summit\SponsorMaterial; +use models\main\Member; use models\summit\Summit; use models\utils\IEntity; use ModelSerializers\SerializerRegistry; @@ -3684,8 +3685,8 @@ public function updateExtraQuestion($summit_id, $sponsor_id, $extra_question_id) #[OA\Delete( path: "/api/v1/summits/{id}/sponsors/{sponsor_id}/extra-questions/{extra_question_id}", - summary: 'Delete Sponsor Extra Question (always refused)', - description: "Always refused with 412: devices may hold answers for this question that the server has not seen yet. Edit the wording or turn off Mandatory instead. required-groups " . IGroup::SuperAdmins . ", " . IGroup::Administrators . ", " . IGroup::SummitAdministrators . ", " . IGroup::Sponsors . ", " . IGroup::SponsorExternalUsers, + summary: 'Delete Sponsor Extra Question (admin only)', + description: "Admin only (Super Admins, Administrators or Summit Administrators allowed on the summit), anyone else gets 403. Devices may hold answers for this question that the server has not seen yet, so the admin must have checked them first. It needs a reason and delete_answers (true or false), and is audited; when the server holds answers it is refused with their count unless delete_answers=true. required-groups " . IGroup::SuperAdmins . ", " . IGroup::Administrators . ", " . IGroup::SummitAdministrators, operationId: 'deleteSponsorExtraQuestion', tags: ['Sponsors'], x: [ @@ -3693,8 +3694,6 @@ public function updateExtraQuestion($summit_id, $sponsor_id, $extra_question_id) IGroup::SuperAdmins, IGroup::Administrators, IGroup::SummitAdministrators, - IGroup::Sponsors, - IGroup::SponsorExternalUsers, ] ], security: [ @@ -3727,13 +3726,38 @@ public function updateExtraQuestion($summit_id, $sponsor_id, $extra_question_id) schema: new OA\Schema(type: 'integer'), description: 'The extra question id' ), + new OA\Parameter( + name: 'delete_answers', + in: 'query', + required: false, + schema: new OA\Schema(type: 'boolean'), + description: 'Required, true or false, in the query string or in the body. With false and answers on the server it is refused with 412 and their count; true destroys them' + ), + new OA\Parameter( + name: 'reason', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string'), + description: 'Alternative to the body property for clients that cannot send a DELETE body. Required in one of the two, goes to the audit entry' + ), ], + requestBody: new OA\RequestBody( + required: false, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'reason', type: 'string', description: 'Required (here or in the query string), goes to the audit entry'), + new OA\Property(property: 'delete_answers', type: 'boolean', description: 'Alternative to the query parameter, required (here or in the query string)'), + ] + ) + ), responses: [ + new OA\Response(response: Response::HTTP_NO_CONTENT, description: 'Question deleted'), new OA\Response( response: Response::HTTP_PRECONDITION_FAILED, - description: "Validation Error - always returned: questions can't be deleted once created, because devices may already be using them" + description: "Validation Error - a reason or delete_answers is missing, or collected answers would be destroyed and delete_answers is false" ), new OA\Response(response: Response::HTTP_UNAUTHORIZED, description: "Unauthorized"), + new OA\Response(response: Response::HTTP_FORBIDDEN, description: "Forbidden - admins only"), new OA\Response(response: Response::HTTP_NOT_FOUND, description: "Not Found"), new OA\Response(response: Response::HTTP_INTERNAL_SERVER_ERROR, description: "Server Error"), ] @@ -3759,7 +3783,12 @@ public function deleteExtraQuestion($summit_id, $sponsor_id, $extra_question_id) if(!$current_member->isAuthzFor($summit, $sponsor)) throw new HTTP403ForbiddenException("You are not allowed to perform this action"); - $this->service->deleteSponsorExtraQuestion($summit, intval($sponsor_id), intval($extra_question_id)); + if (!$this->isAdminFor($current_member, $summit)) + throw new HTTP403ForbiddenException("Only an administrator can delete."); + + [$reason, $delete_answers] = $this->getDeleteParams(); + + $this->service->deleteSponsorExtraQuestion($summit, intval($sponsor_id), intval($extra_question_id), $current_member, $reason, $delete_answers); return $this->deleted(); @@ -3979,8 +4008,8 @@ function ($value_id, $payload, $summit, $sponsor_id, $extra_question_id) { use ParametrizedDeleteEntity; #[OA\Delete( path: "/api/v1/summits/{id}/sponsors/{sponsor_id}/extra-questions/{extra_question_id}/values/{value_id}", - description: "Always refused with 412: devices may hold answers for this option that the server has not seen yet. Edit the wording instead. required-groups " . IGroup::SuperAdmins . ", " . IGroup::Administrators . ", " . IGroup::SummitAdministrators . ", " . IGroup::Sponsors . ", " . IGroup::SponsorExternalUsers, - summary: 'Delete Extra Question Value (always refused)', + description: "Admin only (Super Admins, Administrators or Summit Administrators allowed on the summit), anyone else gets 403. Devices may hold answers for this option that the server has not seen yet, so the admin must have checked them first. It needs a reason and delete_answers (true or false), and is audited. Collected answers that selected the option are deleted when it was their only selection, otherwise they only lose its id; when the server holds any, it is refused with their count unless delete_answers=true. required-groups " . IGroup::SuperAdmins . ", " . IGroup::Administrators . ", " . IGroup::SummitAdministrators, + summary: 'Delete Extra Question Value (admin only)', operationId: 'deleteSponsorExtraQuestionValue', tags: ['Sponsors'], x: [ @@ -3988,8 +4017,6 @@ function ($value_id, $payload, $summit, $sponsor_id, $extra_question_id) { IGroup::SuperAdmins, IGroup::Administrators, IGroup::SummitAdministrators, - IGroup::Sponsors, - IGroup::SponsorExternalUsers, ] ], security: [ @@ -4029,13 +4056,38 @@ function ($value_id, $payload, $summit, $sponsor_id, $extra_question_id) { schema: new OA\Schema(type: 'integer'), description: 'The question value id' ), + new OA\Parameter( + name: 'delete_answers', + in: 'query', + required: false, + schema: new OA\Schema(type: 'boolean'), + description: 'Required, true or false, in the query string or in the body. With false and answers that selected the option on the server it is refused with 412 and their count; true changes them' + ), + new OA\Parameter( + name: 'reason', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string'), + description: 'Alternative to the body property for clients that cannot send a DELETE body. Required in one of the two, goes to the audit entry' + ), ], + requestBody: new OA\RequestBody( + required: false, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'reason', type: 'string', description: 'Required (here or in the query string), goes to the audit entry'), + new OA\Property(property: 'delete_answers', type: 'boolean', description: 'Alternative to the query parameter, required (here or in the query string)'), + ] + ) + ), responses: [ + new OA\Response(response: Response::HTTP_NO_CONTENT, description: 'Option deleted'), new OA\Response( response: Response::HTTP_PRECONDITION_FAILED, - description: "Validation Error - always returned: answer options can't be deleted once created, because devices may already be using them" + description: "Validation Error - a reason or delete_answers is missing, or collected answers would be changed and delete_answers is false" ), new OA\Response(response: Response::HTTP_UNAUTHORIZED, description: "Unauthorized"), + new OA\Response(response: Response::HTTP_FORBIDDEN, description: "Forbidden - admins only"), new OA\Response(response: Response::HTTP_NOT_FOUND, description: "Not Found"), new OA\Response(response: Response::HTTP_INTERNAL_SERVER_ERROR, description: "Server Error") ] @@ -4063,14 +4115,167 @@ public function deleteExtraQuestionValue($summit_id, $sponsor_id, $extra_questio if(!$current_member->isAuthzFor($summit, $sponsor)) throw new HTTP403ForbiddenException("You are not allowed to perform this action"); - return $this->_delete($value_id, function ($value_id, $summit, $sponsor_id, $extra_question_id) { - $this->service->deleteExtraQuestionValue($summit, intval($sponsor_id), intval($extra_question_id), intval($value_id)); + if (!$this->isAdminFor($current_member, $summit)) + throw new HTTP403ForbiddenException("Only an administrator can delete."); + + [$reason, $delete_answers] = $this->getDeleteParams(); + + return $this->_delete($value_id, function ($value_id, $summit, $sponsor_id, $extra_question_id) use ($current_member, $reason, $delete_answers) { + $this->service->deleteExtraQuestionValue($summit, intval($sponsor_id), intval($extra_question_id), intval($value_id), $current_member, $reason, $delete_answers); } , ...$args); }); } + /** + * Delete is for admins only: Super Admins, Administrators or Summit Administrators allowed on the summit. + * @param Member|null $member + * @param Summit $summit + * @return bool + */ + private function isAdminFor(?Member $member, Summit $summit): bool + { + if (is_null($member)) return false; + return $member->isAdmin() || ($member->isSummitAdmin() && $member->isSummitAllowed($summit)); + } + + /** + * reason and delete_answers can come in the body or the query string. + * delete_answers is null when missing or not a boolean, the service refuses it (412). + * @return array [?string $reason, ?bool $delete_answers] + */ + private function getDeleteParams(): array + { + $request = request(); + $reason = $request->input('reason'); + // filter_var turns a missing value into false, so it has to be checked first + $raw = $request->input('delete_answers'); + $delete_answers = (is_null($raw) || $raw === '') + ? null + : filter_var($raw, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + return [ + is_string($reason) ? $reason : null, + $delete_answers, + ]; + } + + #[OA\Get( + path: "/api/v1/summits/{id}/sponsors/{sponsor_id}/extra-questions/{extra_question_id}/usage", + description: "What deleting the question affects: collected answers on the server and the sponsor's badge scan activity (reps and their last scan). It does not prove devices have nothing pending. required-groups " . IGroup::SuperAdmins . ", " . IGroup::Administrators . ", " . IGroup::SummitAdministrators, + summary: 'Get Sponsor Extra Question usage', + operationId: 'getSponsorExtraQuestionUsage', + tags: ['Sponsors'], + x: [ + 'required-groups' => [ + IGroup::SuperAdmins, + IGroup::Administrators, + IGroup::SummitAdministrators, + ] + ], + security: [ + [ + 'summit_sponsor_oauth2' => [ + SummitScopes::ReadSummitData, + SummitScopes::ReadAllSummitData, + SummitScopes::ReadSponsorExtraQuestions, + ] + ] + ], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer'), description: 'The summit id'), + new OA\Parameter(name: 'sponsor_id', in: 'path', required: true, schema: new OA\Schema(type: 'integer'), description: 'The sponsor id'), + new OA\Parameter(name: 'extra_question_id', in: 'path', required: true, schema: new OA\Schema(type: 'integer'), description: 'The extra question id'), + ], + responses: [ + new OA\Response(response: Response::HTTP_OK, description: 'answers_count, reps_count and reps (member_id, first_name, last_name, email, scans_count, last_scan_date)'), + new OA\Response(response: Response::HTTP_UNAUTHORIZED, description: "Unauthorized"), + new OA\Response(response: Response::HTTP_FORBIDDEN, description: "Forbidden"), + new OA\Response(response: Response::HTTP_NOT_FOUND, description: "Not Found"), + new OA\Response(response: Response::HTTP_INTERNAL_SERVER_ERROR, description: "Server Error"), + ] + )] + /** + * @param $summit_id + * @param $sponsor_id + * @param $extra_question_id + * @return mixed + */ + public function getExtraQuestionUsage($summit_id, $sponsor_id, $extra_question_id) + { + return $this->processRequest(function () use ($summit_id, $sponsor_id, $extra_question_id) { + $summit = SummitFinderStrategyFactory::build($this->getSummitRepository(), $this->resource_server_context)->find($summit_id); + if (is_null($summit)) return $this->error404(); + + $sponsor = $summit->getSummitSponsorById(intval($sponsor_id)); + if (is_null($sponsor)) return $this->error404(); + + if (!$this->isAdminFor($this->resource_server_context->getCurrentUser(), $summit)) + throw new HTTP403ForbiddenException("You are not allowed to perform this action"); + + return $this->ok($this->service->getSponsorExtraQuestionUsage($summit, intval($sponsor_id), intval($extra_question_id))); + }); + } + + #[OA\Get( + path: "/api/v1/summits/{id}/sponsors/{sponsor_id}/extra-questions/{extra_question_id}/values/{value_id}/usage", + description: "What deleting the option affects: collected answers that selected it (answers_to_delete when it was their only selection, answers_to_modify when they only lose its id) and the sponsor's badge scan activity. It does not prove devices have nothing pending. required-groups " . IGroup::SuperAdmins . ", " . IGroup::Administrators . ", " . IGroup::SummitAdministrators, + summary: 'Get Extra Question Value usage', + operationId: 'getSponsorExtraQuestionValueUsage', + tags: ['Sponsors'], + x: [ + 'required-groups' => [ + IGroup::SuperAdmins, + IGroup::Administrators, + IGroup::SummitAdministrators, + ] + ], + security: [ + [ + 'summit_sponsor_oauth2' => [ + SummitScopes::ReadSummitData, + SummitScopes::ReadAllSummitData, + SummitScopes::ReadSponsorExtraQuestions, + ] + ] + ], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer'), description: 'The summit id'), + new OA\Parameter(name: 'sponsor_id', in: 'path', required: true, schema: new OA\Schema(type: 'integer'), description: 'The sponsor id'), + new OA\Parameter(name: 'extra_question_id', in: 'path', required: true, schema: new OA\Schema(type: 'integer'), description: 'The extra question id'), + new OA\Parameter(name: 'value_id', in: 'path', required: true, schema: new OA\Schema(type: 'integer'), description: 'The question value id'), + ], + responses: [ + new OA\Response(response: Response::HTTP_OK, description: 'answers_count, answers_to_delete, answers_to_modify, reps_count and reps (member_id, first_name, last_name, email, scans_count, last_scan_date)'), + new OA\Response(response: Response::HTTP_UNAUTHORIZED, description: "Unauthorized"), + new OA\Response(response: Response::HTTP_FORBIDDEN, description: "Forbidden"), + new OA\Response(response: Response::HTTP_NOT_FOUND, description: "Not Found"), + new OA\Response(response: Response::HTTP_INTERNAL_SERVER_ERROR, description: "Server Error"), + ] + )] + /** + * @param $summit_id + * @param $sponsor_id + * @param $extra_question_id + * @param $value_id + * @return mixed + */ + public function getExtraQuestionValueUsage($summit_id, $sponsor_id, $extra_question_id, $value_id) + { + return $this->processRequest(function () use ($summit_id, $sponsor_id, $extra_question_id, $value_id) { + $summit = SummitFinderStrategyFactory::build($this->getSummitRepository(), $this->resource_server_context)->find($summit_id); + if (is_null($summit)) return $this->error404(); + + $sponsor = $summit->getSummitSponsorById(intval($sponsor_id)); + if (is_null($sponsor)) return $this->error404(); + + if (!$this->isAdminFor($this->resource_server_context->getCurrentUser(), $summit)) + throw new HTTP403ForbiddenException("You are not allowed to perform this action"); + + return $this->ok($this->service->getExtraQuestionValueUsage($summit, intval($sponsor_id), intval($extra_question_id), intval($value_id))); + }); + } + #[OA\Get( path: "/api/v1/summits/{id}/sponsors/{sponsor_id}/lead-report-settings/metadata", description: "required-groups " . IGroup::SuperAdmins . ", " . IGroup::Administrators . ", " . IGroup::SummitAdministrators . ", " . IGroup::Sponsors . ", " . IGroup::SponsorExternalUsers, diff --git a/app/Models/Foundation/Summit/Repositories/ISponsorExtraQuestionTypeRepository.php b/app/Models/Foundation/Summit/Repositories/ISponsorExtraQuestionTypeRepository.php index 82356a143..913b1f60d 100644 --- a/app/Models/Foundation/Summit/Repositories/ISponsorExtraQuestionTypeRepository.php +++ b/app/Models/Foundation/Summit/Repositories/ISponsorExtraQuestionTypeRepository.php @@ -12,12 +12,34 @@ * limitations under the License. **/ +use App\Models\Foundation\ExtraQuestions\ExtraQuestionType; use App\Models\Foundation\ExtraQuestions\IExtraQuestionTypeRepository; +use models\summit\SponsorBadgeScanExtraQuestionAnswer; /** * Interface ISponsorExtraQuestionTypeRepository * @package App\Models\Foundation\Summit\Repositories */ interface ISponsorExtraQuestionTypeRepository extends IExtraQuestionTypeRepository { + /** + * Every badge scan answer collected so far for a question. + * @param ExtraQuestionType $question + * @return SponsorBadgeScanExtraQuestionAnswer[] + */ + public function getBadgeScanAnswersByQuestion(ExtraQuestionType $question): array; -} \ No newline at end of file + /** + * How many badge scan answers were collected so far for a question, without hydrating them. + * @param ExtraQuestionType $question + * @return int + */ + public function countBadgeScanAnswersByQuestion(ExtraQuestionType $question): int; + + /** + * Takes a write lock on the question row, to be called at the top of a transaction. + * Badge scan ingestion takes a shared lock on that row when InnoDB checks the answer FK, so + * the answers read afterwards can't change until the transaction ends. + * @param ExtraQuestionType $question + */ + public function lockQuestion(ExtraQuestionType $question): void; +} diff --git a/app/Models/Foundation/Summit/Repositories/ISponsorUserInfoGrantRepository.php b/app/Models/Foundation/Summit/Repositories/ISponsorUserInfoGrantRepository.php index 0788b1261..6cdfb04bd 100644 --- a/app/Models/Foundation/Summit/Repositories/ISponsorUserInfoGrantRepository.php +++ b/app/Models/Foundation/Summit/Repositories/ISponsorUserInfoGrantRepository.php @@ -29,4 +29,12 @@ interface ISponsorUserInfoGrantRepository extends IBaseRepository * @return SponsorBadgeScan|null */ public function findExistingBadgeScan(Sponsor $sponsor, SummitAttendeeBadge $badge, Member $member, \DateTime $scan_date): ?SponsorBadgeScan; -} \ No newline at end of file + + /** + * Badge scan activity of a sponsor, one row per rep (member) that scanned for it, + * most recently active first. + * @param Sponsor $sponsor + * @return array + */ + public function getScanActivityBySponsor(Sponsor $sponsor): array; +} diff --git a/app/Repositories/Summit/DoctrineSponsorExtraQuestionTypeRepository.php b/app/Repositories/Summit/DoctrineSponsorExtraQuestionTypeRepository.php index 1bca6bab1..ff5c7a6be 100644 --- a/app/Repositories/Summit/DoctrineSponsorExtraQuestionTypeRepository.php +++ b/app/Repositories/Summit/DoctrineSponsorExtraQuestionTypeRepository.php @@ -12,11 +12,14 @@ * limitations under the License. **/ +use App\Models\Foundation\ExtraQuestions\ExtraQuestionType; use App\Models\Foundation\ExtraQuestions\ExtraQuestionTypeConstants; use App\Models\Foundation\Summit\ExtraQuestions\SummitSponsorExtraQuestionType; use App\Models\Foundation\Summit\Repositories\ISponsorExtraQuestionTypeRepository; use App\Repositories\Main\DoctrineExtraQuestionTypeRepository; use models\summit\Sponsor; +use models\summit\SponsorBadgeScanExtraQuestionAnswer; +use Doctrine\DBAL\LockMode; use utils\DoctrineLeftJoinFilterMapping; use utils\Filter; use utils\Order; @@ -102,4 +105,42 @@ function ($query) { return $query; }); } -} \ No newline at end of file + + /** + * @inheritDoc + */ + public function getBadgeScanAnswersByQuestion(ExtraQuestionType $question): array + { + return $this->getEntityManager() + ->createQueryBuilder() + ->select("a") + ->from(SponsorBadgeScanExtraQuestionAnswer::class, "a") + ->where("a.question = :question") + ->setParameter("question", $question) + ->getQuery() + ->getResult(); + } + + /** + * @inheritDoc + */ + public function countBadgeScanAnswersByQuestion(ExtraQuestionType $question): int + { + return (int)$this->getEntityManager() + ->createQueryBuilder() + ->select("count(a.id)") + ->from(SponsorBadgeScanExtraQuestionAnswer::class, "a") + ->where("a.question = :question") + ->setParameter("question", $question) + ->getQuery() + ->getSingleScalarResult(); + } + + /** + * @inheritDoc + */ + public function lockQuestion(ExtraQuestionType $question): void + { + $this->getEntityManager()->lock($question, LockMode::PESSIMISTIC_WRITE); + } +} diff --git a/app/Repositories/Summit/DoctrineSponsorUserInfoGrantRepository.php b/app/Repositories/Summit/DoctrineSponsorUserInfoGrantRepository.php index 92608c434..c874f0fa7 100644 --- a/app/Repositories/Summit/DoctrineSponsorUserInfoGrantRepository.php +++ b/app/Repositories/Summit/DoctrineSponsorUserInfoGrantRepository.php @@ -158,4 +158,22 @@ public function findExistingBadgeScan(Sponsor $sponsor, SummitAttendeeBadge $bad return $query->getQuery()->getOneOrNullResult(); } + + /** + * @inheritDoc + */ + public function getScanActivityBySponsor(Sponsor $sponsor): array + { + return $this->getEntityManager() + ->createQueryBuilder() + ->select("m.id AS member_id, m.first_name AS first_name, m.last_name AS last_name, m.email AS email, COUNT(e.id) AS scans_count, MAX(e.scan_date) AS last_scan_date") + ->from(SponsorBadgeScan::class, "e") + ->join("e.user", "m") + ->where("e.sponsor = :sponsor") + ->setParameter("sponsor", $sponsor) + ->groupBy("m.id") + ->orderBy("last_scan_date", "DESC") + ->getQuery() + ->getArrayResult(); + } } diff --git a/app/Services/Model/ISummitSponsorService.php b/app/Services/Model/ISummitSponsorService.php index c64d0b16a..5ee65b688 100644 --- a/app/Services/Model/ISummitSponsorService.php +++ b/app/Services/Model/ISummitSponsorService.php @@ -20,6 +20,7 @@ use models\exceptions\EntityNotFoundException; use models\exceptions\ValidationException; use models\main\File; +use models\main\Member; use models\summit\Sponsor; use models\summit\SponsorAd; use models\summit\SponsorMaterial; @@ -271,14 +272,30 @@ public function addSponsorExtraQuestion(Summit $summit, int $sponsor_id, array $ public function updateSponsorExtraQuestion(Summit $summit, int $sponsor_id, int $extra_question_id, array $payload):SummitSponsorExtraQuestionType; /** + * $deleted_by is an admin the caller already authorized: a device may hold answers the + * server has not seen, and only an admin who checked the devices can tell a delete is safe. + * Refused when the server holds answers and $delete_answers is false. * @param Summit $summit * @param int $sponsor_id * @param int $extra_question_id + * @param Member $deleted_by the admin deleting, goes to the audit entry + * @param string|null $reason required (ValidationException if blank), goes to the audit entry + * @param bool $delete_answers confirms the collected answers of the question are destroyed * @return void * @throws EntityNotFoundException * @throws ValidationException */ - public function deleteSponsorExtraQuestion(Summit $summit, int $sponsor_id, int $extra_question_id):void; + public function deleteSponsorExtraQuestion(Summit $summit, int $sponsor_id, int $extra_question_id, Member $deleted_by, ?string $reason, ?bool $delete_answers):void; + + /** + * What deleting the question would affect. + * @param Summit $summit + * @param int $sponsor_id + * @param int $extra_question_id + * @return array + * @throws EntityNotFoundException + */ + public function getSponsorExtraQuestionUsage(Summit $summit, int $sponsor_id, int $extra_question_id):array; /** * @param Summit $summit @@ -302,13 +319,32 @@ public function addExtraQuestionValue(Summit $summit, int $sponsor_id ,int $ques public function updateExtraQuestionValue(Summit $summit, int $sponsor_id, int $question_id, int $value_id, array $payload):ExtraQuestionTypeValue; /** + * Admin only, see deleteSponsorExtraQuestion. + * Collected answers that selected the option are deleted when it was their only + * selection, otherwise they only lose its id. * @param Summit $summit * @param int $sponsor_id * @param int $question_id * @param int $value_id + * @param Member $deleted_by the admin deleting, goes to the audit entry + * @param string|null $reason required (ValidationException if blank), goes to the audit entry + * @param bool $delete_answers confirms the collected answers that selected the option are changed * @return void + * @throws EntityNotFoundException + * @throws ValidationException + */ + public function deleteExtraQuestionValue(Summit $summit, int $sponsor_id, int $question_id, int $value_id, Member $deleted_by, ?string $reason, ?bool $delete_answers):void; + + /** + * What deleting the option would affect. + * @param Summit $summit + * @param int $sponsor_id + * @param int $question_id + * @param int $value_id + * @return array + * @throws EntityNotFoundException */ - public function deleteExtraQuestionValue(Summit $summit, int $sponsor_id, int $question_id, int $value_id):void; + public function getExtraQuestionValueUsage(Summit $summit, int $sponsor_id, int $question_id, int $value_id):array; /** * @param Summit $summit diff --git a/app/Services/Model/Imp/SummitSponsorService.php b/app/Services/Model/Imp/SummitSponsorService.php index b464a0279..d7ed335f5 100644 --- a/app/Services/Model/Imp/SummitSponsorService.php +++ b/app/Services/Model/Imp/SummitSponsorService.php @@ -12,14 +12,18 @@ * limitations under the License. **/ +use App\Audit\SponsorExtraQuestionDeleteAuditLog; use App\Events\SponsorServices\SponsorDomainEvents; use App\Events\SponsorServices\SummitSponsorCreatedEventDTO; use App\Events\SponsorServices\SummitSponsorshipCreatedEventDTO; use App\Events\SponsorServices\DeletedEventDTO; use App\Http\Utils\IFileUploader; use App\Jobs\SponsorServices\PublishSponsorServiceDomainEventsJob; +use App\Models\Foundation\ExtraQuestions\ExtraQuestionType; +use App\Models\Foundation\ExtraQuestions\ExtraQuestionTypeConstants; use App\Models\Foundation\ExtraQuestions\ExtraQuestionTypeValue; use App\Models\Foundation\Main\IFileConstants; +use App\Models\Foundation\Main\Repositories\IAuditLogRepository; use App\Models\Foundation\Summit\ExtraQuestions\SummitSponsorExtraQuestionType; use App\Models\Foundation\Summit\Factories\LeadReportSettingsFactory; use App\Models\Foundation\Summit\Factories\SponsorAdFactory; @@ -43,8 +47,10 @@ use models\main\ICompanyRepository; use models\main\IMemberRepository; use models\main\Member; +use models\summit\ISponsorUserInfoGrantRepository; use models\summit\Sponsor; use models\summit\SponsorAd; +use models\summit\SponsorBadgeScanExtraQuestionAnswer; use models\summit\SponsorMaterial; use models\summit\SponsorSocialNetwork; use models\summit\Summit; @@ -80,12 +86,24 @@ final class SummitSponsorService */ private $file_uploader; + /** + * @var ISponsorUserInfoGrantRepository + */ + private $user_info_grant_repository; + + /** + * @var IAuditLogRepository + */ + private $audit_log_repository; + /** * @param IMemberRepository $member_repository * @param ICompanyRepository $company_repository * @param ISponsorExtraQuestionTypeRepository $repository * @param ISummitSponsorshipRepository $sponsorship_repository + * @param ISponsorUserInfoGrantRepository $user_info_grant_repository * @param IFileUploader $file_uploader + * @param IAuditLogRepository $audit_log_repository * @param ITransactionService $tx_service */ public function __construct @@ -94,11 +112,15 @@ public function __construct ICompanyRepository $company_repository, ISponsorExtraQuestionTypeRepository $repository, ISummitSponsorshipRepository $sponsorship_repository, + ISponsorUserInfoGrantRepository $user_info_grant_repository, IFileUploader $file_uploader, + IAuditLogRepository $audit_log_repository, ITransactionService $tx_service ) { parent::__construct($tx_service); + $this->audit_log_repository = $audit_log_repository; + $this->user_info_grant_repository = $user_info_grant_repository; $this->member_repository = $member_repository; $this->company_repository = $company_repository; $this->sponsorship_repository = $sponsorship_repository; @@ -1082,12 +1104,191 @@ public function updateSponsorExtraQuestion(Summit $summit, int $sponsor_id, int /** * @inheritDoc */ - public function deleteSponsorExtraQuestion(Summit $summit, int $sponsor_id, int $extra_question_id): void + public function deleteSponsorExtraQuestion + ( + Summit $summit, + int $sponsor_id, + int $extra_question_id, + Member $deleted_by, + ?string $reason, + ?bool $delete_answers + ): void + { + $reason = $this->requireDeleteReason($reason); + $delete_answers = $this->requireDeleteAnswers($delete_answers); + + $audit_data = $this->tx_service->transaction(function () use ($summit, $sponsor_id, $extra_question_id, $deleted_by, $reason, $delete_answers) { + $summit_sponsor = $summit->getSummitSponsorById($sponsor_id); + if (is_null($summit_sponsor)) + throw new EntityNotFoundException("Sponsor not found."); + + $extra_question = $summit_sponsor->getExtraQuestionById($extra_question_id); + + if (!$extra_question instanceof SummitSponsorExtraQuestionType) + throw new EntityNotFoundException("Sponsor extra question not found."); + + // serialize with badge scan ingestion: an answer committed after the read below would be + // cascade deleted (ExtraQuestionAnswer.QuestionID is ON DELETE CASCADE) without being counted + $this->repository->lockQuestion($extra_question); + + // a count is enough to refuse, the answers are only loaded when they are going to be removed + $answers_count = $this->repository->countBadgeScanAnswersByQuestion($extra_question); + + if ($answers_count > 0 && !$delete_answers) { + throw new ValidationException( + sprintf( + "%s collected answers will be permanently deleted. Send delete_answers=true to confirm.", + $answers_count + ) + ); + } + + // the FK would cascade the delete anyway, the explicit removal goes through the ORM so + // orphan removal, LastEdited and the onFlush audit run, and the count is exact (see lockQuestion) + $answers = $answers_count > 0 ? $this->repository->getBadgeScanAnswersByQuestion($extra_question) : []; + foreach ($answers as $answer) { + $this->removeBadgeScanAnswer($answer); + } + + $label = $extra_question->getLabel(); + $summit_sponsor->removeExtraQuestion($extra_question); + + return SponsorExtraQuestionDeleteAuditLog::record( + $this->audit_log_repository, + $deleted_by, + $summit, + $summit_sponsor, + SponsorExtraQuestionDeleteAuditLog::TargetQuestion, + $extra_question_id, + $label, + $reason, + $answers_count + ); + }); + + SponsorExtraQuestionDeleteAuditLog::emit($audit_data); + } + + /** + * @inheritDoc + */ + public function getSponsorExtraQuestionUsage(Summit $summit, int $sponsor_id, int $extra_question_id): array { - // Always refused: a device may hold answers for this question that the server has not seen yet. - throw new ValidationException( - "Questions can't be deleted once created, because devices may already be using them. You can edit the wording, turn off Mandatory, or contact support." - ); + return $this->tx_service->transaction(function () use ($summit, $sponsor_id, $extra_question_id) { + $summit_sponsor = $summit->getSummitSponsorById($sponsor_id); + if (is_null($summit_sponsor)) + throw new EntityNotFoundException("Sponsor not found."); + + $extra_question = $summit_sponsor->getExtraQuestionById($extra_question_id); + + if (!$extra_question instanceof SummitSponsorExtraQuestionType) + throw new EntityNotFoundException("Sponsor extra question not found."); + + return array_merge( + [ + 'answers_count' => $this->repository->countBadgeScanAnswersByQuestion($extra_question), + ], + $this->getScanActivity($summit_sponsor) + ); + }); + } + + /** + * Removes a collected answer from its badge scan (the scan owns it, orphan removal deletes it). + * @param SponsorBadgeScanExtraQuestionAnswer $answer + */ + private function removeBadgeScanAnswer(SponsorBadgeScanExtraQuestionAnswer $answer): void + { + $scan = $answer->getBadgeScan(); + if (is_null($scan)) { + $this->repository->delete($answer); + return; + } + $scan->removeExtraQuestionAnswer($answer); + $scan->updateLastEdited(); + } + + /** + * @param string|null $reason + * @return string + * @throws ValidationException + */ + private function requireDeleteReason(?string $reason): string + { + $reason = trim($reason ?? ''); + if ($reason === '') + throw new ValidationException("A reason is required to delete."); + return $reason; + } + + /** + * @param bool|null $delete_answers + * @return bool + * @throws ValidationException + */ + private function requireDeleteAnswers(?bool $delete_answers): bool + { + if (is_null($delete_answers)) + throw new ValidationException("delete_answers is required (true or false)."); + return $delete_answers; + } + + /** + * Badge scan activity of the sponsor, to see what is at stake before deleting. + * It does not prove devices have nothing pending, only the devices can show that. + * @param Sponsor $sponsor + * @return array + */ + private function getScanActivity(Sponsor $sponsor): array + { + $reps = []; + foreach ($this->user_info_grant_repository->getScanActivityBySponsor($sponsor) as $row) { + $last_scan_date = $row['last_scan_date']; + $reps[] = [ + 'member_id' => intval($row['member_id']), + 'first_name' => $row['first_name'], + 'last_name' => $row['last_name'], + 'email' => $row['email'], + 'scans_count' => intval($row['scans_count']), + 'last_scan_date' => $last_scan_date instanceof \DateTimeInterface + ? $last_scan_date->getTimestamp() + : (new \DateTime($last_scan_date))->getTimestamp(), + ]; + } + return [ + 'reps_count' => count($reps), + 'reps' => $reps, + ]; + } + + /** + * Collected answers that use an option. The answer value holds the ids of the + * selected options separated by commas, so it is matched id by id: a plain + * substring match would take 15 for 115 and a legacy free text like "15 apples" for 15. + * Answers whose only selected option is this one are to be deleted, the others + * only lose the id from their list. + * @param ExtraQuestionType $question + * @param int $value_id + * @return array [SponsorBadgeScanExtraQuestionAnswer[] $to_delete, array $to_modify] + */ + private function splitAnswersUsingOption(ExtraQuestionType $question, int $value_id): array + { + $to_delete = []; + $to_modify = []; + foreach ($this->repository->getBadgeScanAnswersByQuestion($question) as $answer) { + $selected = array_map('trim', explode(ExtraQuestionTypeConstants::AnswerCharDelimiter, $answer->getValue())); + $remaining = array_values(array_filter( + $selected, + fn(string $id) => $id !== '' && !(ctype_digit($id) && intval($id) === $value_id) + )); + $uses_option = count($remaining) !== count(array_filter($selected, fn(string $id) => $id !== '')); + if (!$uses_option) continue; + if (count($remaining) === 0) + $to_delete[] = $answer; + else + $to_modify[] = [$answer, implode(ExtraQuestionTypeConstants::AnswerCharDelimiter, $remaining)]; + } + return [$to_delete, $to_modify]; } /** @@ -1143,19 +1344,109 @@ public function updateExtraQuestionValue(Summit $summit, int $sponsor_id, int $q } /** - * @param Summit $summit - * @param int $sponsor_id - * @param int $question_id - * @param int $value_id - * @return void - * @throws \Exception + * @inheritDoc */ - public function deleteExtraQuestionValue(Summit $summit, int $sponsor_id, int $question_id, int $value_id): void + public function deleteExtraQuestionValue + ( + Summit $summit, + int $sponsor_id, + int $question_id, + int $value_id, + Member $deleted_by, + ?string $reason, + ?bool $delete_answers + ): void { - // Always refused: a device may hold answers for this option that the server has not seen yet. - throw new ValidationException( - "Answer options can't be deleted once created, because devices may already be using them. You can edit the wording, or contact support." - ); + $reason = $this->requireDeleteReason($reason); + $delete_answers = $this->requireDeleteAnswers($delete_answers); + + $audit_data = $this->tx_service->transaction(function () use ($summit, $sponsor_id, $question_id, $value_id, $deleted_by, $reason, $delete_answers) { + $summit_sponsor = $summit->getSummitSponsorById($sponsor_id); + if (is_null($summit_sponsor)) + throw new EntityNotFoundException("Sponsor not found."); + + $extra_question = $summit_sponsor->getExtraQuestionById($question_id); + + if (!$extra_question instanceof SummitSponsorExtraQuestionType) + throw new EntityNotFoundException("Sponsor extra question not found."); + + $value = $extra_question->getValueById($value_id); + if (is_null($value)) + throw new EntityNotFoundException("value not found"); + + // serialize with badge scan ingestion, so the answers split below are the ones that get changed + $this->repository->lockQuestion($extra_question); + + [$to_delete, $to_modify] = $this->splitAnswersUsingOption($extra_question, $value_id); + + if ((count($to_delete) + count($to_modify)) > 0 && !$delete_answers) { + throw new ValidationException( + sprintf( + "%s collected answers use this option: %s will be permanently deleted and %s will only lose this option. Send delete_answers=true to confirm.", + count($to_delete) + count($to_modify), + count($to_delete), + count($to_modify) + ) + ); + } + + foreach ($to_delete as $answer) { + $this->removeBadgeScanAnswer($answer); + } + + foreach ($to_modify as [$answer, $remaining]) { + $answer->setValue($remaining); + } + + $label = $value->getLabel(); + $extra_question->removeValue($value); + + return SponsorExtraQuestionDeleteAuditLog::record( + $this->audit_log_repository, + $deleted_by, + $summit, + $summit_sponsor, + SponsorExtraQuestionDeleteAuditLog::TargetOption, + $value_id, + $label, + $reason, + count($to_delete), + count($to_modify) + ); + }); + + SponsorExtraQuestionDeleteAuditLog::emit($audit_data); + } + + /** + * @inheritDoc + */ + public function getExtraQuestionValueUsage(Summit $summit, int $sponsor_id, int $question_id, int $value_id): array + { + return $this->tx_service->transaction(function () use ($summit, $sponsor_id, $question_id, $value_id) { + $summit_sponsor = $summit->getSummitSponsorById($sponsor_id); + if (is_null($summit_sponsor)) + throw new EntityNotFoundException("Sponsor not found."); + + $extra_question = $summit_sponsor->getExtraQuestionById($question_id); + + if (!$extra_question instanceof SummitSponsorExtraQuestionType) + throw new EntityNotFoundException("Sponsor extra question not found."); + + if (is_null($extra_question->getValueById($value_id))) + throw new EntityNotFoundException("value not found"); + + [$to_delete, $to_modify] = $this->splitAnswersUsingOption($extra_question, $value_id); + + return array_merge( + [ + 'answers_count' => count($to_delete) + count($to_modify), + 'answers_to_delete' => count($to_delete), + 'answers_to_modify' => count($to_modify), + ], + $this->getScanActivity($summit_sponsor) + ); + }); } /** diff --git a/database/migrations/config/Version20261009130000.php b/database/migrations/config/Version20261009130000.php new file mode 100644 index 000000000..c18d3a0d2 --- /dev/null +++ b/database/migrations/config/Version20261009130000.php @@ -0,0 +1,111 @@ + 'get-sponsor-extra-question-usage', + 'route' => '/api/v1/summits/{id}/sponsors/{sponsor_id}/extra-questions/{extra_question_id}/usage', + 'http_method' => 'GET', + 'scopes' => [ + SummitScopes::ReadSummitData, + SummitScopes::ReadAllSummitData, + SummitScopes::ReadSponsorExtraQuestions, + ], + 'authz_groups' => [ + IGroup::SuperAdmins, + IGroup::Administrators, + IGroup::SummitAdministrators, + ], + ], + [ + 'name' => 'get-sponsor-extra-question-value-usage', + 'route' => '/api/v1/summits/{id}/sponsors/{sponsor_id}/extra-questions/{extra_question_id}/values/{value_id}/usage', + 'http_method' => 'GET', + 'scopes' => [ + SummitScopes::ReadSummitData, + SummitScopes::ReadAllSummitData, + SummitScopes::ReadSponsorExtraQuestions, + ], + 'authz_groups' => [ + IGroup::SuperAdmins, + IGroup::Administrators, + IGroup::SummitAdministrators, + ], + ], + ]; + + private const DELETE_ENDPOINTS = [ + 'delete-sponsor-extra-question', + 'delete-sponsor-extra-question-value', + ]; + + private const SPONSOR_GROUPS = [ + IGroup::Sponsors, + IGroup::SponsorExternalUsers, + ]; + + public function getDescription(): string + { + return 'Register the sponsor extra question and answer option usage endpoints, make their DELETE admin only.'; + } + + public function up(Schema $schema): void + { + $this->registerEndpoints(self::API_NAME, self::ENDPOINTS); + + foreach (self::DELETE_ENDPOINTS as $endpoint) { + foreach (self::SPONSOR_GROUPS as $group) { + $this->addSql($this->deleteEndpointAuthzGroup(self::API_NAME, $endpoint, $group)); + } + } + } + + public function down(Schema $schema): void + { + foreach (self::DELETE_ENDPOINTS as $endpoint) { + foreach (self::SPONSOR_GROUPS as $group) { + $this->addSql($this->insertEndpointAuthzGroup(self::API_NAME, $endpoint, $group)); + } + } + + $this->unregisterEndpoints(self::API_NAME, array_column(self::ENDPOINTS, 'name')); + } +} diff --git a/database/seeders/ApiEndpointsSeeder.php b/database/seeders/ApiEndpointsSeeder.php index f7af63e2e..af083c4c3 100644 --- a/database/seeders/ApiEndpointsSeeder.php +++ b/database/seeders/ApiEndpointsSeeder.php @@ -2578,8 +2578,6 @@ private function seedSummitEndpoints() IGroup::SuperAdmins, IGroup::Administrators, IGroup::SummitAdministrators, - IGroup::Sponsors, - IGroup::SponsorExternalUsers, ] ], [ @@ -2643,8 +2641,36 @@ private function seedSummitEndpoints() IGroup::SuperAdmins, IGroup::Administrators, IGroup::SummitAdministrators, - IGroup::Sponsors, - IGroup::SponsorExternalUsers, + ] + ], + [ + 'name' => 'get-sponsor-extra-question-usage', + 'route' => '/api/v1/summits/{id}/sponsors/{sponsor_id}/extra-questions/{extra_question_id}/usage', + 'http_method' => 'GET', + 'scopes' => [ + SummitScopes::ReadSummitData, + SummitScopes::ReadAllSummitData, + SummitScopes::ReadSponsorExtraQuestions, + ], + 'authz_groups' => [ + IGroup::SuperAdmins, + IGroup::Administrators, + IGroup::SummitAdministrators, + ] + ], + [ + 'name' => 'get-sponsor-extra-question-value-usage', + 'route' => '/api/v1/summits/{id}/sponsors/{sponsor_id}/extra-questions/{extra_question_id}/values/{value_id}/usage', + 'http_method' => 'GET', + 'scopes' => [ + SummitScopes::ReadSummitData, + SummitScopes::ReadAllSummitData, + SummitScopes::ReadSponsorExtraQuestions, + ], + 'authz_groups' => [ + IGroup::SuperAdmins, + IGroup::Administrators, + IGroup::SummitAdministrators, ] ], // lead report settings diff --git a/routes/api_v1.php b/routes/api_v1.php index 537d27590..bf0df1cd1 100644 --- a/routes/api_v1.php +++ b/routes/api_v1.php @@ -1350,12 +1350,14 @@ Route::get('', ['middleware' => 'auth.user', 'uses' => 'OAuth2SummitSponsorApiController@getExtraQuestion']); Route::put('', ['middleware' => 'auth.user', 'uses' => 'OAuth2SummitSponsorApiController@updateExtraQuestion']); Route::delete('', ['middleware' => 'auth.user', 'uses' => 'OAuth2SummitSponsorApiController@deleteExtraQuestion']); + Route::get('usage', ['middleware' => 'auth.user', 'uses' => 'OAuth2SummitSponsorApiController@getExtraQuestionUsage']); // multi values Route::group(['prefix' => 'values'], function () { Route::post('', ['middleware' => 'auth.user', 'uses' => 'OAuth2SummitSponsorApiController@addExtraQuestionValue']); Route::group(['prefix' => '{value_id}'], function () { Route::put('', ['middleware' => 'auth.user', 'uses' => 'OAuth2SummitSponsorApiController@updateExtraQuestionValue']); Route::delete('', ['middleware' => 'auth.user', 'uses' => 'OAuth2SummitSponsorApiController@deleteExtraQuestionValue']); + Route::get('usage', ['middleware' => 'auth.user', 'uses' => 'OAuth2SummitSponsorApiController@getExtraQuestionValueUsage']); }); }); }); diff --git a/tests/oauth2/OAuth2SummitSponsorApiTest.php b/tests/oauth2/OAuth2SummitSponsorApiTest.php index 6fbf2aa0e..47384ca96 100644 --- a/tests/oauth2/OAuth2SummitSponsorApiTest.php +++ b/tests/oauth2/OAuth2SummitSponsorApiTest.php @@ -17,6 +17,7 @@ use Mockery; use models\summit\SummitLeadReportSetting; use App\Models\Foundation\Main\IGroup; +use App\Models\ResourceServer\IAccessTokenService; /** * Class OAuth2SummitSponsorApiTest */ @@ -690,28 +691,29 @@ public function testUpdateSponsorExtraQuestionsBySponsor(){ return $question; } - public function testDeleteSponsorExtraQuestionsBySponsor(){ + public function testDeleteSponsorExtraQuestionsByAdmin(){ $q = $this->testAddSponsorExtraQuestions(); $params = [ 'id' => self::$summit->getId(), 'sponsor_id' => self::$sponsors[0]->getId(), - 'extra_question_id' => $q->id + 'extra_question_id' => $q->id, + 'delete_answers' => 'false', ]; - $response = $this->action( + $this->action( "DELETE", "OAuth2SummitSponsorApiController@deleteExtraQuestion", $params, [], [], [], - $this->getAuthHeaders() + $this->getAuthHeaders(), + json_encode(['reason' => 'created by mistake']) ); - // questions can never be deleted: devices may hold answers the server has not seen - $this->assertResponseStatus(412); - $this->assertStringContainsString("Questions can't be deleted once created", $response->getContent()); + // admin only, with a reason and an explicit delete_answers + $this->assertResponseStatus(204); } public function testAddLeadReportSettings(){ @@ -1604,21 +1606,127 @@ public function testDeleteExtraQuestionValue(){ 'sponsor_id' => self::$sponsors[0]->getId(), 'extra_question_id' => $result->question_id, 'value_id' => $result->value->id, + 'delete_answers' => 'false', ]; - $response = $this->action( + $this->action( "DELETE", "OAuth2SummitSponsorApiController@deleteExtraQuestionValue", $params, [], [], [], + $this->getAuthHeaders(), + json_encode(['reason' => 'created by mistake']) + ); + + // admin only, with a reason and an explicit delete_answers + $this->assertResponseStatus(204); + } + + // ---- Delete is for admins only ---- + + /** + * Makes the user a member of sponsors[0] so the sponsor authz passes. It has to run + * before the first request: the entity manager is closed after each one. + */ + private function makeMemberSponsorUser(): void + { + $sponsor = self::$sponsors[0]; + $sponsor->addUser(self::$member); + self::$em->persist(self::$member); + self::$em->persist($sponsor); + self::$em->flush(); + self::$member->addSponsorPermission($sponsor->getId(), IGroup::Sponsors); + } + + /** + * The token stub grants the user the administrators group, and the first request syncs + * the token groups into the member's DB groups, so swap it for one that only has the + * sponsors group before the first request. + */ + private function useSponsorOnlyToken(): void + { + $stub = new AccessTokenServiceStub([['slug' => IGroup::Sponsors]]); + $stub->setUserId(self::$member->getUserExternalId()); + $stub->setUserExternalId(self::$member->getUserExternalId()); + $stub->setUserEmail(self::$member->getEmail()); + $stub->setUserFirstName(self::$member->getFirstName()); + $stub->setUserLastName(self::$member->getLastName()); + self::$service = $stub; + $this->app->singleton(IAccessTokenService::class, function () use ($stub) { return $stub; }); + } + + public function testDeleteSponsorExtraQuestionBySponsorIsForbidden(){ + $this->makeMemberSponsorUser(); + $this->useSponsorOnlyToken(); + $q = $this->testAddSponsorExtraQuestions(); + + $params = [ + 'id' => self::$summit->getId(), + 'sponsor_id' => self::$sponsors[0]->getId(), + 'extra_question_id' => $q->id, + ]; + + // the sponsor is authorized on the sponsor, but delete is admin only + $this->action( + "DELETE", + "OAuth2SummitSponsorApiController@deleteExtraQuestion", + array_merge($params, ['delete_answers' => 'true']), + [], + [], + [], + $this->getAuthHeaders(), + json_encode(['reason' => 'sponsor insists']) + ); + $this->assertResponseStatus(403); + } + + public function testDeleteExtraQuestionValueBySponsorIsForbidden(){ + $this->makeMemberSponsorUser(); + $this->useSponsorOnlyToken(); + $result = $this->testAddExtraQuestionValue(); + + $params = [ + 'id' => self::$summit->getId(), + 'sponsor_id' => self::$sponsors[0]->getId(), + 'extra_question_id' => $result->question_id, + 'value_id' => $result->value->id, + ]; + + $this->action( + "DELETE", + "OAuth2SummitSponsorApiController@deleteExtraQuestionValue", + array_merge($params, ['delete_answers' => 'true']), + [], + [], + [], + $this->getAuthHeaders(), + json_encode(['reason' => 'sponsor insists']) + ); + $this->assertResponseStatus(403); + } + + public function testGetExtraQuestionUsageBySponsorIsForbidden(){ + $this->makeMemberSponsorUser(); + $this->useSponsorOnlyToken(); + $q = $this->testAddSponsorExtraQuestions(); + + $this->action( + "GET", + "OAuth2SummitSponsorApiController@getExtraQuestionUsage", + [ + 'id' => self::$summit->getId(), + 'sponsor_id' => self::$sponsors[0]->getId(), + 'extra_question_id' => $q->id, + ], + [], + [], + [], $this->getAuthHeaders() ); - // answer options can never be deleted: devices may hold answers the server has not seen - $this->assertResponseStatus(412); - $this->assertStringContainsString("Answer options can't be deleted once created", $response->getContent()); + $this->assertResponseStatus(403); } // ---- Public API ---- diff --git a/tests/oauth2/OAuth2SummitSponsorExtraQuestionDeleteApiTest.php b/tests/oauth2/OAuth2SummitSponsorExtraQuestionDeleteApiTest.php new file mode 100644 index 000000000..fb6324712 --- /dev/null +++ b/tests/oauth2/OAuth2SummitSponsorExtraQuestionDeleteApiTest.php @@ -0,0 +1,549 @@ +shouldIgnoreMissing(); + + $fileUploaderMock->shouldReceive('build')->andReturn(new \models\main\File()); + + $app->instance(\App\Http\Utils\IFileUploader::class, $fileUploaderMock); + + return $app; + } + + protected function setUp(): void + { + parent::setUp(); + self::$defaultMember = self::$member; + self::$defaultMember2 = self::$member2; + self::insertSummitTestData(); + } + + protected function tearDown(): void + { + Mockery::close(); + self::clearSummitTestData(); + parent::tearDown(); + } + + // ---- helpers ---- + + /** + * Sponsors are seeded with the maximum number of extra questions: makes room directly + * on the model, then adds a question of the given type with the given option labels. + * @param string $type + * @param string[] $option_labels + * @return array [question id, option ids] + */ + private function addQuestion(string $type, array $option_labels = []): array + { + $sponsor = self::$sponsors[0]; + $sponsor->removeExtraQuestion($sponsor->getExtraQuestions()->last()); + self::$em->persist($sponsor); + self::$em->flush(); + + $question = new SummitSponsorExtraQuestionType(); + $question->setType($type); + $question->setLabel('Delete question'); + $question->setName('DELETE_' . str_random(5)); + $values = []; + foreach ($option_labels as $label) { + $value = new ExtraQuestionTypeValue(); + $value->setLabel($label); + $value->setValue($label); + $question->addValue($value); + $values[] = $value; + } + $sponsor->addExtraQuestion($question); + self::$em->persist($sponsor); + self::$em->flush(); + + return [$question->getId(), array_map(fn($v) => $v->getId(), $values)]; + } + + /** + * A scan of the sponsor that carries the given answer for the question. + * @param int $question_id + * @param string $answer + * @param int $idx keeps the scans distinct + * @return int scan id + */ + private function addScanWithAnswer(int $question_id, string $answer, int $idx): int + { + $attendee = self::$summit->getAttendeeByMemberId(self::$defaultMember->getId()); + $service = App::make(ISponsorUserInfoGrantService::class); + $scan = $service->addBadgeScan(self::$summit, self::$member, [ + 'qr_code' => $attendee->getFirstTicket()->getBadge()->generateQRCode(), + 'scan_date' => 1572019200 + $idx, + 'sponsor_id' => self::$sponsors[0]->getId(), + 'extra_questions' => [ + ['question_id' => $question_id, 'answer' => $answer], + ], + ]); + return $scan->getId(); + } + + private function answersOf(int $question_id): array + { + $em = Registry::getManager(SilverstripeBaseModel::EntityManager); + $em->clear(); + $answers = $em->getRepository(SponsorBadgeScanExtraQuestionAnswer::class) + ->createQueryBuilder('a') + ->where('IDENTITY(a.question) = :q') + ->setParameter('q', $question_id) + ->getQuery() + ->getResult(); + return $answers; + } + + private function answerValueOfScan(int $scan_id): ?string + { + $em = Registry::getManager(SilverstripeBaseModel::EntityManager); + $em->clear(); + $scan = $em->getRepository(SponsorBadgeScan::class)->find($scan_id); + $answer = $scan->getExtraQuestionAnswers()->first(); + return $answer === false ? null : $answer->getValue(); + } + + private function sponsorQuestionIds(): array + { + $em = Registry::getManager(SilverstripeBaseModel::EntityManager); + $em->clear(); + return $em->getRepository(SummitSponsorExtraQuestionType::class) + ->createQueryBuilder('q') + ->select('q.id') + ->where('IDENTITY(q.sponsor) = :s') + ->setParameter('s', self::$sponsors[0]->getId()) + ->getQuery() + ->getSingleColumnResult(); + } + + private function deleteQuestion(int $question_id, array $query = [], ?array $body = null) + { + return $this->action( + "DELETE", + "OAuth2SummitSponsorApiController@deleteExtraQuestion", + array_merge([ + 'id' => self::$summit->getId(), + 'sponsor_id' => self::$sponsors[0]->getId(), + 'extra_question_id' => $question_id, + ], $query), + [], + [], + [], + $this->getAuthHeaders(), + is_null($body) ? null : json_encode($body) + ); + } + + private function deleteValue(int $question_id, int $value_id, array $query = [], ?array $body = null) + { + return $this->action( + "DELETE", + "OAuth2SummitSponsorApiController@deleteExtraQuestionValue", + array_merge([ + 'id' => self::$summit->getId(), + 'sponsor_id' => self::$sponsors[0]->getId(), + 'extra_question_id' => $question_id, + 'value_id' => $value_id, + ], $query), + [], + [], + [], + $this->getAuthHeaders(), + is_null($body) ? null : json_encode($body) + ); + } + + /** + * The delete audit entry that was emitted, if any. + * @return EmitAuditLogJob|null + */ + private function emittedAudit(): ?EmitAuditLogJob + { + $jobs = Bus::dispatched(EmitAuditLogJob::class); + return $jobs->isEmpty() ? null : $jobs->first(); + } + + /** + * The delete audit records persisted for the given target. + * @param string $target_type question | option + * @param int $target_id + * @return SummitAuditLog[] + */ + private function persistedAudits(string $target_type, int $target_id): array + { + $em = Registry::getManager(SilverstripeBaseModel::EntityManager); + $em->clear(); + $logs = $em->getRepository(SummitAuditLog::class)->findBy(['summit' => self::$summit->getId()]); + return array_values(array_filter($logs, function (SummitAuditLog $log) use ($target_type, $target_id) { + return str_ends_with((string)$log->getMetadata(), sprintf(' %s:%s', $target_type, $target_id)); + })); + } + + protected function enableAuditCapture(): void + { + config(['opentelemetry.enabled' => true]); + Bus::fake([EmitAuditLogJob::class]); + } + + // ---- questions ---- + + public function testAdminDeleteQuestionWithoutDeleteAnswersIsRefused() + { + [$question_id] = $this->addQuestion(ExtraQuestionTypeConstants::TextQuestionType); + + $response = $this->deleteQuestion($question_id, [], ['reason' => 'cleanup']); + + $this->assertResponseStatus(412); + $this->assertStringContainsString("delete_answers is required", $response->getContent()); + $this->assertContains($question_id, $this->sponsorQuestionIds()); + } + + public function testAdminDeleteQuestionWithoutReasonIsRefused() + { + [$question_id] = $this->addQuestion(ExtraQuestionTypeConstants::TextQuestionType); + + $response = $this->deleteQuestion($question_id, ['delete_answers' => 'false']); + $this->assertResponseStatus(412); + $this->assertStringContainsString("A reason is required", $response->getContent()); + + $this->deleteQuestion($question_id, ['delete_answers' => 'false'], ['reason' => ' ']); + $this->assertResponseStatus(412); + + $this->assertContains($question_id, $this->sponsorQuestionIds()); + } + + public function testAdminDeleteUnknownQuestionIsNotFound() + { + $this->deleteQuestion(999999999, ['delete_answers' => 'false'], ['reason' => 'cleanup']); + $this->assertResponseStatus(404); + } + + public function testAdminDeleteQuestionWithoutAnswersFreesTheSlotAndIsAudited() + { + $this->enableAuditCapture(); + [$question_id] = $this->addQuestion(ExtraQuestionTypeConstants::TextQuestionType); + // the sponsor is at the cap + $this->assertCount(5, $this->sponsorQuestionIds()); + + $this->deleteQuestion($question_id, ['delete_answers' => 'false'], ['reason' => 'created by mistake, 0 pending uploads']); + + $this->assertResponseStatus(204); + $this->assertCount(4, $this->sponsorQuestionIds()); + $this->assertNotContains($question_id, $this->sponsorQuestionIds()); + + $audit = $this->emittedAudit(); + $this->assertNotNull($audit); + $this->assertEquals('created by mistake, 0 pending uploads', $audit->auditData['audit.reason']); + $this->assertEquals((string)$question_id, $audit->auditData['audit.entity_id']); + $this->assertEquals(0, $audit->auditData['audit.answers_deleted']); + $this->assertEquals((string)self::$member->getId(), (string)$audit->auditData['auth.user.id']); + + $logs = $this->persistedAudits('question', $question_id); + $this->assertCount(1, $logs); + $this->assertEquals(self::$member->getId(), $logs[0]->getUser()->getId()); + $this->assertStringContainsString('Reason: created by mistake, 0 pending uploads', $logs[0]->getAction()); + + // the slot is free right away + $this->action( + "POST", + "OAuth2SummitSponsorApiController@addExtraQuestion", + [ + 'id' => self::$summit->getId(), + 'sponsor_id' => self::$sponsors[0]->getId(), + ], + [], + [], + [], + $this->getAuthHeaders(), + json_encode([ + 'name' => 'REPLACEMENT_' . str_random(5), + 'type' => ExtraQuestionTypeConstants::TextQuestionType, + 'label' => 'Replacement question', + 'mandatory' => false, + ]) + ); + $this->assertResponseStatus(201); + $this->assertCount(5, $this->sponsorQuestionIds()); + } + + public function testAdminDeleteQuestionWithAnswersIsRefusedWithTheirCount() + { + [$question_id] = $this->addQuestion(ExtraQuestionTypeConstants::TextQuestionType); + $this->addScanWithAnswer($question_id, 'one', 1); + $this->addScanWithAnswer($question_id, 'two', 2); + $this->addScanWithAnswer($question_id, 'three', 3); + + $response = $this->deleteQuestion($question_id, ['delete_answers' => 'false'], ['reason' => 'sponsor insists']); + + $this->assertResponseStatus(412); + $this->assertStringContainsString("3 collected answers will be permanently deleted", $response->getContent()); + $this->assertContains($question_id, $this->sponsorQuestionIds()); + $this->assertCount(3, $this->answersOf($question_id)); + // nothing was deleted, so nothing is audited + $this->assertCount(0, $this->persistedAudits('question', $question_id)); + } + + public function testAdminDeleteQuestionWithAnswersAndConfirmationDestroysThemAndIsAudited() + { + $this->enableAuditCapture(); + [$question_id] = $this->addQuestion(ExtraQuestionTypeConstants::TextQuestionType); + $scan_ids = [ + $this->addScanWithAnswer($question_id, 'one', 1), + $this->addScanWithAnswer($question_id, 'two', 2), + $this->addScanWithAnswer($question_id, 'three', 3), + ]; + + $this->deleteQuestion($question_id, ['delete_answers' => 'true'], ['reason' => 'sponsor insists']); + + $this->assertResponseStatus(204); + $this->assertNotContains($question_id, $this->sponsorQuestionIds()); + $this->assertCount(0, $this->answersOf($question_id)); + // the scans themselves are kept + $em = Registry::getManager(SilverstripeBaseModel::EntityManager); + foreach ($scan_ids as $scan_id) { + $this->assertNotNull($em->getRepository(SponsorBadgeScan::class)->find($scan_id)); + } + + $audit = $this->emittedAudit(); + $this->assertNotNull($audit); + $this->assertEquals(3, $audit->auditData['audit.answers_deleted']); + $this->assertEquals('sponsor insists', $audit->auditData['audit.reason']); + } + + public function testAdminDeleteQuestionIsPersistedInTheAuditLogWhenOpenTelemetryIsOff() + { + config(['opentelemetry.enabled' => false]); + Bus::fake([EmitAuditLogJob::class]); + [$question_id] = $this->addQuestion(ExtraQuestionTypeConstants::TextQuestionType); + + $this->deleteQuestion($question_id, ['delete_answers' => 'false'], ['reason' => 'created by mistake']); + + $this->assertResponseStatus(204); + $this->assertNull($this->emittedAudit()); + $logs = $this->persistedAudits('question', $question_id); + $this->assertCount(1, $logs); + $this->assertStringContainsString('Reason: created by mistake', $logs[0]->getAction()); + } + + public function testAdminDeleteQuestionWithReasonAndDeleteAnswersInTheQueryString() + { + [$question_id] = $this->addQuestion(ExtraQuestionTypeConstants::TextQuestionType); + $this->addScanWithAnswer($question_id, 'one', 1); + + // for clients that can't send a DELETE body + $this->deleteQuestion($question_id, ['delete_answers' => 'true', 'reason' => 'no body']); + + $this->assertResponseStatus(204); + $this->assertNotContains($question_id, $this->sponsorQuestionIds()); + $this->assertCount(0, $this->answersOf($question_id)); + } + + public function testAdminDeleteQuestionWithReasonAndDeleteAnswersInTheBody() + { + [$question_id] = $this->addQuestion(ExtraQuestionTypeConstants::TextQuestionType); + $this->addScanWithAnswer($question_id, 'one', 1); + + $this->deleteQuestion($question_id, [], ['delete_answers' => true, 'reason' => 'all in the body']); + + $this->assertResponseStatus(204); + $this->assertNotContains($question_id, $this->sponsorQuestionIds()); + $this->assertCount(0, $this->answersOf($question_id)); + } + + // ---- options ---- + + public function testAdminDeleteOptionWithoutDeleteAnswersIsRefused() + { + [$question_id, [$a]] = $this->addQuestion(ExtraQuestionTypeConstants::CheckBoxListQuestionType, ['A', 'B']); + + $response = $this->deleteValue($question_id, $a, [], ['reason' => 'cleanup']); + $this->assertResponseStatus(412); + $this->assertStringContainsString("delete_answers is required", $response->getContent()); + } + + public function testAdminDeleteOptionWithoutReasonIsRefused() + { + [$question_id, [$a]] = $this->addQuestion(ExtraQuestionTypeConstants::CheckBoxListQuestionType, ['A', 'B']); + + $this->deleteValue($question_id, $a, ['delete_answers' => 'false']); + $this->assertResponseStatus(412); + } + + public function testAdminDeleteUnusedOptionDeletesIt() + { + $this->enableAuditCapture(); + [$question_id, [$a, $b]] = $this->addQuestion(ExtraQuestionTypeConstants::CheckBoxListQuestionType, ['A', 'B']); + // only B is used + $this->addScanWithAnswer($question_id, (string)$b, 1); + + $this->deleteValue($question_id, $a, ['delete_answers' => 'false'], ['reason' => 'option created by mistake']); + + $this->assertResponseStatus(204); + $audit = $this->emittedAudit(); + $this->assertNotNull($audit); + $this->assertEquals(0, $audit->auditData['audit.answers_deleted']); + $this->assertEquals(0, $audit->auditData['audit.answers_modified']); + // B is untouched + $this->assertCount(1, $this->answersOf($question_id)); + } + + public function testAdminDeleteUsedOptionIsRefusedWithTheCountOfDeletedAndModifiedAnswers() + { + [$question_id, [$a, $b, $c]] = $this->addQuestion(ExtraQuestionTypeConstants::CheckBoxListQuestionType, ['A', 'B', 'C']); + $this->addScanWithAnswer($question_id, (string)$a, 1); // only A: deleted + $this->addScanWithAnswer($question_id, "$a,$b", 2); // A and B: loses A + $this->addScanWithAnswer($question_id, "$b,$c", 3); // no A: untouched + + $response = $this->deleteValue($question_id, $a, ['delete_answers' => 'false'], ['reason' => 'sponsor insists']); + + $this->assertResponseStatus(412); + $this->assertStringContainsString("2 collected answers use this option: 1 will be permanently deleted and 1 will only lose this option", $response->getContent()); + $this->assertCount(3, $this->answersOf($question_id)); + } + + public function testAdminDeleteUsedOptionWithConfirmationDeletesOnlyAnswersWithNoOtherOptionAndStripsTheRest() + { + $this->enableAuditCapture(); + [$question_id, [$a, $b, $c]] = $this->addQuestion(ExtraQuestionTypeConstants::CheckBoxListQuestionType, ['A', 'B', 'C']); + $only_a = $this->addScanWithAnswer($question_id, (string)$a, 1); + $a_and_b = $this->addScanWithAnswer($question_id, "$a,$b", 2); + $b_and_c = $this->addScanWithAnswer($question_id, "$b,$c", 3); + $only_c = $this->addScanWithAnswer($question_id, (string)$c, 4); + + $this->deleteValue($question_id, $a, ['delete_answers' => 'true'], ['reason' => 'sponsor insists']); + + $this->assertResponseStatus(204); + $this->assertNull($this->answerValueOfScan($only_a), 'the answer that only selected the option is deleted'); + $this->assertEquals((string)$b, $this->answerValueOfScan($a_and_b), 'the option id is taken out of the list'); + $this->assertEquals("$b,$c", $this->answerValueOfScan($b_and_c)); + $this->assertEquals((string)$c, $this->answerValueOfScan($only_c)); + + $audit = $this->emittedAudit(); + $this->assertNotNull($audit); + $this->assertEquals(1, $audit->auditData['audit.answers_deleted']); + $this->assertEquals(1, $audit->auditData['audit.answers_modified']); + $this->assertEquals('sponsor insists', $audit->auditData['audit.reason']); + + $logs = $this->persistedAudits('option', $a); + $this->assertCount(1, $logs); + $this->assertStringContainsString('Collected answers deleted: 1, modified: 1', $logs[0]->getAction()); + + // the option is gone + $this->deleteValue($question_id, $a, ['delete_answers' => 'false'], ['reason' => 'again']); + $this->assertResponseStatus(404); + } + + public function testAdminDeleteOptionWithReasonAndDeleteAnswersInTheQueryString() + { + [$question_id, [$a]] = $this->addQuestion(ExtraQuestionTypeConstants::CheckBoxListQuestionType, ['A', 'B']); + $this->addScanWithAnswer($question_id, (string)$a, 1); + + $this->deleteValue($question_id, $a, ['delete_answers' => 'true', 'reason' => 'no body']); + + $this->assertResponseStatus(204); + $this->assertCount(0, $this->answersOf($question_id)); + } + + // ---- usage ---- + + public function testAdminGetsQuestionUsage() + { + [$question_id] = $this->addQuestion(ExtraQuestionTypeConstants::TextQuestionType); + $this->addScanWithAnswer($question_id, 'one', 1); + $this->addScanWithAnswer($question_id, 'two', 2); + + $response = $this->action( + "GET", + "OAuth2SummitSponsorApiController@getExtraQuestionUsage", + [ + 'id' => self::$summit->getId(), + 'sponsor_id' => self::$sponsors[0]->getId(), + 'extra_question_id' => $question_id, + ], + [], + [], + [], + $this->getAuthHeaders() + ); + + $this->assertResponseStatus(200); + $usage = json_decode($response->getContent()); + $this->assertEquals(2, $usage->answers_count); + $this->assertEquals(1, $usage->reps_count); + $this->assertEquals(self::$member->getId(), $usage->reps[0]->member_id); + $this->assertEquals(2, $usage->reps[0]->scans_count); + $this->assertGreaterThan(0, $usage->reps[0]->last_scan_date); + } + + public function testAdminGetsOptionUsage() + { + [$question_id, [$a, $b]] = $this->addQuestion(ExtraQuestionTypeConstants::CheckBoxListQuestionType, ['A', 'B']); + $this->addScanWithAnswer($question_id, (string)$a, 1); + $this->addScanWithAnswer($question_id, "$a,$b", 2); + $this->addScanWithAnswer($question_id, (string)$b, 3); + + $response = $this->action( + "GET", + "OAuth2SummitSponsorApiController@getExtraQuestionValueUsage", + [ + 'id' => self::$summit->getId(), + 'sponsor_id' => self::$sponsors[0]->getId(), + 'extra_question_id' => $question_id, + 'value_id' => $a, + ], + [], + [], + [], + $this->getAuthHeaders() + ); + + $this->assertResponseStatus(200); + $usage = json_decode($response->getContent()); + $this->assertEquals(2, $usage->answers_count); + $this->assertEquals(1, $usage->answers_to_delete); + $this->assertEquals(1, $usage->answers_to_modify); + $this->assertEquals(1, $usage->reps_count); + } +}