- {% if appfeatures.comment %}
+
+ {% if app_features_comment %}
diff --git a/templates/components/Challenge/Header.html.twig b/templates/components/Challenge/Header.html.twig
index 51ddf6a..0204de5 100644
--- a/templates/components/Challenge/Header.html.twig
+++ b/templates/components/Challenge/Header.html.twig
@@ -27,7 +27,7 @@
@@ -36,7 +36,7 @@
diff --git a/templates/components/Challenge/SolutionVideoModal.html.twig b/templates/components/Challenge/SolutionVideoModal.html.twig
index b54c2ef..c59c097 100644
--- a/templates/components/Challenge/SolutionVideoModal.html.twig
+++ b/templates/components/Challenge/SolutionVideoModal.html.twig
@@ -4,7 +4,7 @@
'aria-label': '打開解答影片',
'aria-hidden': 'true',
'data-video-url': path('app_challenge_solution_video', {
- id: this.question.id,
+ question: this.question.id,
csrf: csrf_token('challenge-solution'),
}),
}|merge(stimulus_controller('challenge-solution-video-modal'))) }}>
diff --git a/templates/components/Challenge/Ui.html.twig b/templates/components/Challenge/Ui.html.twig
index 39ade25..de3d888 100644
--- a/templates/components/Challenge/Ui.html.twig
+++ b/templates/components/Challenge/Ui.html.twig
@@ -3,7 +3,7 @@
{% if question.solutionVideo %}
{% endif %}
- {% if appfeatures.hint %}
+ {% if app_features_hint %}
{% endif %}
@@ -15,7 +15,7 @@
- {% if appfeatures.hint %}
+ {% if app_features_hint %}
{% endif %}
{% if question.solutionVideo %}
@@ -35,7 +35,7 @@
- {% if appfeatures.comment %}
+ {% if app_features_comment %}
-
進行測驗
+
進行測驗
{% set passRate = this.passRate %}
通過率 {{ passRate.passRate }}%
diff --git a/templates/email/mjml/_partials/footer.mjml.twig b/templates/email/mjml/_partials/footer.mjml.twig
new file mode 100644
index 0000000..e80e816
--- /dev/null
+++ b/templates/email/mjml/_partials/footer.mjml.twig
@@ -0,0 +1,9 @@
+
+
+
+ 你會收到這封郵件,是因為你是資料庫練功房的練習學生。
+ 回報信件問題
+
+
+
diff --git a/templates/email/mjml/_partials/header-situation.mjml.twig b/templates/email/mjml/_partials/header-situation.mjml.twig
new file mode 100644
index 0000000..55d47f3
--- /dev/null
+++ b/templates/email/mjml/_partials/header-situation.mjml.twig
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/templates/email/mjml/remember-to-login.mjml.twig b/templates/email/mjml/remember-to-login.mjml.twig
new file mode 100644
index 0000000..4b52781
--- /dev/null
+++ b/templates/email/mjml/remember-to-login.mjml.twig
@@ -0,0 +1,43 @@
+{% block email_content %}
+ {% mjml %}
+
+
+
+ ⚠️ 我注意到這週你沒有登入,記得持續學習和練習對進步非常重要!提醒你一下,如果這週做題數量未達 5
+ 題,每少做一題將會扣 4 分,希望你能儘快投入學習,保持進度,這樣才能持續提升自己的 SQL 能力。加油!
+
+
+
+
+ {{ include('email/mjml/_partials/header-situation.mjml.twig') }}
+
+
+
+
+
+
+
+
+
+ ⚠️ 我注意到這週你沒有登入,記得持續學習和練習對進步非常重要!提醒你一下,如果這週做題數量未達
+ 5
+ 題,每少做一題將會扣 4 分,希望你能儘快投入學習,保持進度,這樣才能持續提升自己的 SQL 能力。加油!
+
+
+ 立即登入
+
+
+
+
+
+
+
+
+ {{ include('email/mjml/_partials/footer.mjml.twig') }}
+
+
+
+ {% endmjml %}
+{% endblock %}
diff --git a/templates/email/preview.html.twig b/templates/email/preview.html.twig
new file mode 100644
index 0000000..d3d0246
--- /dev/null
+++ b/templates/email/preview.html.twig
@@ -0,0 +1,77 @@
+{% extends 'app.html.twig' %}
+
+{% block nav %}
+
{% endblock %}
+{% block title %}信件預覽{% endblock %}
+
+{% block app %}
+ {% set textContent = emailDeliveryEvent.email.textContent %}
+ {% set htmlContent = emailDeliveryEvent.email.htmlContent %}
+ {% set hasText, hasHtml = textContent|length > 0, htmlContent|length > 0 %}
+
+
+
+
+
+
+ {{ emailDeliveryEvent.email.subject }}
+
+
+
+
+
+ {% if hasHtml %}
+
+
+
+ {% endif %}
+ {% if hasText %}
+
+
{{ emailDeliveryEvent.email.textContent }}
+
+ {% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/tests/Entity/EmailDtoTest.php b/tests/Entity/EmailDtoTest.php
new file mode 100644
index 0000000..30d73ff
--- /dev/null
+++ b/tests/Entity/EmailDtoTest.php
@@ -0,0 +1,57 @@
+setSubject('Test subject')
+ ->setToAddress(new Address('test@dbplay.pan93.com'))
+ ->setKind(EmailKind::Test)
+ ->setText('Test text')
+ ->setHtml('
Test text
');
+
+ $email = $emailDto->toEmail();
+
+ self::assertEquals('Test subject', $email->getSubject());
+ self::assertEquals('test@dbplay.pan93.com', $email->getTo()[0]->getAddress());
+ self::assertEquals('Test text', $email->getTextBody());
+ self::assertEquals('
Test text
', $email->getHtmlBody());
+
+ $extractedKind = EmailKind::fromEmailHeader($email->getHeaders());
+ self::assertEquals(EmailKind::Test, $extractedKind);
+ }
+
+ public function testEmailDtoToUser(): void
+ {
+ $user = (new User())
+ ->setName('Test name')
+ ->setEmail('test@dbplay.pan93.com');
+
+ $emailDto = EmailDto::fromUser($user)
+ ->setSubject('Test subject')
+ ->setKind(EmailKind::Test)
+ ->setText('Test text')
+ ->setHtml('
Test text
');
+
+ $email = $emailDto->toEmail();
+
+ self::assertEquals('Test subject', $email->getSubject());
+ self::assertEquals('"Test name"
', $email->getTo()[0]->toString());
+ self::assertEquals('Test text', $email->getTextBody());
+ self::assertEquals('Test text
', $email->getHtmlBody());
+
+ $extractedKind = EmailKind::fromEmailHeader($email->getHeaders());
+ self::assertEquals(EmailKind::Test, $extractedKind);
+ }
+}
diff --git a/tests/Entity/EmailKindTest.php b/tests/Entity/EmailKindTest.php
new file mode 100644
index 0000000..a993b45
--- /dev/null
+++ b/tests/Entity/EmailKindTest.php
@@ -0,0 +1,54 @@
+addToEmailHeader($header);
+
+ self::assertEquals($kind->value, $header->get(EmailKind::EMAIL_HEADER)?->getBodyAsString());
+ }
+
+ public function testEmailKindExtractHeader(): void
+ {
+ $kind = EmailKind::Transactional;
+ $header = new Headers();
+ $header->addTextHeader(EmailKind::EMAIL_HEADER, $kind->value);
+
+ $extractedKind = EmailKind::fromEmailHeader($header);
+
+ self::assertEquals($kind, $extractedKind);
+ }
+
+ public function testEmailKindNoHeader(): void
+ {
+ $header = new Headers();
+
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('The email kind header is missing or is invalid type.');
+
+ EmailKind::fromEmailHeader($header);
+ }
+
+ public function testEmailKindInvalidHeader(): void
+ {
+ $header = new Headers();
+ $header->addTextHeader(EmailKind::EMAIL_HEADER, 'invalid###');
+
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Invalid email kind: invalid###');
+
+ EmailKind::fromEmailHeader($header);
+ }
+}
diff --git a/tests/Entity/LevelTest.php b/tests/Entity/LevelTest.php
new file mode 100644
index 0000000..6bf06f0
--- /dev/null
+++ b/tests/Entity/LevelTest.php
@@ -0,0 +1,38 @@
+
+ */
+ public static function fromPercentDataProvider(): iterable
+ {
+ yield [0, Level::Starter];
+ yield [4.9, Level::Starter];
+ yield [5, Level::Beginner];
+ yield [19.9, Level::Beginner];
+ yield [20, Level::Intermediate];
+ yield [39.9, Level::Intermediate];
+ yield [40, Level::Advanced];
+ yield [64.9, Level::Advanced];
+ yield [65, Level::Expert];
+ yield [89.9, Level::Expert];
+ yield [90, Level::Master];
+ yield [100, Level::Master];
+ }
+}
diff --git a/tests/EventListener/EmailCreatedSubscriberTest.php b/tests/EventListener/EmailCreatedSubscriberTest.php
new file mode 100644
index 0000000..45f8bac
--- /dev/null
+++ b/tests/EventListener/EmailCreatedSubscriberTest.php
@@ -0,0 +1,86 @@
+subject('subject')
+ ->text('body')
+ ->html('body
from('demo-dbplay@example.com')
+ ->to('test@example.com');
+
+ $headers = $message->getHeaders();
+ $headers = EmailKind::Test->addToEmailHeader($headers);
+ $message->setHeaders($headers);
+
+ $envelope = Envelope::create($message);
+
+ $userRepository = self::createMock(UserRepository::class);
+ $userRepository
+ ->expects(self::once())
+ ->method('findOneBy')
+ ->with(['email' => 'test@example.com'])
+ ->willReturn(new UserEntity());
+
+ $invokedCount = self::exactly(2);
+ /**
+ * @var Email|null $emailInstance
+ */
+ $emailInstance = null;
+ $entityManager = self::createMock(EntityManagerInterface::class);
+ $entityManager
+ ->expects(self::exactly(2))
+ ->method('persist')
+ ->willReturnCallback(function (mixed ...$parameters) use ($invokedCount, &$emailInstance): void {
+ switch ($invokedCount->numberOfInvocations()) {
+ case 1:
+ $email = $parameters[0];
+ \assert($email instanceof EmailEntity);
+
+ self::assertEquals('subject', $email->getSubject());
+ self::assertEquals('body', $email->getTextContent());
+ self::assertEquals('body
', $email->getHtmlContent());
+ self::assertEquals(EmailKind::Test, $email->getKind());
+
+ $emailInstance = $email;
+ break;
+ case 2:
+ $event = $parameters[0];
+ \assert($event instanceof EmailDeliveryEventEntity);
+
+ self::assertEquals('test@example.com', $event->getToAddress());
+ self::assertEquals($emailInstance, $event->getEmail());
+ break;
+ }
+ });
+
+ $subscriber = new EmailCreatedSubscriber($logger, $userRepository, $entityManager);
+ $dispatcher = new EventDispatcher();
+ $event = new MessageEvent($message, $envelope, '');
+
+ $dispatcher->addSubscriber($subscriber);
+ $dispatcher->dispatch($event);
+ }
+}
diff --git a/tests/object-manager.php b/tests/object-manager.php
new file mode 100644
index 0000000..c58de6f
--- /dev/null
+++ b/tests/object-manager.php
@@ -0,0 +1,22 @@
+bootEnv(__DIR__.'/../.env');
+
+$appEnv = $_SERVER['APP_ENV'];
+assert(is_string($appEnv), 'APP_ENV should be specified and must be a string.');
+
+$appDebug = $_SERVER['APP_DEBUG'] ?? 'false';
+assert(is_string($appDebug));
+
+$kernel = new Kernel($appEnv, (bool) $appDebug);
+
+$kernel->boot();
+
+return $kernel->getContainer()->get('doctrine')->getManager();
diff --git a/translations/messages.zh_TW.yaml b/translations/messages.zh_TW.yaml
index 92a3780..eee286c 100644
--- a/translations/messages.zh_TW.yaml
+++ b/translations/messages.zh_TW.yaml
@@ -59,6 +59,16 @@ System Management: 系統管理
Announcement: 公告
URL: 網址
Published: 發布
+Preview: 預覽
+To User: 收件使用者
+To Address: 收件信箱
+Mails: 郵件
+Subject: 主旨
+EmailDeliveryEvent: 郵件投遞事件
+Kind: 種類
+Text Content: 文字內容
+HTML Content: HTML 內容
+EmailTemplates: 郵件範本
result_presenter.tabs.result: 執行結果
result_presenter.tabs.answer: 正確答案
@@ -173,3 +183,8 @@ challenge:
answer-query-failure: 正確答案也是個錯誤的 SQL 查詢:%error%
user-query-error: 你的 SQL 查詢執行失敗:%error%
user-query-failure: 你的 SQL 查詢不正確:%error%
+
+email-kind:
+ transactional: 通知型信件
+ marketing: 行銷型信件
+ test: 測試用信件
diff --git a/worker.Dockerfile b/worker.Dockerfile
new file mode 100644
index 0000000..3aa00dc
--- /dev/null
+++ b/worker.Dockerfile
@@ -0,0 +1,79 @@
+# syntax=docker/dockerfile:1
+
+FROM php:8.3-cli AS base
+
+ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
+
+WORKDIR /app
+VOLUME /app/var/
+
+RUN set -eux; \
+ apt-get update \
+ && apt-get install -y --no-install-recommends \
+ acl=* \
+ file=* \
+ gettext=* \
+ git=* \
+ && rm -rf /var/lib/apt/lists/* \
+ ;
+
+RUN set -eux; \
+ install-php-extensions \
+ @composer \
+ apcu \
+ curl \
+ intl \
+ opcache \
+ zip \
+ redis \
+ pdo_pgsql \
+ sysvsem \
+ ;
+
+# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
+ENV COMPOSER_ALLOW_SUPERUSER=1
+
+ENV PHP_INI_SCAN_DIR=":$PHP_INI_DIR/app.conf.d"
+
+COPY --link frankenphp/conf.d/10-app.ini $PHP_INI_DIR/app.conf.d/
+COPY --link --chmod=755 frankenphp/docker-entrypoint.sh /usr/local/bin/docker-entrypoint
+
+ENTRYPOINT ["docker-entrypoint"]
+
+# Worker
+FROM base AS worker
+LABEL org.opencontainers.image.source="https://github.com/database-playground/app-sf"
+
+ENV APP_ENV=prod
+ENV APP_DEBUG=0
+
+RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
+
+COPY --link frankenphp/conf.d/20-app.prod.ini $PHP_INI_DIR/app.conf.d/
+
+# prevent the reinstallation of vendors at every changes in the source code
+COPY --link composer.* symfony.* package.json* ./
+RUN set -eux; \
+ composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress;
+
+# copy sources
+COPY --link . ./
+RUN rm -Rf frankenphp/
+
+RUN set -eux; \
+ mkdir -p var/cache var/log; \
+ composer dump-autoload --classmap-authoritative --no-dev; \
+ composer dump-env prod; \
+ composer run-script --no-dev post-install-cmd; \
+ chmod +x bin/console; sync;
+
+RUN set -eux; \
+ chmod +x bin/console; sync; \
+ ./bin/console cache:clear; \
+ ./bin/console cache:warmup;
+
+ENV RUN_MIGRATIONS=false
+
+# Restart the messenger about each 10 minute or when memory limit (300M) is reached
+# https://symfony.com/doc/current/messenger.html#deploying-to-production
+CMD ["php", "bin/console", "messenger:consume", "--all", "-vv", "--time-limit=600", "--memory-limit=300M"]