taktik-nette

This commit is contained in:
Ondrej Vlach 2025-01-22 22:52:26 +01:00
commit 8e2cd6ca68
No known key found for this signature in database
GPG Key ID: 7F141CDACEDEE2DE
45 changed files with 5410 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
vendor
log
.phpunit.result.cache
temp
.phpcs-cache

1
.htaccess Normal file
View File

@ -0,0 +1 @@
Require all denied

11
Dockerfile.fpm Normal file
View File

@ -0,0 +1,11 @@
FROM php:8.3-fpm
RUN apt-get update && apt-get install -y nodejs npm unzip libzip-dev && rm -rf /var/cache/apt/*
RUN curl -sSL https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions -o - | sh -s \
opentelemetry-php/ext-opentelemetry@main opcache zip grpc intl calendar pdo_mysql mysqli redis
RUN curl -sS https://getcomposer.org/installer | php && mv composer.phar /usr/local/bin/composer
RUN composer self-update
RUN usermod -a -G www-data root
RUN mkdir -p /var/www/html
RUN chown -R www-data:www-data /var/www/html
WORKDIR /var/www/html/

3
Dockerfile.nginx Normal file
View File

@ -0,0 +1,3 @@
FROM nginx:1.25.3-alpine
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

0
Dockerfile.static Normal file
View File

64
README.md Normal file
View File

@ -0,0 +1,64 @@
How to start with development
===
* **Requirements:**
* bash
* Docker with Docker Compose (v2) command
* **Run:**
```bash
develop.sh
```
* Enjoy your coffee ;-)
* Nette will serve the web at [http://localhost:8000].
---
Tests
---
Run the following command to execute the tests:
```bash
docker compose exec php-fpm vendor/bin/phpunit
```
---
PHPStan & CS
---
* Run PHP CodeSniffer:
```bash
docker compose exec php-fpm vendor/bin/phpcs
```
* Run PHPStan with increased memory limit:
```bash
docker compose exec php-fpm vendor/bin/phpstan --memory-limit=2G
```
---
Containers
---
* `php-fpm` - FPM and additional tooling
* `nginx` - Serves static content, proxies requests to FPM
* `mariadb` - Database
---
TODO
---
* [ ] The current tests are just two unit tests. It definitely needs testing of individual pages, but I haven't had time to set up Codeception (which I would like to use), and the framework itself doesn't offer much.
* [ ] Add a monitoring stack (Prometheus, Otel, Loki, Grafana, etc.).
* [ ] Integrate Sentry.
* [ ] Establish deployment pipelines for dev/test and production environments. Configure production settings and Docker
images.
* [ ] Integrate CI/CD.
---
Notes
---
* Saving filters into session makes this app very unusable (back button etc.)

51
app/Bootstrap.php Normal file
View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App;
use Nette;
use Nette\Bootstrap\Configurator;
use Tracy\Debugger;
class Bootstrap
{
private Configurator $configurator;
private string $rootDir;
public function __construct()
{
$this->rootDir = dirname(__DIR__);
$this->configurator = new Configurator();
$this->configurator->setTempDirectory($this->rootDir . '/temp');
}
public function bootWebApplication(): Nette\DI\Container
{
$this->initializeEnvironment();
$this->setupContainer();
return $this->configurator->createContainer();
}
public function initializeEnvironment(): void
{
//$this->configurator->setDebugMode('secret@23.75.345.200'); // enable for your remote IP
//$this->configurator->enableTracy($this->rootDir . '/log');
$this->configurator->createRobotLoader()
->addDirectory(__DIR__)
->register();
}
private function setupContainer(): void
{
$configDir = $this->rootDir . '/config';
$this->configurator->addConfig($configDir . '/common.neon');
$this->configurator->addConfig($configDir . '/services.neon');
$this->configurator->addConfig($configDir . '/local.neon');
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Core;
use Nette;
use Nette\Application\Routers\RouteList;
final class RouterFactory
{
use Nette\StaticClass;
public static function createRouter(): RouteList
{
$router = new RouteList();
$router->addRoute('<presenter>/<action>[/<id>]', [
'presenter' => 'Survey',
'action' => 'default',
]);
return $router;
}
}

View File

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\UI\DTO\FilterDTO;
use App\UI\DTO\OrderDirection;
use App\UI\DTO\OrderDTO;
use Nette\Database\Explorer;
use Nette\Database\Row;
use Nette\Database\SqlLiteral;
class SurveyRepository
{
public function __construct(
private readonly Explorer $explorer
) {
}
/**
* @return array<int, Row>
*/
public function findSurveys(int $limit, int $offset, ?OrderDTO $order, FilterDTO $filter): array
{
if ($order === null) {
$order = new OrderDTO(
'survey_id',
OrderDirection::DESC
);
}
$data = $this->explorer->query(
"SELECT
survey.id AS survey_id,
survey.name AS survey_name,
survey.comments,
survey.agreement,
(
SELECT
GROUP_CONCAT(i2.interest SEPARATOR ',')
FROM interests i2
WHERE survey.id = i2.survey_id
GROUP BY i2.survey_id
) AS interests
FROM
survey
LEFT JOIN
interests
ON
survey.id = interests.survey_id
WHERE ?
GROUP BY
survey.id
ORDER BY ? ?
LIMIT ?
OFFSET ?",
$filter->filters,
new SqlLiteral($order->column),
new SqlLiteral($order->direction->value),
$limit,
$offset
);
return $data->fetchAll();
}
public function getSuveysCount(FilterDTO $filter): int
{
$countQuery = $this->explorer->query(
"SELECT
COUNT(DISTINCT survey.id) AS cnt
FROM survey
LEFT JOIN
interests
ON
survey.id = interests.survey_id
WHERE ?",
$filter->filters
);
return $countQuery->fetchField();
}
}

