Refine survey editor workflow and publication handling
This commit is contained in:
@@ -41,14 +41,30 @@ final class SurveyEditorService
|
||||
$this->surveyRepository->update($survey, $memberId, $data->toArray());
|
||||
}
|
||||
|
||||
public function toggleSurveyPublished(SurveyModel $survey, int $memberId): bool
|
||||
public function publishSurvey(SurveyModel $survey, int $memberId): void
|
||||
{
|
||||
$data = SurveyEditorData::fromModel($survey);
|
||||
$data->published = !$data->published;
|
||||
if ('1' === (string) $survey->published) {
|
||||
throw new \RuntimeException('Die Umfrage ist bereits veröffentlicht.');
|
||||
}
|
||||
|
||||
$this->surveyRepository->update($survey, $memberId, $data->toArray());
|
||||
$this->surveyRepository->publish((int) $survey->id, $memberId);
|
||||
}
|
||||
|
||||
return $data->published;
|
||||
public function toggleSurveyActive(SurveyModel $survey, int $memberId): bool
|
||||
{
|
||||
if ('1' !== (string) $survey->published) {
|
||||
throw new \RuntimeException('Die Umfrage muss zuerst veröffentlicht werden.');
|
||||
}
|
||||
|
||||
$isActive = '1' !== (string) ($survey->isActive ?? '');
|
||||
$this->surveyRepository->setActive((int) $survey->id, $memberId, $isActive);
|
||||
|
||||
return $isActive;
|
||||
}
|
||||
|
||||
public function isStructureLocked(SurveyModel $survey): bool
|
||||
{
|
||||
return '1' === (string) $survey->published || '1' === (string) $survey->isLocked;
|
||||
}
|
||||
|
||||
public function saveQuestion(SurveyModel $survey, SurveyQuestionData $data, int $questionId = 0): int
|
||||
@@ -117,8 +133,8 @@ final class SurveyEditorService
|
||||
|
||||
private function assertUnlocked(SurveyModel $survey): void
|
||||
{
|
||||
if ('1' === (string) $survey->isLocked) {
|
||||
throw new \RuntimeException('Die Umfrage ist gesperrt, weil bereits Antworten existieren.');
|
||||
if ($this->isStructureLocked($survey)) {
|
||||
throw new \RuntimeException('Die Fragen dieser Umfrage können nach der Veröffentlichung oder nach ersten Teilnahmen nicht mehr geändert werden.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,28 @@ final class SurveyFlowService
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getFirstQuestion(SurveyModel $survey): ?SurveyContentModel
|
||||
{
|
||||
$questions = $this->getOrderedQuestions($survey);
|
||||
|
||||
return $questions[0] ?? null;
|
||||
}
|
||||
|
||||
public function resolveQuestionById(SurveyModel $survey, int $questionId): ?SurveyContentModel
|
||||
{
|
||||
if ($questionId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($this->getOrderedQuestions($survey) as $question) {
|
||||
if ((int) $question->id === $questionId) {
|
||||
return $question;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{current:int,total:int,percentage:int}
|
||||
*/
|
||||
|
||||
@@ -23,7 +23,7 @@ final class SurveyPermissionService
|
||||
public function assertEditorAccess(SurveyModel $survey, FrontendUser $member): void
|
||||
{
|
||||
if (!$this->canEditSurvey($survey, $member)) {
|
||||
throw new AccessDeniedHttpException('Sie duerfen diese Umfrage nicht bearbeiten.');
|
||||
throw new AccessDeniedHttpException('Sie dürfen diese Umfrage nicht bearbeiten.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,118 +41,191 @@ final class SurveyResultsViewService
|
||||
|
||||
/**
|
||||
* @param list<SurveyContentModel> $questions
|
||||
* @param list<array{questionId:int,value:string}> $answers
|
||||
* @param list<array{questionId:int,questionType:string,questionLabel:string,questionDescription:string,questionSorting:int,value:string}> $answers
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function buildQuestionResults(array $questions, array $answers, int $completedSubmissionCount): array
|
||||
{
|
||||
$answersByQuestion = [];
|
||||
|
||||
foreach ($answers as $answer) {
|
||||
$answersByQuestion[$answer['questionId']][] = $answer['value'];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($questions as $question) {
|
||||
$questionId = (int) $question->id;
|
||||
$values = $answersByQuestion[$questionId] ?? [];
|
||||
$totalAnswers = count($values);
|
||||
$responseRate = $completedSubmissionCount > 0 ? (int) round(($totalAnswers / $completedSubmissionCount) * 100) : 0;
|
||||
$key = $this->buildQuestionResultKey(
|
||||
(int) $question->id,
|
||||
(string) $question->type,
|
||||
(string) $question->question,
|
||||
(string) ($question->description ?? ''),
|
||||
);
|
||||
|
||||
$result = [
|
||||
'id' => $questionId,
|
||||
'question' => (string) $question->question,
|
||||
'description' => (string) ($question->description ?? ''),
|
||||
'type' => (string) $question->type,
|
||||
'totalAnswers' => $totalAnswers,
|
||||
'responseRate' => $responseRate,
|
||||
'chart' => null,
|
||||
];
|
||||
|
||||
switch ((string) $question->type) {
|
||||
case 'yes_no_maybe':
|
||||
$optionCounts = [
|
||||
'Ja' => 0,
|
||||
'Nein' => 0,
|
||||
];
|
||||
|
||||
if ('1' === (string) $question->allowMaybe) {
|
||||
$optionCounts['Vielleicht'] = 0;
|
||||
}
|
||||
|
||||
foreach ($values as $value) {
|
||||
$normalizedValue = mb_strtolower(trim($value));
|
||||
|
||||
if ('yes' === $normalizedValue) {
|
||||
++$optionCounts['Ja'];
|
||||
} elseif ('no' === $normalizedValue) {
|
||||
++$optionCounts['Nein'];
|
||||
} elseif ('maybe' === $normalizedValue && isset($optionCounts['Vielleicht'])) {
|
||||
++$optionCounts['Vielleicht'];
|
||||
}
|
||||
}
|
||||
|
||||
$result['options'] = $this->buildOptionStats($optionCounts);
|
||||
$result['chart'] = $this->buildPieChartConfig($result['options']);
|
||||
break;
|
||||
|
||||
case 'choice':
|
||||
$optionCounts = [];
|
||||
|
||||
for ($index = 1; $index <= 10; ++$index) {
|
||||
$option = trim((string) $question->{'answerOption'.$index});
|
||||
|
||||
if ('' !== $option) {
|
||||
$optionCounts[$option] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($values as $value) {
|
||||
foreach (array_filter(array_map('trim', explode(' | ', $value)), static fn (string $entry): bool => '' !== $entry) as $selectedOption) {
|
||||
if (!array_key_exists($selectedOption, $optionCounts)) {
|
||||
$optionCounts[$selectedOption] = 0;
|
||||
}
|
||||
|
||||
++$optionCounts[$selectedOption];
|
||||
}
|
||||
}
|
||||
|
||||
$result['options'] = $this->buildOptionStats($optionCounts);
|
||||
$result['chart'] = $this->buildPieChartConfig($result['options']);
|
||||
break;
|
||||
|
||||
case 'range':
|
||||
$numericValues = array_values(array_map(static fn (string $value): int => (int) $value, $values));
|
||||
$distribution = [];
|
||||
|
||||
foreach ($numericValues as $numericValue) {
|
||||
$label = (string) $numericValue;
|
||||
$distribution[$label] = ($distribution[$label] ?? 0) + 1;
|
||||
}
|
||||
|
||||
ksort($distribution, SORT_NATURAL);
|
||||
|
||||
$result['minimum'] = [] !== $numericValues ? min($numericValues) : null;
|
||||
$result['maximum'] = [] !== $numericValues ? max($numericValues) : null;
|
||||
$result['average'] = [] !== $numericValues ? round(array_sum($numericValues) / count($numericValues), 2) : null;
|
||||
$result['distribution'] = $this->buildOptionStats($distribution);
|
||||
$result['chart'] = $this->buildRangeChartConfig($result['distribution']);
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
default:
|
||||
$result['responses'] = array_values(array_filter($values, static fn (string $value): bool => '' !== trim($value)));
|
||||
$result['responseSummary'] = $this->buildTextResponseSummary($result['responses']);
|
||||
$result['chart'] = $this->buildPieChartConfig($result['responseSummary']);
|
||||
break;
|
||||
}
|
||||
|
||||
$results[] = $result;
|
||||
$results[$key] = $this->buildResultSkeleton(
|
||||
(int) $question->id,
|
||||
(string) $question->type,
|
||||
(string) $question->question,
|
||||
(string) ($question->description ?? ''),
|
||||
(int) ($question->sorting ?? 0),
|
||||
);
|
||||
$results[$key]['_choiceOptions'] = $this->getChoiceOptions($question);
|
||||
}
|
||||
|
||||
return $results;
|
||||
foreach ($answers as $answer) {
|
||||
$questionId = (int) ($answer['questionId'] ?? 0);
|
||||
$questionType = (string) ($answer['questionType'] ?? 'text');
|
||||
$questionLabel = trim((string) ($answer['questionLabel'] ?? '')) ?: 'Frage #'.$questionId;
|
||||
$questionDescription = (string) ($answer['questionDescription'] ?? '');
|
||||
$key = $this->buildQuestionResultKey($questionId, $questionType, $questionLabel, $questionDescription);
|
||||
|
||||
if (!isset($results[$key])) {
|
||||
$results[$key] = $this->buildResultSkeleton(
|
||||
$questionId,
|
||||
$questionType,
|
||||
$questionLabel,
|
||||
$questionDescription,
|
||||
(int) ($answer['questionSorting'] ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
$results[$key]['_values'][] = (string) ($answer['value'] ?? '');
|
||||
}
|
||||
|
||||
foreach ($results as $key => $result) {
|
||||
$results[$key] = $this->finalizeQuestionResult($result, $completedSubmissionCount);
|
||||
}
|
||||
|
||||
uasort($results, static function (array $left, array $right): int {
|
||||
$sortingComparison = ((int) ($left['_sorting'] ?? 0)) <=> ((int) ($right['_sorting'] ?? 0));
|
||||
|
||||
if (0 !== $sortingComparison) {
|
||||
return $sortingComparison;
|
||||
}
|
||||
|
||||
return strcasecmp((string) ($left['question'] ?? ''), (string) ($right['question'] ?? ''));
|
||||
});
|
||||
|
||||
return array_values($results);
|
||||
}
|
||||
|
||||
private function buildQuestionResultKey(int $questionId, string $questionType, string $questionLabel, string $questionDescription): string
|
||||
{
|
||||
return implode('|', [$questionId, $questionType, md5($questionLabel), md5($questionDescription)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildResultSkeleton(int $questionId, string $questionType, string $questionLabel, string $questionDescription, int $sorting): array
|
||||
{
|
||||
return [
|
||||
'id' => $questionId,
|
||||
'question' => $questionLabel,
|
||||
'description' => $questionDescription,
|
||||
'type' => $questionType,
|
||||
'totalAnswers' => 0,
|
||||
'responseRate' => 0,
|
||||
'chart' => null,
|
||||
'_sorting' => $sorting,
|
||||
'_values' => [],
|
||||
'_choiceOptions' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $result
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function finalizeQuestionResult(array $result, int $completedSubmissionCount): array
|
||||
{
|
||||
$values = $result['_values'] ?? [];
|
||||
$values = \is_array($values) ? array_values(array_map('strval', $values)) : [];
|
||||
$totalAnswers = count($values);
|
||||
|
||||
$result['totalAnswers'] = $totalAnswers;
|
||||
$result['responseRate'] = $completedSubmissionCount > 0 ? (int) round(($totalAnswers / $completedSubmissionCount) * 100) : 0;
|
||||
|
||||
switch ((string) ($result['type'] ?? 'text')) {
|
||||
case 'yes_no_maybe':
|
||||
$optionCounts = ['Ja' => 0, 'Nein' => 0];
|
||||
|
||||
foreach ($values as $value) {
|
||||
$normalizedValue = mb_strtolower(trim($value));
|
||||
|
||||
if ('yes' === $normalizedValue) {
|
||||
++$optionCounts['Ja'];
|
||||
} elseif ('no' === $normalizedValue) {
|
||||
++$optionCounts['Nein'];
|
||||
} elseif ('maybe' === $normalizedValue) {
|
||||
$optionCounts['Vielleicht'] = ($optionCounts['Vielleicht'] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
$result['options'] = $this->buildOptionStats($optionCounts);
|
||||
$result['chart'] = $this->buildPieChartConfig($result['options']);
|
||||
break;
|
||||
|
||||
case 'choice':
|
||||
$optionCounts = [];
|
||||
|
||||
foreach (($result['_choiceOptions'] ?? []) as $choiceOption) {
|
||||
$optionCounts[(string) $choiceOption] = 0;
|
||||
}
|
||||
|
||||
foreach ($values as $value) {
|
||||
foreach (array_filter(array_map('trim', explode(' | ', $value)), static fn (string $entry): bool => '' !== $entry) as $selectedOption) {
|
||||
$optionCounts[$selectedOption] = ($optionCounts[$selectedOption] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
$result['options'] = $this->buildOptionStats($optionCounts);
|
||||
$result['chart'] = $this->buildPieChartConfig($result['options']);
|
||||
break;
|
||||
|
||||
case 'range':
|
||||
$numericValues = array_values(array_map(static fn (string $value): int => (int) $value, $values));
|
||||
$distribution = [];
|
||||
|
||||
foreach ($numericValues as $numericValue) {
|
||||
$label = (string) $numericValue;
|
||||
$distribution[$label] = ($distribution[$label] ?? 0) + 1;
|
||||
}
|
||||
|
||||
ksort($distribution, SORT_NATURAL);
|
||||
|
||||
$result['minimum'] = [] !== $numericValues ? min($numericValues) : null;
|
||||
$result['maximum'] = [] !== $numericValues ? max($numericValues) : null;
|
||||
$result['average'] = [] !== $numericValues ? round(array_sum($numericValues) / count($numericValues), 2) : null;
|
||||
$result['distribution'] = $this->buildOptionStats($distribution);
|
||||
$result['chart'] = $this->buildRangeChartConfig($result['distribution']);
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
default:
|
||||
$result['responses'] = array_values(array_filter($values, static fn (string $value): bool => '' !== trim($value)));
|
||||
$result['responseSummary'] = $this->buildTextResponseSummary($result['responses']);
|
||||
$result['chart'] = null;
|
||||
break;
|
||||
}
|
||||
|
||||
unset($result['_sorting'], $result['_values'], $result['_choiceOptions']);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function getChoiceOptions(SurveyContentModel $question): array
|
||||
{
|
||||
$options = [];
|
||||
|
||||
for ($index = 1; $index <= 10; ++$index) {
|
||||
$option = trim((string) $question->{'answerOption'.$index});
|
||||
|
||||
if ('' !== $option) {
|
||||
$options[] = $option;
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Mummert\SurveyBundle\Service;
|
||||
|
||||
use Mummert\SurveyBundle\Model\SurveyContentModel;
|
||||
use Mummert\SurveyBundle\Form\Model\SurveyAnswerData;
|
||||
use Mummert\SurveyBundle\Model\SurveyModel;
|
||||
use Mummert\SurveyBundle\Model\SurveySubmissionModel;
|
||||
use Mummert\SurveyBundle\QuestionType\QuestionTypeRegistry;
|
||||
@@ -42,20 +43,23 @@ final class SurveySubmissionService
|
||||
$token = (string) new Ulid();
|
||||
$submission = $this->surveySubmissionRepository->create((int) $survey->id, $token, $firstQuestion?->id ? (int) $firstQuestion->id : null);
|
||||
$session->set($sessionKey, $token);
|
||||
$session->set($this->buildHistoryKey((int) $survey->id), []);
|
||||
|
||||
return $submission;
|
||||
}
|
||||
|
||||
public function storeAnswer(SurveyModel $survey, SurveySubmissionModel $submission, SurveyContentModel $question, mixed $rawAnswer): string
|
||||
{
|
||||
$questionType = $this->questionTypeRegistry->get((string) $question->type);
|
||||
$normalizedAnswer = $questionType->normalizeAnswer($rawAnswer, $question);
|
||||
$normalizedAnswer = $this->normalizeAnswerForQuestion($question, $rawAnswer);
|
||||
$surveyHadAnswers = $this->surveyAnswerRepository->hasAnswersForSurvey((int) $survey->id);
|
||||
|
||||
$this->surveyAnswerRepository->saveAnswer(
|
||||
(int) $submission->id,
|
||||
(int) $question->id,
|
||||
(string) $question->type,
|
||||
trim((string) $question->question),
|
||||
trim((string) ($question->description ?? '')),
|
||||
(int) ($question->sorting ?? 0),
|
||||
$normalizedAnswer,
|
||||
);
|
||||
|
||||
@@ -66,6 +70,73 @@ final class SurveySubmissionService
|
||||
return $normalizedAnswer;
|
||||
}
|
||||
|
||||
public function normalizeAnswerForQuestion(SurveyContentModel $question, mixed $rawAnswer): string
|
||||
{
|
||||
return $this->questionTypeRegistry->get((string) $question->type)->normalizeAnswer($rawAnswer, $question);
|
||||
}
|
||||
|
||||
public function createAnswerData(SurveyContentModel $question, mixed $storedValue = null): SurveyAnswerData
|
||||
{
|
||||
$answerData = new SurveyAnswerData();
|
||||
|
||||
if (null === $storedValue || '' === trim((string) $storedValue)) {
|
||||
return $answerData;
|
||||
}
|
||||
|
||||
$normalizedValue = (string) $storedValue;
|
||||
|
||||
$answerData->answer = match ((string) $question->type) {
|
||||
'choice' => '1' === (string) $question->allowMultiple
|
||||
? array_values(array_filter(array_map('trim', explode(' | ', $normalizedValue)), static fn (string $entry): bool => '' !== $entry))
|
||||
: $normalizedValue,
|
||||
'range' => (int) $normalizedValue,
|
||||
default => $normalizedValue,
|
||||
};
|
||||
|
||||
return $answerData;
|
||||
}
|
||||
|
||||
public function getStoredAnswerValue(SurveySubmissionModel $submission, SurveyContentModel $question): ?string
|
||||
{
|
||||
return $this->surveyAnswerRepository->findStoredAnswerValue((int) $submission->id, (int) $question->id);
|
||||
}
|
||||
|
||||
public function rememberQuestion(SurveyModel $survey, SessionInterface $session, SurveyContentModel $question): void
|
||||
{
|
||||
$history = $this->getHistory($survey, $session);
|
||||
$questionId = (int) $question->id;
|
||||
|
||||
if (($history[array_key_last($history)] ?? null) !== $questionId) {
|
||||
$history[] = $questionId;
|
||||
}
|
||||
|
||||
$session->set($this->buildHistoryKey((int) $survey->id), $history);
|
||||
}
|
||||
|
||||
public function canGoBack(SurveyModel $survey, SessionInterface $session): bool
|
||||
{
|
||||
return [] !== $this->getHistory($survey, $session);
|
||||
}
|
||||
|
||||
public function goBack(SurveyModel $survey, SurveySubmissionModel $submission, SessionInterface $session): bool
|
||||
{
|
||||
$history = $this->getHistory($survey, $session);
|
||||
|
||||
if ([] === $history) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$previousQuestionId = (int) array_pop($history);
|
||||
$this->surveySubmissionRepository->reopen((int) $submission->id, $previousQuestionId);
|
||||
$this->surveyAnswerRepository->deleteAnswersForSubmissionExceptQuestions(
|
||||
(int) $submission->id,
|
||||
array_merge($history, [$previousQuestionId]),
|
||||
);
|
||||
$session->set($this->buildHistoryKey((int) $survey->id), $history);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function advance(SurveySubmissionModel $submission, ?SurveyContentModel $nextQuestion): void
|
||||
{
|
||||
if ($nextQuestion instanceof SurveyContentModel) {
|
||||
@@ -100,6 +171,25 @@ final class SurveySubmissionService
|
||||
return 'mummert_survey_submission_'.$surveyId;
|
||||
}
|
||||
|
||||
private function buildHistoryKey(int $surveyId): string
|
||||
{
|
||||
return 'mummert_survey_history_'.$surveyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function getHistory(SurveyModel $survey, SessionInterface $session): array
|
||||
{
|
||||
$history = $session->get($this->buildHistoryKey((int) $survey->id), []);
|
||||
|
||||
if (!\is_array($history)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map('intval', $history), static fn (int $questionId): bool => $questionId > 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $answer
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user