aboutsummaryrefslogtreecommitdiffhomepage
path: root/services/app/src/Forms
diff options
context:
space:
mode:
Diffstat (limited to 'services/app/src/Forms')
-rw-r--r--services/app/src/Forms/AdminQuizEditForm.php214
-rw-r--r--services/app/src/Forms/AdminQuizNewForm.php192
-rw-r--r--services/app/src/Forms/AdminTestcaseEditForm.php122
-rw-r--r--services/app/src/Forms/AdminTestcaseNewForm.php107
-rw-r--r--services/app/src/Forms/AdminUserEditForm.php85
-rw-r--r--services/app/src/Forms/AnswerNewForm.php116
-rw-r--r--services/app/src/Forms/LoginForm.php116
7 files changed, 952 insertions, 0 deletions
diff --git a/services/app/src/Forms/AdminQuizEditForm.php b/services/app/src/Forms/AdminQuizEditForm.php
new file mode 100644
index 0000000..0fc382b
--- /dev/null
+++ b/services/app/src/Forms/AdminQuizEditForm.php
@@ -0,0 +1,214 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Nsfisis\Albatross\Forms;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use Nsfisis\Albatross\Exceptions\EntityValidationException;
+use Nsfisis\Albatross\Form\FormBase;
+use Nsfisis\Albatross\Form\FormItem;
+use Nsfisis\Albatross\Form\FormState;
+use Nsfisis\Albatross\Form\FormSubmissionFailureException;
+use Nsfisis\Albatross\Models\Quiz;
+use Nsfisis\Albatross\Repositories\QuizRepository;
+use Slim\Interfaces\RouteParserInterface;
+
+final class AdminQuizEditForm extends FormBase
+{
+ public function __construct(
+ ?FormState $state,
+ private readonly Quiz $quiz,
+ private readonly RouteParserInterface $routeParser,
+ private readonly QuizRepository $quizRepo,
+ ) {
+ if (!isset($state)) {
+ $state = new FormState([
+ 'title' => $quiz->title,
+ 'slug' => $quiz->slug,
+ 'description' => $quiz->description,
+ 'example_code' => $quiz->example_code,
+ 'birdie_code_size' => (string)$quiz->birdie_code_size,
+ 'started_at' => $quiz->started_at->setTimezone(new DateTimeZone('Asia/Tokyo'))->format('Y-m-d\TH:i'),
+ 'ranking_hidden_at' => $quiz->ranking_hidden_at->setTimezone(new DateTimeZone('Asia/Tokyo'))->format('Y-m-d\TH:i'),
+ 'finished_at' => $quiz->finished_at->setTimezone(new DateTimeZone('Asia/Tokyo'))->format('Y-m-d\TH:i'),
+ ]);
+ }
+ parent::__construct($state);
+ }
+
+ public function pageTitle(): string
+ {
+ return "管理画面 - 問題 #{$this->quiz->quiz_id} - 編集";
+ }
+
+ public function redirectUrl(): string
+ {
+ return $this->routeParser->urlFor('admin_quiz_list');
+ }
+
+ protected function submitLabel(): string
+ {
+ return '保存';
+ }
+
+ /**
+ * @return list<FormItem>
+ */
+ protected function items(): array
+ {
+ return [
+ new FormItem(
+ name: 'title',
+ type: 'text',
+ label: 'タイトル',
+ isRequired: true,
+ ),
+ new FormItem(
+ name: 'slug',
+ type: 'text',
+ label: 'スラグ',
+ isRequired: true,
+ isDisabled: true,
+ ),
+ new FormItem(
+ name: 'description',
+ type: 'textarea',
+ label: '説明',
+ isRequired: true,
+ extra: 'rows="3" cols="80"',
+ ),
+ new FormItem(
+ name: 'example_code',
+ type: 'textarea',
+ label: '実装例',
+ isRequired: true,
+ extra: 'rows="10" cols="80"',
+ ),
+ new FormItem(
+ name: 'birdie_code_size',
+ type: 'text',
+ label: 'バーディになるコードサイズ (byte)',
+ ),
+ new FormItem(
+ name: 'started_at',
+ type: 'datetime-local',
+ label: '開始日時 (JST)',
+ isRequired: true,
+ ),
+ new FormItem(
+ name: 'ranking_hidden_at',
+ type: 'datetime-local',
+ label: 'ランキングが非表示になる日時 (JST)',
+ isRequired: true,
+ ),
+ new FormItem(
+ name: 'finished_at',
+ type: 'datetime-local',
+ label: '終了日時 (JST)',
+ isRequired: true,
+ ),
+ ];
+ }
+
+ /**
+ * @return array{quiz: Quiz}
+ */
+ public function getRenderContext(): array
+ {
+ return [
+ 'quiz' => $this->quiz,
+ ];
+ }
+
+ public function submit(): void
+ {
+ $title = $this->state->get('title') ?? '';
+ $slug = $this->state->get('slug') ?? '';
+ $description = $this->state->get('description') ?? '';
+ $example_code = $this->state->get('example_code') ?? '';
+ $birdie_code_size = $this->state->get('birdie_code_size') ?? '';
+ $started_at = $this->state->get('started_at') ?? '';
+ $ranking_hidden_at = $this->state->get('ranking_hidden_at') ?? '';
+ $finished_at = $this->state->get('finished_at') ?? '';
+
+ $errors = [];
+ if ($birdie_code_size !== '' && !is_numeric($birdie_code_size)) {
+ $errors['birdie_code_size'] = '数値を入力してください';
+ $birdie_code_size = ''; // dummy
+ }
+ if ($started_at === '') {
+ $errors['started_at'] = '開始日時は必須です';
+ } else {
+ $started_at = DateTimeImmutable::createFromFormat(
+ 'Y-m-d\TH:i',
+ $started_at,
+ new DateTimeZone('Asia/Tokyo'),
+ );
+ if ($started_at === false) {
+ $errors['started_at'] = '開始日時の形式が不正です';
+ } else {
+ $started_at = $started_at->setTimezone(new DateTimeZone('UTC'));
+ }
+ }
+ if (!$started_at instanceof DateTimeImmutable) {
+ $started_at = new DateTimeImmutable('now', new DateTimeZone('UTC')); // dummy
+ }
+ if ($ranking_hidden_at === '') {
+ $errors['ranking_hidden_at'] = 'ランキングが非表示になる日時は必須です';
+ } else {
+ $ranking_hidden_at = DateTimeImmutable::createFromFormat(
+ 'Y-m-d\TH:i',
+ $ranking_hidden_at,
+ new DateTimeZone('Asia/Tokyo'),
+ );
+ if ($ranking_hidden_at === false) {
+ $errors['ranking_hidden_at'] = 'ランキングが非表示になる日時の形式が不正です';
+ } else {
+ $ranking_hidden_at = $ranking_hidden_at->setTimezone(new DateTimeZone('UTC'));
+ }
+ }
+ if (!$ranking_hidden_at instanceof DateTimeImmutable) {
+ $ranking_hidden_at = new DateTimeImmutable('now', new DateTimeZone('UTC')); // dummy
+ }
+ if ($finished_at === '') {
+ $errors['finished_at'] = '終了日時は必須です';
+ } else {
+ $finished_at = DateTimeImmutable::createFromFormat(
+ 'Y-m-d\TH:i',
+ $finished_at,
+ new DateTimeZone('Asia/Tokyo'),
+ );
+ if ($finished_at === false) {
+ $errors['finished_at'] = '終了日時の形式が不正です';
+ } else {
+ $finished_at = $finished_at->setTimezone(new DateTimeZone('UTC'));
+ }
+ }
+ if (!$finished_at instanceof DateTimeImmutable) {
+ $finished_at = new DateTimeImmutable('now', new DateTimeZone('UTC')); // dummy
+ }
+
+ if (0 < count($errors)) {
+ $this->state->setErrors($errors);
+ throw new FormSubmissionFailureException();
+ }
+
+ try {
+ $this->quizRepo->update(
+ $this->quiz->quiz_id,
+ $title,
+ $description,
+ $example_code,
+ $birdie_code_size === '' ? null : (int)$birdie_code_size,
+ $started_at,
+ $ranking_hidden_at,
+ $finished_at,
+ );
+ } catch (EntityValidationException $e) {
+ $this->state->setErrors($e->toFormErrors());
+ throw new FormSubmissionFailureException(previous: $e);
+ }
+ }
+}
diff --git a/services/app/src/Forms/AdminQuizNewForm.php b/services/app/src/Forms/AdminQuizNewForm.php
new file mode 100644
index 0000000..0038dc1
--- /dev/null
+++ b/services/app/src/Forms/AdminQuizNewForm.php
@@ -0,0 +1,192 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Nsfisis\Albatross\Forms;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use Nsfisis\Albatross\Exceptions\EntityValidationException;
+use Nsfisis\Albatross\Form\FormBase;
+use Nsfisis\Albatross\Form\FormItem;
+use Nsfisis\Albatross\Form\FormState;
+use Nsfisis\Albatross\Form\FormSubmissionFailureException;
+use Nsfisis\Albatross\Repositories\QuizRepository;
+use Slim\Interfaces\RouteParserInterface;
+
+final class AdminQuizNewForm extends FormBase
+{
+ public function __construct(
+ ?FormState $state,
+ private readonly RouteParserInterface $routeParser,
+ private readonly QuizRepository $quizRepo,
+ ) {
+ if (!isset($state)) {
+ $state = new FormState();
+ }
+ parent::__construct($state);
+ }
+
+ public function pageTitle(): string
+ {
+ return '管理画面 - 問題作成';
+ }
+
+ public function redirectUrl(): string
+ {
+ return $this->routeParser->urlFor('admin_quiz_list');
+ }
+
+ protected function submitLabel(): string
+ {
+ return '作成';
+ }
+
+ /**
+ * @return list<FormItem>
+ */
+ protected function items(): array
+ {
+ return [
+ new FormItem(
+ name: 'title',
+ type: 'text',
+ label: 'タイトル',
+ isRequired: true,
+ ),
+ new FormItem(
+ name: 'slug',
+ type: 'text',
+ label: 'スラグ',
+ isRequired: true,
+ ),
+ new FormItem(
+ name: 'description',
+ type: 'textarea',
+ label: '説明',
+ isRequired: true,
+ extra: 'rows="3" cols="80"',
+ ),
+ new FormItem(
+ name: 'example_code',
+ type: 'textarea',
+ label: '実装例',
+ isRequired: true,
+ extra: 'rows="10" cols="80"',
+ ),
+ new FormItem(
+ name: 'birdie_code_size',
+ type: 'text',
+ label: 'バーディになるコードサイズ (byte)',
+ ),
+ new FormItem(
+ name: 'started_at',
+ type: 'datetime-local',
+ label: '開始日時 (JST)',
+ isRequired: true,
+ ),
+ new FormItem(
+ name: 'ranking_hidden_at',
+ type: 'datetime-local',
+ label: 'ランキングが非表示になる日時 (JST)',
+ isRequired: true,
+ ),
+ new FormItem(
+ name: 'finished_at',
+ type: 'datetime-local',
+ label: '終了日時 (JST)',
+ isRequired: true,
+ ),
+ ];
+ }
+
+ public function submit(): void
+ {
+ $title = $this->state->get('title') ?? '';
+ $slug = $this->state->get('slug') ?? '';
+ $description = $this->state->get('description') ?? '';
+ $example_code = $this->state->get('example_code') ?? '';
+ $birdie_code_size = $this->state->get('birdie_code_size') ?? '';
+ $started_at = $this->state->get('started_at') ?? '';
+ $ranking_hidden_at = $this->state->get('ranking_hidden_at') ?? '';
+ $finished_at = $this->state->get('finished_at') ?? '';
+
+ $errors = [];
+ if ($birdie_code_size !== '' && !is_numeric($birdie_code_size)) {
+ $errors['birdie_code_size'] = '数値を入力してください';
+ $birdie_code_size = ''; // dummy
+ }
+ if ($started_at === '') {
+ $errors['started_at'] = '開始日時は必須です';
+ } else {
+ $started_at = DateTimeImmutable::createFromFormat(
+ 'Y-m-d\TH:i',
+ $started_at,
+ new DateTimeZone('Asia/Tokyo'),
+ );
+ if ($started_at === false) {
+ $errors['started_at'] = '開始日時の形式が不正です';
+ } else {
+ $started_at = $started_at->setTimezone(new DateTimeZone('UTC'));
+ }
+ }
+ if (!$started_at instanceof DateTimeImmutable) {
+ $started_at = new DateTimeImmutable('now', new DateTimeZone('UTC')); // dummy
+ }
+ if ($ranking_hidden_at === '') {
+ $errors['ranking_hidden_at'] = 'ランキングが非表示になる日時は必須です';
+ } else {
+ $ranking_hidden_at = DateTimeImmutable::createFromFormat(
+ 'Y-m-d\TH:i',
+ $ranking_hidden_at,
+ new DateTimeZone('Asia/Tokyo'),
+ );
+ if ($ranking_hidden_at === false) {
+ $errors['ranking_hidden_at'] = 'ランキングが非表示になる日時の形式が不正です';
+ } else {
+ $ranking_hidden_at = $ranking_hidden_at->setTimezone(new DateTimeZone('UTC'));
+ }
+ }
+ if (!$ranking_hidden_at instanceof DateTimeImmutable) {
+ $ranking_hidden_at = new DateTimeImmutable('now', new DateTimeZone('UTC')); // dummy
+ }
+ if ($finished_at === '') {
+ $errors['finished_at'] = '終了日時は必須です';
+ } else {
+ $finished_at = DateTimeImmutable::createFromFormat(
+ 'Y-m-d\TH:i',
+ $finished_at,
+ new DateTimeZone('Asia/Tokyo'),
+ );
+ if ($finished_at === false) {
+ $errors['finished_at'] = '終了日時の形式が不正です';
+ } else {
+ $finished_at = $finished_at->setTimezone(new DateTimeZone('UTC'));
+ }
+ }
+ if (!$finished_at instanceof DateTimeImmutable) {
+ $finished_at = new DateTimeImmutable('now', new DateTimeZone('UTC')); // dummy
+ }
+
+ if (0 < count($errors)) {
+ $this->state->setErrors($errors);
+ throw new FormSubmissionFailureException();
+ }
+
+ try {
+ $this->quizRepo->create(
+ $title,
+ $slug,
+ $description,
+ $example_code,
+ $birdie_code_size === '' ? null : (int)$birdie_code_size,
+ $started_at,
+ $ranking_hidden_at,
+ $finished_at,
+ );
+ } catch (EntityValidationException $e) {
+ $this->state->setErrors($e->toFormErrors());
+ throw new FormSubmissionFailureException(previous: $e);
+ }
+ }
+}
diff --git a/services/app/src/Forms/AdminTestcaseEditForm.php b/services/app/src/Forms/AdminTestcaseEditForm.php
new file mode 100644
index 0000000..aa87c8a
--- /dev/null
+++ b/services/app/src/Forms/AdminTestcaseEditForm.php
@@ -0,0 +1,122 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Nsfisis\Albatross\Forms;
+
+use Nsfisis\Albatross\Database\Connection;
+use Nsfisis\Albatross\Exceptions\EntityValidationException;
+use Nsfisis\Albatross\Form\FormBase;
+use Nsfisis\Albatross\Form\FormItem;
+use Nsfisis\Albatross\Form\FormState;
+use Nsfisis\Albatross\Form\FormSubmissionFailureException;
+use Nsfisis\Albatross\Models\Quiz;
+use Nsfisis\Albatross\Models\Testcase;
+use Nsfisis\Albatross\Repositories\AnswerRepository;
+use Nsfisis\Albatross\Repositories\TestcaseExecutionRepository;
+use Nsfisis\Albatross\Repositories\TestcaseRepository;
+use Slim\Interfaces\RouteParserInterface;
+
+final class AdminTestcaseEditForm extends FormBase
+{
+ public function __construct(
+ ?FormState $state,
+ private readonly Testcase $testcase,
+ private readonly Quiz $quiz,
+ private readonly RouteParserInterface $routeParser,
+ private readonly AnswerRepository $answerRepo,
+ private readonly TestcaseRepository $testcaseRepo,
+ private readonly TestcaseExecutionRepository $testcaseExecutionRepo,
+ private readonly Connection $conn,
+ ) {
+ if (!isset($state)) {
+ $state = new FormState([
+ 'input' => $testcase->input,
+ 'expected_result' => $testcase->expected_result,
+ ]);
+ }
+ parent::__construct($state);
+ }
+
+ public function pageTitle(): string
+ {
+ return "管理画面 - 問題 #{$this->quiz->quiz_id} - テストケース #{$this->testcase->testcase_id} - 編集";
+ }
+
+ public function redirectUrl(): string
+ {
+ return $this->routeParser->urlFor('admin_testcase_list', ['qslug' => $this->quiz->slug]);
+ }
+
+ protected function submitLabel(): string
+ {
+ return '保存';
+ }
+
+ /**
+ * @return list<FormItem>
+ */
+ protected function items(): array
+ {
+ return [
+ new FormItem(
+ name: 'input',
+ type: 'textarea',
+ label: '標準入力',
+ extra: 'rows="10" cols="80"',
+ ),
+ new FormItem(
+ name: 'expected_result',
+ type: 'textarea',
+ label: '期待する出力',
+ isRequired: true,
+ extra: 'rows="10" cols="80"',
+ ),
+ ];
+ }
+
+ /**
+ * @return array{testcase: Testcase, quiz: Quiz}
+ */
+ public function getRenderContext(): array
+ {
+ return [
+ 'testcase' => $this->testcase,
+ 'quiz' => $this->quiz,
+ ];
+ }
+
+ public function submit(): void
+ {
+ $input = $this->state->get('input') ?? '';
+ $expected_result = $this->state->get('expected_result') ?? '';
+
+ $errors = [];
+ if ($expected_result === '') {
+ $errors['expected_result'] = '期待する出力は必須です';
+ }
+ if (0 < count($errors)) {
+ $this->state->setErrors($errors);
+ throw new FormSubmissionFailureException();
+ }
+
+ try {
+ $this->conn->transaction(function () use ($input, $expected_result): void {
+ $quiz_id = $this->quiz->quiz_id;
+
+ $this->testcaseRepo->update(
+ testcase_id: $this->testcase->testcase_id,
+ input: $input,
+ expected_result: $expected_result,
+ );
+ $this->answerRepo->markAllAsPending($quiz_id);
+ $this->testcaseExecutionRepo->markAllAsPendingByTestcaseId(
+ testcase_id: $this->testcase->testcase_id,
+ );
+ });
+ } catch (EntityValidationException $e) {
+ $this->state->setErrors($e->toFormErrors());
+ throw new FormSubmissionFailureException(previous: $e);
+ }
+ }
+}
diff --git a/services/app/src/Forms/AdminTestcaseNewForm.php b/services/app/src/Forms/AdminTestcaseNewForm.php
new file mode 100644
index 0000000..e925f8c
--- /dev/null
+++ b/services/app/src/Forms/AdminTestcaseNewForm.php
@@ -0,0 +1,107 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Nsfisis\Albatross\Forms;
+
+use Nsfisis\Albatross\Database\Connection;
+use Nsfisis\Albatross\Exceptions\EntityValidationException;
+use Nsfisis\Albatross\Form\FormBase;
+use Nsfisis\Albatross\Form\FormItem;
+use Nsfisis\Albatross\Form\FormState;
+use Nsfisis\Albatross\Form\FormSubmissionFailureException;
+use Nsfisis\Albatross\Models\Quiz;
+use Nsfisis\Albatross\Repositories\AnswerRepository;
+use Nsfisis\Albatross\Repositories\TestcaseExecutionRepository;
+use Nsfisis\Albatross\Repositories\TestcaseRepository;
+use Slim\Interfaces\RouteParserInterface;
+
+final class AdminTestcaseNewForm extends FormBase
+{
+ public function __construct(
+ ?FormState $state,
+ private readonly Quiz $quiz,
+ private readonly RouteParserInterface $routeParser,
+ private readonly AnswerRepository $answerRepo,
+ private readonly TestcaseRepository $testcaseRepo,
+ private readonly TestcaseExecutionRepository $testcaseExecutionRepo,
+ private readonly Connection $conn,
+ ) {
+ if (!isset($state)) {
+ $state = new FormState();
+ }
+ parent::__construct($state);
+ }
+
+ public function pageTitle(): string
+ {
+ return "管理画面 - 問題 #{$this->quiz->quiz_id} - テストケース作成";
+ }
+
+ public function redirectUrl(): string
+ {
+ return $this->routeParser->urlFor('admin_testcase_list', ['qslug' => $this->quiz->slug]);
+ }
+
+ protected function submitLabel(): string
+ {
+ return '作成';
+ }
+
+ /**
+ * @return list<FormItem>
+ */
+ protected function items(): array
+ {
+ return [
+ new FormItem(
+ name: 'input',
+ type: 'textarea',
+ label: '標準入力',
+ extra: 'rows="10" cols="80"',
+ ),
+ new FormItem(
+ name: 'expected_result',
+ type: 'textarea',
+ label: '期待する出力',
+ isRequired: true,
+ extra: 'rows="10" cols="80"',
+ ),
+ ];
+ }
+
+ public function submit(): void
+ {
+ $input = $this->state->get('input') ?? '';
+ $expected_result = $this->state->get('expected_result') ?? '';
+
+ $errors = [];
+ if ($expected_result === '') {
+ $errors['expected_result'] = '期待する出力は必須です';
+ }
+ if (0 < count($errors)) {
+ $this->state->setErrors($errors);
+ throw new FormSubmissionFailureException();
+ }
+
+ try {
+ $this->conn->transaction(function () use ($input, $expected_result): void {
+ $quiz_id = $this->quiz->quiz_id;
+
+ $testcase_id = $this->testcaseRepo->create(
+ quiz_id: $quiz_id,
+ input: $input,
+ expected_result: $expected_result,
+ );
+ $this->answerRepo->markAllAsPending($quiz_id);
+ $this->testcaseExecutionRepo->enqueueForAllAnswers(
+ quiz_id: $quiz_id,
+ testcase_id: $testcase_id,
+ );
+ });
+ } catch (EntityValidationException $e) {
+ $this->state->setErrors($e->toFormErrors());
+ throw new FormSubmissionFailureException(previous: $e);
+ }
+ }
+}
diff --git a/services/app/src/Forms/AdminUserEditForm.php b/services/app/src/Forms/AdminUserEditForm.php
new file mode 100644
index 0000000..1a17f6c
--- /dev/null
+++ b/services/app/src/Forms/AdminUserEditForm.php
@@ -0,0 +1,85 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Nsfisis\Albatross\Forms;
+
+use Nsfisis\Albatross\Form\FormBase;
+use Nsfisis\Albatross\Form\FormItem;
+use Nsfisis\Albatross\Form\FormState;
+use Nsfisis\Albatross\Models\User;
+use Nsfisis\Albatross\Repositories\UserRepository;
+use Slim\Interfaces\RouteParserInterface;
+
+final class AdminUserEditForm extends FormBase
+{
+ public function __construct(
+ ?FormState $state,
+ private readonly User $user,
+ private readonly RouteParserInterface $routeParser,
+ private readonly UserRepository $userRepo,
+ ) {
+ if (!isset($state)) {
+ $state = new FormState([
+ 'username' => $user->username,
+ 'is_admin' => $user->is_admin ? 'on' : '',
+ ]);
+ }
+ parent::__construct($state);
+ }
+
+ public function pageTitle(): string
+ {
+ return "管理画面 - ユーザ {$this->user->username} 編集";
+ }
+
+ public function redirectUrl(): string
+ {
+ return $this->routeParser->urlFor('admin_user_list');
+ }
+
+ protected function submitLabel(): string
+ {
+ return '保存';
+ }
+
+ /**
+ * @return list<FormItem>
+ */
+ protected function items(): array
+ {
+ return [
+ new FormItem(
+ name: 'username',
+ type: 'text',
+ label: 'ユーザ名',
+ isDisabled: true,
+ ),
+ new FormItem(
+ name: 'is_admin',
+ type: 'checkbox',
+ label: '管理者',
+ ),
+ ];
+ }
+
+ /**
+ * @return array{user: User}
+ */
+ public function getRenderContext(): array
+ {
+ return [
+ 'user' => $this->user,
+ ];
+ }
+
+ public function submit(): void
+ {
+ $is_admin = $this->state->get('is_admin') === 'on';
+
+ $this->userRepo->update(
+ $this->user->user_id,
+ is_admin: $is_admin,
+ );
+ }
+}
diff --git a/services/app/src/Forms/AnswerNewForm.php b/services/app/src/Forms/AnswerNewForm.php
new file mode 100644
index 0000000..a07a172
--- /dev/null
+++ b/services/app/src/Forms/AnswerNewForm.php
@@ -0,0 +1,116 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Nsfisis\Albatross\Forms;
+
+use Nsfisis\Albatross\Database\Connection;
+use Nsfisis\Albatross\Exceptions\EntityValidationException;
+use Nsfisis\Albatross\Form\FormBase;
+use Nsfisis\Albatross\Form\FormItem;
+use Nsfisis\Albatross\Form\FormState;
+use Nsfisis\Albatross\Form\FormSubmissionFailureException;
+use Nsfisis\Albatross\Models\Answer;
+use Nsfisis\Albatross\Models\Quiz;
+use Nsfisis\Albatross\Models\User;
+use Nsfisis\Albatross\Repositories\AnswerRepository;
+use Nsfisis\Albatross\Repositories\TestcaseExecutionRepository;
+use Slim\Interfaces\RouteParserInterface;
+
+final class AnswerNewForm extends FormBase
+{
+ private ?Answer $answer = null;
+
+ public function __construct(
+ ?FormState $state,
+ private readonly User $currentUser,
+ private readonly Quiz $quiz,
+ private readonly RouteParserInterface $routeParser,
+ private readonly AnswerRepository $answerRepo,
+ private readonly TestcaseExecutionRepository $testcaseExecutionRepo,
+ private readonly Connection $conn,
+ ) {
+ if (!isset($state)) {
+ $state = new FormState();
+ }
+ parent::__construct($state);
+ }
+
+ public function pageTitle(): string
+ {
+ return "問題 #{$this->quiz->quiz_id} - 提出";
+ }
+
+ public function redirectUrl(): string
+ {
+ $answer = $this->answer;
+ assert(isset($answer));
+ return $this->routeParser->urlFor(
+ 'answer_view',
+ ['qslug' => $this->quiz->slug, 'anum' => "$answer->answer_number"],
+ );
+ }
+
+ protected function submitLabel(): string
+ {
+ return '投稿';
+ }
+
+ /**
+ * @return list<FormItem>
+ */
+ protected function items(): array
+ {
+ return [
+ new FormItem(
+ name: 'code',
+ type: 'textarea',
+ label: 'コード',
+ isRequired: true,
+ extra: 'rows="3" cols="80"',
+ ),
+ ];
+ }
+
+ /**
+ * @return array{quiz: Quiz, is_closed: bool}
+ */
+ public function getRenderContext(): array
+ {
+ return [
+ 'quiz' => $this->quiz,
+ 'is_closed' => $this->quiz->isClosedToAnswer(),
+ ];
+ }
+
+ public function submit(): void
+ {
+ if ($this->quiz->isClosedToAnswer()) {
+ $this->state->setErrors(['general' => 'この問題の回答は締め切られました']);
+ throw new FormSubmissionFailureException();
+ }
+
+ $code = $this->state->get('code') ?? '';
+
+ try {
+ $answer_id = $this->conn->transaction(function () use ($code) {
+ $answer_id = $this->answerRepo->create(
+ $this->quiz->quiz_id,
+ $this->currentUser->user_id,
+ $code,
+ );
+ $this->testcaseExecutionRepo->enqueueForSingleAnswer(
+ $answer_id,
+ $this->quiz->quiz_id,
+ );
+ return $answer_id;
+ });
+ } catch (EntityValidationException $e) {
+ $this->state->setErrors($e->toFormErrors());
+ throw new FormSubmissionFailureException(previous: $e);
+ }
+ $answer = $this->answerRepo->findById($answer_id);
+ assert(isset($answer));
+ $this->answer = $answer;
+ }
+}
diff --git a/services/app/src/Forms/LoginForm.php b/services/app/src/Forms/LoginForm.php
new file mode 100644
index 0000000..26f6ad1
--- /dev/null
+++ b/services/app/src/Forms/LoginForm.php
@@ -0,0 +1,116 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Nsfisis\Albatross\Forms;
+
+use Nsfisis\Albatross\Auth\AuthenticationResult;
+use Nsfisis\Albatross\Auth\AuthProviderInterface;
+use Nsfisis\Albatross\Exceptions\EntityValidationException;
+use Nsfisis\Albatross\Form\FormBase;
+use Nsfisis\Albatross\Form\FormItem;
+use Nsfisis\Albatross\Form\FormState;
+use Nsfisis\Albatross\Form\FormSubmissionFailureException;
+use Nsfisis\Albatross\Repositories\UserRepository;
+use Slim\Interfaces\RouteParserInterface;
+
+final class LoginForm extends FormBase
+{
+ public function __construct(
+ ?FormState $state,
+ private readonly ?string $destination,
+ private readonly RouteParserInterface $routeParser,
+ private readonly UserRepository $userRepo,
+ private readonly AuthProviderInterface $authProvider,
+ ) {
+ if (!isset($state)) {
+ $state = new FormState();
+ }
+ parent::__construct($state);
+ }
+
+ public function pageTitle(): string
+ {
+ return 'ログイン';
+ }
+
+ public function redirectUrl(): string
+ {
+ return $this->destination ?? $this->routeParser->urlFor('quiz_list');
+ }
+
+ protected function submitLabel(): string
+ {
+ return 'ログイン';
+ }
+
+ /**
+ * @return list<FormItem>
+ */
+ protected function items(): array
+ {
+ return [
+ new FormItem(
+ name: 'username',
+ type: 'text',
+ label: 'ユーザ名',
+ isRequired: true,
+ ),
+ new FormItem(
+ name: 'password',
+ type: 'password',
+ label: 'パスワード',
+ isRequired: true,
+ ),
+ ];
+ }
+
+ public function submit(): void
+ {
+ $username = $this->state->get('username') ?? '';
+ $password = $this->state->get('password') ?? '';
+
+ $this->validate($username, $password);
+
+ $authResult = $this->authProvider->login($username, $password);
+ if ($authResult === AuthenticationResult::InvalidCredentials) {
+ $this->state->setErrors(['general' => 'ユーザ名またはパスワードが異なります']);
+ throw new FormSubmissionFailureException(code: 403);
+ } elseif ($authResult === AuthenticationResult::InvalidJson || $authResult === AuthenticationResult::UnknownError) {
+ throw new FormSubmissionFailureException(code: 500);
+ } else {
+ $user = $this->userRepo->findByUsername($username);
+ if ($user === null) {
+ try {
+ $user_id = $this->userRepo->create(
+ $username,
+ is_admin: true, // TODO
+ );
+ } catch (EntityValidationException $e) {
+ $this->state->setErrors($e->toFormErrors());
+ throw new FormSubmissionFailureException(previous: $e);
+ }
+ $_SESSION['user_id'] = $user_id;
+ } else {
+ $_SESSION['user_id'] = $user->user_id;
+ }
+ }
+ }
+
+ private function validate(string $username, string $password): void
+ {
+ $errors = [];
+ if (strlen($username) < 1) {
+ $errors['username'] = 'ユーザ名は必須です';
+ }
+
+ if (strlen($password) < 1) {
+ $errors['password'] = 'パスワードは必須です';
+ }
+
+ if (count($errors) > 0) {
+ $this->state->setErrors($errors);
+ throw new FormSubmissionFailureException(code: 400);
+ }
+ }
+}