19
app/UI/@layout.latte Normal file
View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<title>{ifset title}{include title|stripHtml} | {/ifset}Nette Web</title>
</head>
<body>
<div n:foreach="$flashes as $flash" n:class="flash, $flash->type, alert, alert . '-' . $flash->type">{$flash->message}</div>
{include content}
{block scripts}
<script src="https://unpkg.com/nette-forms@3"></script>
{/block}
</body>
</html>

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\UI\Accessory;
use Latte\Extension;
use Nette\Localization\Translator;
final class LatteExtension extends Extension
{
public function __construct(
private readonly Translator $translator
) {
}
public function getFilters(): array
{
return [];
}
public function getFunctions(): array
{
return [
'interests' => [$this, 'renderInterests'],
];
}
public function renderInterests(?string $interests): string
{
if ($interests === null) {
return '';
}
$explodedInterests = explode(',', $interests);
$rtVal = [];
foreach ($explodedInterests as $interest) {
$rtVal[] = $this->translator->translate('survey.interests.' . $interest);
}
return implode(',', $rtVal);
}
}

31
app/UI/DTO/FilterDTO.php Normal file
View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\UI\DTO;
class FilterDTO
{
/**
* @param array<string, mixed> $filters
*/
public function __construct(public readonly array $filters = [])
{
}
/**
* @param array<string, mixed>|null $data
* @param array<int, string> $acceptFilters
* @return FilterDTO
*/
public static function createFromData(?array $data, array $acceptFilters = []): FilterDTO
{
if ($data === null) {
return new self([]);
}
$result = array_intersect_key($data, array_flip($acceptFilters));
return new self($result);
}
}

34
app/UI/DTO/OrderDTO.php Normal file
View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\UI\DTO;
class OrderDTO
{
public function __construct(public readonly string $column, public readonly OrderDirection $direction)
{
}
/**
* @param array<int, string> $acceptColumns
*/
public static function createOrReturnNullFromData(?string $order, ?string $direction, array $acceptColumns = []): ?OrderDTO
{
if ($order === null || $direction === null) {
return null;
}
if (!in_array($order, $acceptColumns, true)) {
return null;
}
$direction = OrderDirection::tryFrom($direction);
if ($direction === null) {
return null;
}
return new self($order, $direction);
}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\UI\DTO;
enum OrderDirection: string
{
case ASC = "ASC";
case DESC = "DESC";
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\UI\Error\Error4xx;
use Nette;
use Nette\Application\Attributes\Requires;
/**
* Handles 4xx HTTP error responses.
*/
#[Requires(methods: '*', forward: true)]
final class Error4xxPresenter extends Nette\Application\UI\Presenter
{
public function renderDefault(Nette\Application\BadRequestException $exception): void
{
$this->redirect(':Survey:default');
}
}

View File

@ -0,0 +1,27 @@
<!DOCTYPE html><!-- "' --></textarea></script></style></pre></xmp></a></audio></button></canvas></datalist></details></dialog></iframe></listing></meter></noembed></noframes></noscript></optgroup></option></progress></rp></select></table></template></title></video>
<meta charset="utf-8">
<meta name="robots" content="noindex">
<title>Server Error</title>
<style>
#nette-error { all: initial; position: absolute; top: 0; left: 0; right: 0; height: 70vh; min-height: 400px; display: flex; align-items: center; justify-content: center; z-index: 1000 }
#nette-error div { all: initial; max-width: 550px; background: white; color: #333; display: block }
#nette-error h1 { all: initial; font: bold 50px/1.1 sans-serif; display: block; margin: 40px }
#nette-error p { all: initial; font: 20px/1.4 sans-serif; margin: 40px; display: block }
#nette-error small { color: gray }
</style>
<div id=nette-error>
<div>
<h1>Server Error</h1>
<p>We're sorry! The server encountered an internal error and
was unable to complete your request. Please try again later.</p>
<p><small>error 500</small></p>
</div>
</div>
<script>
document.body.insertBefore(document.getElementById('nette-error'), document.body.firstChild);
</script>

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
header('HTTP/1.1 503 Service Unavailable');
header('Retry-After: 300'); // 5 minutes in seconds
?>
<!DOCTYPE html>
<meta charset="utf-8">
<meta name="robots" content="noindex">
<meta name="generator" content="Nette Framework">
<style>
body { color: #333; background: white; width: 500px; margin: 100px auto }
h1 { font: bold 47px/1.5 sans-serif; margin: .6em 0 }
p { font: 21px/1.5 Georgia,serif; margin: 1.5em 0 }
</style>
<title>Site is temporarily down for maintenance</title>
<h1>We're Sorry</h1>
<p>The site is temporarily down for maintenance. Please try again in a few minutes.</p>

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\UI\Error\Error5xx;
use Nette;
use Nette\Application\Attributes\Requires;
use Nette\Application\Responses;
use Nette\Http;
use Tracy\ILogger;
/**
* Handles uncaught exceptions and errors, and logs them.
*/
#[Requires(forward: true)]
final class Error5xxPresenter implements Nette\Application\IPresenter
{
public function __construct(
private ILogger $logger,
) {
}
public function run(Nette\Application\Request $request): Nette\Application\Response
{
// Log the exception
$exception = $request->getParameter('exception');
$this->logger->log($exception, ILogger::EXCEPTION);
// Display a generic error message to the user
return new Responses\CallbackResponse(function (Http\IRequest $httpRequest, Http\IResponse $httpResponse): void {
if (preg_match('#^text/html(?:;|$)#', (string) $httpResponse->getHeader('Content-Type'))) {
require __DIR__ . '/500.phtml';
}
});
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\UI\Form;
use Nette\Application\UI\Form;
use Nette\Localization\Translator;
class ResultFilteringFactory
{
public function __construct(
private readonly Translator $translator,
) {
}
public function create(): Form
{
$form = new Form();
$form->setTranslator($this->translator);
$form->addText('name', 'survey.name');
$form->addTextArea('comments', 'survey.comments');
$form->addMultiSelect('interest', 'survey.interests', [
'sport' => 'survey.interests.sport',
'music' => 'survey.interests.music',
'travel' => 'survey.interests.travel',
]);
$form->addSubmit('send', 'survey.search');
return $form;
}
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\UI\Form;
use Nette\Application\UI\Form;
use Nette\Forms\Form as NetteForm;
use Nette\Localization\Translator;
class SurveyFormFactory
{
public function __construct(
protected readonly Translator $translator,
) {
}
public function create(): Form
{
$form = new Form();
$form->setTranslator($this->translator);
$form->addText('name', 'survey.name')
->setRequired('survey.name_required')
->addRule(NetteForm::MaxLength, $this->translator->translate('survey.name_is_too_long'), 255);
$form->addTextArea('comments', 'survey.comments');
$form->addCheckbox('agreement', 'survey.agreement')
->setRequired('survey.agreement_required');
$form->addMultiSelect('interests', 'survey.interests', [
'sport' => 'survey.interests.sport',
'music' => 'survey.interests.music',
'travel' => 'survey.interests.travel',
])->setRequired('survey.interests_required')
->addRule(NetteForm::MaxLength, $this->translator->translate('survey.interest_is_too_long'), 255);
$form->addSubmit('send', 'survey.send');
$form->addProtection('survey.protection');
return $form;
}
}

View File

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\UI\Results;
use App\Repository\SurveyRepository;
use App\UI\DTO\FilterDTO;
use App\UI\DTO\OrderDTO;
use App\UI\Form\ResultFilteringFactory;
use Nette\Application\Attributes\Persistent;
use Nette\Application\UI\Form;
use Nette\Application\UI\Presenter;
use Nette\Http\Session;
use Nette\Utils\Paginator;
use Nubium\Exception\ThisShouldNeverHappenException;
class ResultsPresenter extends Presenter
{
#[Persistent]
public ?string $order = null;
#[Persistent]
public ?string $direction = null;
/**
* @var array<string, mixed>|null
*/
#[Persistent]
public ?array $filter = null;
protected const SESSION_STORE_KEY = 'survey_results_v1';
public function __construct(
protected readonly SurveyRepository $surveyRepository,
protected readonly ResultFilteringFactory $resultFilteringFactory,
protected readonly Session $session,
) {
parent::__construct();
$this->restoreStateFromSession();
if ($this->order === null) {
$this->order = 'survey_id';
}
if ($this->direction === null) {
$this->direction = 'desc';
}
if ($this->filter === null) {
$this->filter = null;
}
}
public function renderDefault(int $page = 1): void
{
$order = OrderDTO::createOrReturnNullFromData($this->order, $this->direction, ['survey_name', 'comments', 'survey_id']);
$filter = FilterDTO::createFromData($this->filter, ['name', 'comments', 'agreement', 'interest']);
$itemsCount = $this->surveyRepository->getSuveysCount($filter);
$paginator = new Paginator();
$paginator->setItemCount($itemsCount);
$paginator->setItemsPerPage(10);
$paginator->setPage($page);
$articles = $this->surveyRepository->findSurveys($paginator->getLength(), $paginator->getOffset(), $order, $filter);
$this->template->surveys = $articles; // @phpstan-ignore property.notFound
$this->template->paginator = $paginator; // @phpstan-ignore property.notFound
$this->template->order = $this->order; // @phpstan-ignore property.notFound
$this->template->direction = $this->direction; // @phpstan-ignore property.notFound
}
protected function createComponentFilteringForm(): Form
{
$form = $this->resultFilteringFactory->create();
$form->setDefaults($this->filter ?? []);
// @phpstan-ignore-next-line assign.propertyType
$form->onSuccess[] = function (\Nette\Forms\Form $form, array $values): void {
if ($form->isValid()) {
$filter = [];
if (!empty($values['name'])) {
$filter['name'] = $values['name'];
}
if (!empty($values['agreement'])) {
$filter['agreement'] = $values['agreement'];
}
if (!empty($values['comments'])) {
$filter['comments'] = $values['comments'];
}
if (!empty($values['interest'])) {
$filter['interest'] = $values['interest'];
}
$this->filter = $filter;
$this->storeStateIntoSession();
$this->redirect('default', [
'page' => 1,
'filter' => $filter,
]);
}
};
return $form;
}
protected function restoreStateFromSession(): void
{
$session = $this->session->getSection(self::SESSION_STORE_KEY);
if ($session->get('order') !== null) {
$this->order = $session->get('order');
}
if ($session->get('direction') !== null) {
$this->direction = $session->get('direction');
}
if ($session->get('filter') !== null) {
$this->filter = $session->get('filter');
}
}
protected function storeStateIntoSession(): void
{
$session = $this->session->getSection(self::SESSION_STORE_KEY);
$session->set('order', $this->order);
$session->set('direction', $this->direction);
$session->set('filter', $this->filter);
}
public function terminate(): void
{
$this->storeStateIntoSession();
parent::terminate();
}
}

View File

@ -0,0 +1,63 @@
{block content}
<h1>Ankety</h1>
<div class="suveys">
{control filteringForm}
{if count($surveys) == 0}
<div class="alert alert-danger">{_('survey.data_not_found')}</div>
{else}
<table class="table">
<thead>
<tr>
<th>
<a n:href="default, 1, order => 'survey_id', direction => $direction === 'ASC' && $order === 'survey_id' ? 'DESC' : 'ASC'">
{_('survey.id')} {$order === 'survey_id' ? ($direction === 'ASC' ? '▲' : '▼') : ''}
</a>
</th>
<th>
<a n:href="default, 1, order => 'survey_name', direction => $direction === 'ASC' && $order === 'survey_name' ? 'DESC' : 'ASC'">
{_('survey.name')} {$order === 'survey_name' ? ($direction === 'ASC' ? '▲' : '▼') : ''}
</a>
</th>
<th>
<a n:href="default, 1, order => 'comments', direction => $direction === 'ASC' && $order === 'comments' ? 'DESC' : 'ASC'">
{_('survey.comments')} {$order === 'comments' ? ($direction === 'ASC' ? '▲' : '▼') : ''}
</a>
</th>
<th>{_('survey.interests')}</th>
</tr>
</thead>
<tbody>
{foreach $surveys as $survey}
<tr >
<td>{$survey->survey_id}</td>
<td>{$survey->survey_name}</td>
<td>{$survey->comments}</td>
<td>{interests($survey->interests)}</td>
</tr>
{/foreach}
</tbody>
</table>
{/if}
</div>
{if count($surveys) > 0}
<div class="pagination">
{if !$paginator->isFirst()}
<a n:href="default, 1">{_('pagination.first')}</a>
&nbsp;|&nbsp;
<a n:href="default, $paginator->page-1">{_('pagination.prev')}</a>
&nbsp;|&nbsp;
{/if}
Stránka {$paginator->getPage()} z {$paginator->getPageCount()}
{if !$paginator->isLast()}
&nbsp;|&nbsp;
<a n:href="default, $paginator->getPage() + 1">{_('pagination.next')}</a>
&nbsp;|&nbsp;
<a n:href="default, $paginator->getPageCount()">{_('pagination.last')}</a>
{/if}
</div>
{/if}
{/block}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\UI\Survey;
use Nette;
use App\UI\Form\SurveyFormFactory;
use Nette\Application\UI\Form;
use Nubium\Exception\ThisShouldNeverHappenException;
class SurveyPresenter extends Nette\Application\UI\Presenter
{
public function __construct(
protected readonly SurveyFormFactory $surveyFormFactor,
protected readonly Nette\Database\Explorer $database,
protected readonly Nette\Localization\Translator $translator
) {
parent::__construct();
}
public function renderDefault(): void
{
}
/**
* @param array<string, mixed> $values
*/
public function processSurveyForm(Nette\Forms\Form $form, array $values): void
{
if ($form->isValid()) {
$this->database->beginTransaction();
try {
$recordId = $this->database->table('survey')->insert(
[
'name' => $values['name'],
'comments' => $values['comments'],
'agreement' => $values['agreement'],
]
);
foreach ($values['interests'] as $value) {
$this->database->table('interests')->insert(
[
'survey_id' => $recordId,
'interest' => $value,
]
);
}
$this->database->commit();
$this->flashMessage($this->translator->translate('survey.successfullyFilled'), 'success');
$this->redirect('this');
} catch (\PDOException $e) {
$this->database->rollback(); // TODO: Tady bych dal log do sentry a rekl uziteli at to zkusi pozdeji ;-)
throw $e;
}
}
}
protected function createComponentSurveyForm(): Nette\Application\UI\Form
{
$form = $this->surveyFormFactor->create();
$form->onSuccess[] = [$this, 'processSurveyForm']; // @phpstan-ignore assign.propertyType
return $form;
}
}

View File

@ -0,0 +1,4 @@
{block content}
<h1>{_('survey.header')}</h1>
{control surveyForm}
{/block}

View File

View File

@ -0,0 +1,4 @@
last: Poslední
next: Následující
prev: Předcházející
first: První

18
app/lang/survey.cs.neon Normal file
View File

@ -0,0 +1,18 @@
name: Jméno
comments: Komentáře
agreement: Souhlasím s podmínkami
interests: Zájmy
header: Dotazník
interests.sport: Sport
interests.music: Hudba
interests.travel: Cestování
send: Odeslat
name_required: Vyplňte prosím jméno
interests_required: Vypňte prosím zájmy
agreement_required: Musíte souhlasit s podmínkami
successfullyFilled: "Anketa byla úspěšně vyplňena, děkujeme!"
search: Hledat
id: ID
name_is_too_long: "Jméno je na naši anketu příliš dlouhé. Bohužel :-("
interest_is_too_long: "Váš zájem náš systém neschroupe. Pokud si myslíte že to je chyba, prosím obraťte se na naši podporu"
data_not_found: "Data nebyly nalezeny"

42
composer.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "nette/web-project",
"description": "Nette: Standard Web Project",
"keywords": ["nette"],
"type": "project",
"license": ["MIT", "BSD-3-Clause", "GPL-2.0-only", "GPL-3.0-only"],
"require": {
"php": ">= 8.1",
"nette/application": "^3.2.3",
"nette/bootstrap": "^3.2",
"nette/caching": "^3.3",
"nette/database": "^3.2",
"nette/di": "^3.2",
"nette/forms": "^3.2",
"nette/http": "^3.3",
"nette/mail": "^4.0",
"nette/robot-loader": "^4.0",
"nette/security": "^3.2",
"nette/utils": "^4.0",
"latte/latte": "^3.0",
"tracy/tracy": "^2.10",
"contributte/translation": "^2.0",
"nubium/this-should-never-happen-exception": "^1.0"
},
"autoload": {
"psr-4": {
"App\\": "app"
}
},
"minimum-stability": "stable",
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
}
},
"require-dev": {
"phpunit/phpunit": "^11.5",
"slevomat/coding-standard": "^8.15",
"squizlabs/php_codesniffer": "^3.11",
"phpstan/phpstan": "^2.1"
}
}

4090
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

44
config/common.neon Normal file
View File

@ -0,0 +1,44 @@
parameters:
extensions:
translation: Contributte\Translation\DI\TranslationExtension
translation:
locales:
whitelist: [cs]
default: cs
fallback: [cs]
dirs:
- %appDir%/lang
returnOriginalMessage: true # to not translate undefined messages, default is true
application:
errorPresenter:
4xx: Error:Error4xx
5xx: Error:Error5xx
mapping: App\UI\*\**Presenter
database:
dsn: 'mysql:host=mariadb;dbname=survey;charset=utf8mb4'
user: nette
password: nette
latte:
strictTypes: yes
strictParsing: yes
extensions:
- App\UI\Accessory\LatteExtension
session:
autoStart: true
save_handler: redis
save_path: 'tcp://redis?timeout=3'
services:
- App\Repository\SurveyRepository
di:
export:
parameters: no
tags: no

5
config/local.neon Normal file
View File

@ -0,0 +1,5 @@
parameters:
debugMode: true
tracy:
showDebugBar: true
strictMode: true

10
config/services.neon Normal file
View File

@ -0,0 +1,10 @@
services:
- App\Core\RouterFactory::createRouter
search:
- in: %appDir%
classes:
- *Facade
- *Factory
- *Repository
- *Service

23
develop.sh Executable file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env sh
set -ex
docker compose up --build -d
# composer
docker compose exec -it php-fpm composer install
# fix permissions (TODO: fix-me) - by user in dockerfile
docker compose exec -it php-fpm /bin/sh -c "mkdir -p /var/www/html/temp && chown -R www-data:www-data /var/www/html/temp"
docker compose up -d mariadb --wait
cat migrations/*.sql | docker compose exec -T mariadb mysql -pnette survey
# run containers (and build again)
if [ "$NO_EXECUTE" != "1" ]; then
docker compose up
fi

59
docker-compose.yml Normal file
View File

@ -0,0 +1,59 @@
services:
nginx:
build:
context: .
dockerfile: ./Dockerfile.nginx
ports:
- '8000:80'
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
- ./:/var/www/html/
links:
- php-fpm
php-fpm:
build:
context: .
dockerfile: ./Dockerfile.fpm
volumes:
- ./:/var/www/html/
environment:
- OTEL_LOG_LEVEL=debug
- OTEL_TRACES_EXPORTER=otlp
- OTEL_METRICS_EXPORTER=otlp
- OTEL_LOGS_EXPORTER=otlp
- OTEL_PHP_AUTOLOAD_ENABLED=true
- OTEL_PHP_TRACES_PROCESSOR=simple
- OTEL_PHP_LOG_DESTINATION=stderr
- OTEL_EXPORTER_OTLP_PROTOCOL=grpc
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4317
- DB_CONNECTION=mysql
- DB_URL=mysql://laravel:laravel@mariadb:3306/postsystem
- REDIS_HOST=redis
- SESSION_DRIVER=file
- APP_DEBUG=true
- APP_ENV=local
- APP_KEY=base64:yFEh+jTepLsusyVKLmFY3ukDfJrshbB3J6jVzVk1guw=
links:
- mariadb
depends_on:
mariadb:
condition: service_healthy
dns:
- 8.8.8.8
mariadb:
image: mariadb:10.6
environment:
- MYSQL_ROOT_PASSWORD=nette
- MYSQL_DATABASE=survey
- MYSQL_USER=nette
- MYSQL_PASSWORD=nette
healthcheck:
test: [ "CMD", "healthcheck.sh", "--connect", "--innodb_initialized" ]
start_period: 10s
interval: 10s
timeout: 10s
retries: 10
redis:
image: redis:7.4.2

20
docker/nginx/default.conf Normal file
View File

@ -0,0 +1,20 @@
server {
index index.php index.html;
server_name symfony_example_app;
error_log stderr debug;
access_log stderr;
listen 80;
root /var/www/html/www;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ ^/.+\.php(/|$) {
fastcgi_pass php-fpm:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}

21
migrations/001_init.sql Normal file
View File

@ -0,0 +1,21 @@
CREATE TABLE survey (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL COLLATE utf8_czech_ci,
comments TEXT COLLATE utf8_czech_ci,
agreement BOOLEAN NOT NULL
);
CREATE INDEX idx_survey_name ON survey (name);
CREATE FULLTEXT INDEX idx_survey_comments ON survey (comments);
CREATE TABLE interests
(
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
survey_id BIGINT UNSIGNED NOT NULL,
interest VARCHAR(255) NOT NULL COLLATE utf8_czech_ci ,
FOREIGN KEY (survey_id) REFERENCES survey (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX idx_interests_survey_id ON interests (survey_id);
CREATE INDEX idx_interests_interest ON interests (interest);

30
phpcs.xml.dist Normal file
View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<ruleset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/squizlabs/php_codesniffer/phpcs.xsd">
<arg name="basepath" value="."/>
<arg name="cache" value=".phpcs-cache"/>
<arg name="colors"/>
<arg name="extensions" value="php"/>
<config name="installed_paths" value="vendor/slevomat/coding-standard"/>
<rule ref="vendor/slevomat/coding-standard/SlevomatCodingStandard/Sniffs/TypeHints/DeclareStrictTypesSniff.php">
<properties>
<property name="spacesCountAroundEqualsSign">0</property>
</properties>
</rule>
<rule ref="PSR12"/>
<rule ref="Generic.Files.LineLength">
<properties>
<property name="lineLimit" value="N"/>
<property name="absoluteLineLimit" value="M"/>
</properties>
</rule>
<file>app/</file>
<file>tests/</file>
</ruleset>

5
phpstan.neon.dist Normal file
View File

@ -0,0 +1,5 @@
parameters:
level: 8
paths:
- app/
- tests/

19
phpunit.xml Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
</php>
</phpunit>

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\UI\DTO\FilterDTO;
use PHPUnit\Framework\TestCase;
final class FilterDTOTest extends TestCase
{
public function testCreateFromInvalidDataWithAcceptedFilters(): void
{
$data = [
'name' => 'John',
'age' => 30,
'invalid_key' => 'should not be included',
];
$acceptFilters = ['name', 'age'];
$filterDTO = FilterDTO::createFromData($data, $acceptFilters);
$this->assertInstanceOf(FilterDTO::class, $filterDTO);
$this->assertSame(
[
'name' => 'John',
'age' => 30,
],
$filterDTO->filters
);
}
public function testCreateFromInvalidDataWithNoAcceptedFilters(): void
{
$data = [
'name' => 'John',
'age' => 30,
'invalid_key' => 'should not be included',
];
$acceptFilters = [];
$filterDTO = FilterDTO::createFromData($data, $acceptFilters);
$this->assertInstanceOf(FilterDTO::class, $filterDTO);
$this->assertSame([], $filterDTO->filters);
}
public function testCreateFromInvalidDataWithoutMatchingKeys(): void
{
$data = [
'invalid_key1' => 'value1',
'invalid_key2' => 'value2',
];
$acceptFilters = ['name', 'age'];
$filterDTO = FilterDTO::createFromData($data, $acceptFilters);
$this->assertInstanceOf(FilterDTO::class, $filterDTO);
$this->assertSame([], $filterDTO->filters);
}
}

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\UI\DTO\OrderDirection;
use App\UI\DTO\OrderDTO;
use PHPUnit\Framework\TestCase;
final class OrderDTOTest extends TestCase
{
public function testCreateOrReturnNullFromInvalidDataWithValidInputs(): void
{
$acceptFilters = ['name', 'created_at'];
$order = 'name';
$direction = 'ASC';
$orderDTO = OrderDTO::createOrReturnNullFromData($order, $direction, $acceptFilters);
$this->assertInstanceOf(OrderDTO::class, $orderDTO);
$this->assertSame($order, $orderDTO->column);
$this->assertSame(OrderDirection::ASC, $orderDTO->direction);
}
public function testCreateOrReturnNullFromInvalidDataWithInvalidOrder(): void
{
$acceptFilters = ['name', 'created_at'];
$order = 'invalid_column';
$direction = 'ASC';
$orderDTO = OrderDTO::createOrReturnNullFromData($order, $direction, $acceptFilters);
$this->assertNull($orderDTO);
}
public function testCreateOrReturnNullFromInvalidDataWithInvalidDirection(): void
{
$acceptFilters = ['name', 'created_at'];
$order = 'name';
$direction = 'INVALID_DIRECTION';
$orderDTO = OrderDTO::createOrReturnNullFromData($order, $direction, $acceptFilters);
$this->assertNull($orderDTO);
}
public function testCreateOrReturnNullFromInvalidDataWithEmptyFilters(): void
{
$acceptFilters = [];
$order = 'name';
$direction = 'ASC';
$orderDTO = OrderDTO::createOrReturnNullFromData($order, $direction, $acceptFilters);
$this->assertNull($orderDTO);
}
}

41
www/.htaccess Normal file
View File

@ -0,0 +1,41 @@
# Apache configuration file (see https://httpd.apache.org/docs/current/mod/quickreference.html)
# Allow access to all resources by default
Require all granted
# Disable directory listing for security reasons
<IfModule mod_autoindex.c>
Options -Indexes
</IfModule>
# Enable pretty URLs (removing the need for "index.php" in the URL)
<IfModule mod_rewrite.c>
RewriteEngine On
# Uncomment the next line if you want to set the base URL for rewrites
# RewriteBase /
# Force usage of HTTPS (secure connection). Uncomment if you have SSL setup.
# RewriteCond %{HTTPS} !on
# RewriteRule .? https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# Permit requests to the '.well-known' directory (used for SSL verification and more)
RewriteRule ^\.well-known/.* - [L]
# Block access to hidden files (starting with a dot) and URLs resembling WordPress admin paths
RewriteRule /\.|^\.|^wp-(login|admin|includes|content) - [F]
# Return 404 for missing files with specific extensions (images, scripts, styles, archives)
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule \.(pdf|js|mjs|ico|gif|jpg|jpeg|png|webp|avif|svg|css|rar|zip|7z|tar\.gz|map|eot|ttf|otf|woff|woff2)$ - [L]
# Front controller pattern - all requests are routed through index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.php [L]
</IfModule>
# Enable gzip compression for text files
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css application/javascript application/json application/xml application/rss+xml image/svg+xml
</IfModule>

BIN
www/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

11
www/index.php Normal file
View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
$bootstrap = new App\Bootstrap;
$container = $bootstrap->bootWebApplication();
$application = $container->getByType(Nette\Application\Application::class);
$application->run();

0
www/robots.txt Normal file
View File