<?php
declare(strict_types=1);

namespace Infrastructure\BaseEntity\DatabaseTable\Model;

use Infrastructure\BaseEntity\DatabaseTable\View\DatabaseTableForm;
use Infrastructure\Database\Postgres;
use Infrastructure\Database\DataMappers\TableMapper;
use Infrastructure\Database\DataMappers\ColumnMapper;

class DatabaseTableValidation
{
    private $tableMapper;
    private $validationColumns;

    public function __construct(TableMapper $tableMapper)
    {
        $this->tableMapper = $tableMapper;
        $this->setValidationRules();
    }

    private function setValidationRules(): array
    {
        $validation = [];
        /** primary key and 'created' columns are skipped in getFieldColumns() */
        foreach (DatabaseTableForm::getFieldColumns($this->tableMapper) as $column) {
            // do not impose required validation on boolean (checkbox) fields, even though the column may be not null, allowing 'f' (unchecked) is fine but doing required validation will cause an error for unchecked condition
            if (!$column->isBoolean()) {
                $this->validationRules[$column->getName()] = self::getDatabaseColumnValidation($column);
            }
        }

        return $validation;
    }

    public function getValidationRules(): array
    {
        return $this->validationRules;
    }

    // just the keys (column names) of the rules array
    public function getValidationColumnNames(): array
    {
        return array_keys($this->validationRules);
    }

    // returns true, false, or validation value
    public static function getDatabaseColumnValidationValue(ColumnMapper $databaseColumnMapper, string $validationType)
    {
        foreach (self::getDatabaseColumnValidation($databaseColumnMapper) as $validation) {
            if (!is_array($validation) && $validation == $validationType) {
                return true;
            } elseif (is_array($validation) && $validation[0] == $validationType) {
                return $validation[1];
            }
        }

        return false;
    }

    public static function getDatabaseColumnValidation(ColumnMapper $databaseColumnMapper): array
    {
        $columnValidation = [];
        $columnConstraints = $databaseColumnMapper->getConstraints();

        if (!$databaseColumnMapper->getIsNullable()) {
            $columnValidation[] = 'required';
        }

        if ($databaseColumnMapper->getCharacterMaximumLength() != null) {
            $columnValidation[] = ['lengthMax', $databaseColumnMapper->getCharacterMaximumLength()];
        }

        if ($databaseColumnMapper->isNumericType()) {
            if ($databaseColumnMapper->isIntegerType()) {
                $columnValidation[] = 'integer';
                switch ($databaseColumnMapper->getType()) {
                    case 'smallint':
                        $minValue = (in_array('positve', $columnConstraints)) ? 1 : Postgres::SMALLINT_MIN;
                        $columnValidation[] = ['min', $minValue];
                        $columnValidation[] = ['max', Postgres::SMALLINT_MAX];
                        break;

                    case 'integer':
                        $minValue = (in_array('positve', $columnConstraints)) ? 1 : Postgres::INTEGER_MIN;
                        $columnValidation[] = ['min', $minValue];
                        $columnValidation[] = ['max', Postgres::INTEGER_MAX];
                        break;

                    case 'bigint':
                        $minValue = (in_array('positve', $columnConstraints)) ? 1 : Postgres::BIGINT_MIN;
                        $columnValidation[] = ['min', $minValue];
                        $columnValidation[] = ['max', Postgres::BIGINT_MAX];
                        break;

                    case 'smallserial':
                        $minValue = (in_array('positve', $columnConstraints)) ? 1 : Postgres::SMALLSERIAL_MIN;
                        $columnValidation[] = ['min', $minValue];
                        $columnValidation[] = ['max', Postgres::SMALLSERIAL_MAX];
                        break;

                    case 'serial':
                        $minValue = (in_array('positve', $columnConstraints)) ? 1 : Postgres::SERIAL_MIN;
                        $columnValidation[] = ['min', $minValue];
                        $columnValidation[] = ['max', Postgres::SERIAL_MAX];
                        break;

                    case 'bigserial':
                        $minValue = (in_array('positve', $columnConstraints)) ? 1 : Postgres::BIGSERIAL_MIN;
                        $columnValidation[] = ['min', $minValue];
                        $columnValidation[] = ['max', Postgres::BIGSERIAL_MAX];
                        break;

                    default:
                        throw new \Exception("Undefined postgres integer type ".$column->getType());
                }
            } else {
                $columnValidation[] = 'numeric';
            }
        }

        return $columnValidation;
    }

}


<?php
declare(strict_types=1);

namespace Infrastructure\BaseEntity\DatabaseTable\View;

use Infrastructure\BaseEntity\BaseMVC\View\Forms\FormHelper;
use It_All\FormFormer\Fields\Field;
use It_All\FormFormer\Fields\InputField;
use It_All\FormFormer\Fields\InputFields\CheckboxRadioInputField;
use It_All\FormFormer\Fields\SelectField;
use It_All\FormFormer\Fields\SelectOption;
use It_All\FormFormer\Fields\TextareaField;
use It_All\FormFormer\Form;
use Infrastructure\Database\DataMappers\ColumnMapper;
use Infrastructure\Database\DataMappers\TableMapper;
use Infrastructure\BaseEntity\DatabaseTable\Model\DatabaseTableValidation;
use Infrastructure\Database\Postgres;

class DatabaseTableForm extends Form
{
    private static $tableMapper;

    /** by default do not include in forms */
    private const DEFAULT_SKIP_COLUMNS = ['created'];

    /** @var array of form field columns */
    private static $fieldColumns;

    /** @var array */
    private static $fieldNames;

    const TEXTAREA_COLS = 50;
    const TEXTAREA_ROWS = 5;

    public function __construct(TableMapper $databaseTableMapper, string $formAction, string $csrfNameKey, string $csrfNameValue, string $csrfValueKey, string $csrfValueValue, ?string $databaseAction = 'insert', ?array $fieldData = null, ?array $skipColumnNames = null, ?bool $jsValidate = true)
    {
        self::$tableMapper = $databaseTableMapper;

        $formTagAttributes = ['method' => 'post', 'action' => $formAction];
        if (!$jsValidate) {
            $formTagAttributes['novalidate'] = 'novalidate';
        }

        /** also sets field names */
        self::setFieldColumns($skipColumnNames);

        $fields = $this->getFields($csrfNameKey, $csrfNameValue, $csrfValueKey, $csrfValueValue, $databaseAction, $fieldData);

        parent::__construct($fields, $formTagAttributes, FormHelper::getGeneralError());
    }

    private static function setFieldColumns(?array $skipColumnNames = null)
    {
        self::$fieldColumns = [];
        self::$fieldNames = [];
        foreach (self::$tableMapper->getColumns() as $column) {
            if (self::includeFieldForColumn($column, $skipColumnNames)) {
                self::$fieldColumns[] = $column;
                self::$fieldNames[] = $column->getName();
            }
        }
    }

    /**
     * conditions for returning false:
     * - primary column
     */
    protected static function includeFieldForColumn(ColumnMapper $column, ?array $skipColumnNames = null): bool
    {
        if ($column->isPrimaryKey()) {
            return false;
        }

        $notIncluded = ($skipColumnNames !== null) ? array_merge(self::DEFAULT_SKIP_COLUMNS, $skipColumnNames) : self::DEFAULT_SKIP_COLUMNS;

        if (in_array($column->getName(), $notIncluded)) {
            return false;
        }

        return true;
    }

    /** allow access without constructing */
    public static function getFieldColumns(TableMapper $databaseTableMapper): array
    {
        if (!isset(self::$tableMapper)) {
            self::$tableMapper = $databaseTableMapper;
        } elseif (self::$tableMapper !== $databaseTableMapper) {
            throw new \InvalidArgumentException("Table mapper mismatch");
        }

        if (!isset(self::$fieldColumns)) {
            self::setFieldColumns();
        }

        return self::$fieldColumns;
    }

    /** allow access without constructing */
    public static function getFieldNames(TableMapper $databaseTableMapper): array
    {
        if (!isset(self::$tableMapper)) {
            self::$tableMapper = $databaseTableMapper;
        } elseif (self::$tableMapper !== $databaseTableMapper) {
            throw new \InvalidArgumentException("Table mapper mismatch");
        }

        if (!isset(self::$fieldNames)) {
            self::setFieldColumns();
        }

        return self::$fieldNames;
    }

    private function getFields(string $csrfNameKey, string $csrfNameValue, string $csrfValueKey, string $csrfValueValue, string $databaseAction = 'insert', array $fieldData = null)
    {
        $this->validateDatabaseActionString($databaseAction);

        $nodes = [];

        foreach (self::$fieldColumns as $fieldColumn) {
            // value
            if (isset($fieldData)) {
                $columnValue = (isset($fieldData[$fieldColumn->getName()])) ? $fieldData[$fieldColumn->getName()] : ''; // sending '' instead of null takes care of checkbox fields where nothing is posted if unchecked
            } else {
                $columnValue = null;
            }

            $nodes[] = $this->getFieldFromDatabaseColumn($fieldColumn, null, $columnValue);
        }

        if ($databaseAction == 'update') {
            // override post method
            $nodes[] = FormHelper::getPutMethodField();
        }

        $nodes[] = FormHelper::getCsrfNameField($csrfNameKey, $csrfNameValue);
        $nodes[] = FormHelper::getCsrfValueField($csrfValueKey, $csrfValueValue);
        $nodes[] = FormHelper::getSubmitField();

        return $nodes;
    }
    protected function validateDatabaseActionString(string $databaseAction)
    {
        if ($databaseAction != 'insert' && $databaseAction != 'update') {
            throw new \Exception("databaseAction must be insert or update ".$databaseAction);
        }
    }

    protected static function getMinMaxForIntegerTypes(ColumnMapper $column): array
    {
        switch ($column->getType()) {
            case 'smallint':
                $min = DatabaseTableValidation::getDatabaseColumnValidationValue($column,'min');
                $max = DatabaseTableValidation::getDatabaseColumnValidationValue($column,'max');
                break;

            case 'integer':
                $min = DatabaseTableValidation::getDatabaseColumnValidationValue($column,'min');
                $max = DatabaseTableValidation::getDatabaseColumnValidationValue($column,'max');
                break;

            case 'bigint':
                $min = DatabaseTableValidation::getDatabaseColumnValidationValue($column,'min');
                $max = DatabaseTableValidation::getDatabaseColumnValidationValue($column,'max');
                break;

            case 'smallserial':
                $min = DatabaseTableValidation::getDatabaseColumnValidationValue($column,'min');
                $max = DatabaseTableValidation::getDatabaseColumnValidationValue($column,'max');
                break;

            case 'serial':
                $min = DatabaseTableValidation::getDatabaseColumnValidationValue($column,'min');
                $max = DatabaseTableValidation::getDatabaseColumnValidationValue($column,'max');
                break;

            case 'bigserial':
                $min = DatabaseTableValidation::getDatabaseColumnValidationValue($column,'min');
                $max = DatabaseTableValidation::getDatabaseColumnValidationValue($column,'max');
                break;

            default:
                throw new \Exception("Undefined postgres integer type ".$column->getType());

        }

        return [$min, $max];

    }

    // static for access to column field only
    // valueOverride for checkboxes should be 't' or 'f' to match postgres bool
    public static function getFieldFromDatabaseColumn(
        ColumnMapper $column,
        ?bool $isRequiredOverride = null,
        ?string $valueOverride = null,
        string $labelOverride = '',
        string $inputTypeOverride = '',
        string $nameOverride = '',
        string $idOverride = ''
    )
    {
        $columnName = $column->getName();
        $value = ($valueOverride !== null) ? $valueOverride : $column->getDefaultValue();

        // set label
        if ($inputTypeOverride == 'hidden') {
            $label = '';
        } elseif (mb_strlen($labelOverride) > 0) {
            $label = $labelOverride;
        } else {
            $label = ucwords(str_replace('_', ' ', $columnName));
        }

        $fieldInfo = [
            'label' => $label,
            'attributes' => [
                'name' => ($nameOverride) ? $nameOverride : $columnName,
                'id' => ($idOverride) ? $idOverride : $columnName
            ]
        ];

        if ( ($isRequiredOverride !== null && $isRequiredOverride) || DatabaseTableValidation::getDatabaseColumnValidationValue($column, 'required') ) {
            $fieldInfo['attributes']['required'] = 'required';
        }

        // the rest of $formField is derived below based on postgres column type

        if ($column->isNumericType()) {

            $fieldInfo['tag'] = 'input';
            $fieldInfo['attributes']['type'] = 'number';

            if ($column->isIntegerType()) {
                list($fieldInfo['attributes']['min'], $fieldInfo['attributes']['max']) = self::getMinMaxForIntegerTypes($column);
            } else {
                // default for the remaining numeric fields.
                $fieldInfo['attributes']['step'] = '.01';
            }

            // value
            if (mb_strlen($value) > 0) {
                $fieldInfo['attributes']['value'] = $value;
            }
            $formField = new InputField($fieldInfo['label'], FormHelper::getInputFieldAttributes($fieldInfo['attributes']['name'], $fieldInfo['attributes']), FormHelper::getFieldError($fieldInfo['attributes']['name']));

        } else {

            switch ($column->getType()) {

                case 'text':
                    $fieldInfo['tag'] = 'textarea';
                    $fieldInfo['attributes']['cols'] = self::TEXTAREA_COLS;
                    $fieldInfo['attributes']['rows'] = self::TEXTAREA_ROWS;
                    $formField = new TextareaField($value, $fieldInfo['label'], FormHelper::getTextareaFieldAttributes($fieldInfo['attributes']['name'], $fieldInfo['attributes']), FormHelper::getFieldError($fieldInfo['attributes']['name']));
                    break;

                // input fields of various types

                case 'date':
                    $fieldInfo['tag'] = 'input';
                    $fieldInfo['attributes']['type'] = 'date';
                    // value
                    if (mb_strlen($value) > 0) {
                        $fieldInfo['attributes']['value'] = $value;
                    }
                    $formField = new InputField($fieldInfo['label'], FormHelper::getInputFieldAttributes($fieldInfo['attributes']['name'], $fieldInfo['attributes']), FormHelper::getFieldError($fieldInfo['attributes']['name']));
                    break;

                case 'character':
                    $fieldInfo['attributes']['size'] = $column->getCharacterMaximumLength();
                    // continue into cv..

                case 'character varying':
                    $fieldInfo['tag'] = 'input';
                    $fieldInfo['attributes']['type'] = self::getInputType($inputTypeOverride);

                    // must have max defined
                    $fieldInfo['attributes']['maxlength'] = $column->getCharacterMaximumLength();
                    // value
                    if (mb_strlen($value) > 0) {
                        $fieldInfo['attributes']['value'] = $value;
                    }

                    $formField = new InputField($fieldInfo['label'], FormHelper::getInputFieldAttributes($fieldInfo['attributes']['name'], $fieldInfo['attributes']), FormHelper::getFieldError($fieldInfo['attributes']['name']));
                    break;

                case 'USER-DEFINED':
                    $options = [];
                    foreach ($column->getEnumOptions() as $option) {
                        $options[] = new SelectOption($option, $option);
                    }
                    $formField = new SelectField($options, $value, $fieldInfo['label'], $fieldInfo['attributes'], FormHelper::getFieldError($fieldInfo['attributes']['name']));
                    break;

                case 'boolean':
                    // remove required attribute if it exists
                    if (isset($fieldInfo['attributes']['required'])) {
                        unset($fieldInfo['attributes']['required']);
                    }
                    $fieldInfo['tag'] = 'input';
                    $fieldInfo['attributes']['type'] = 'checkbox';
                    if ($value == Postgres::BOOLEAN_TRUE) {
                        $fieldInfo['attributes']['checked'] = 'checked';
                    }
                    $formField = new CheckboxRadioInputField($fieldInfo['label'], FormHelper::getInputFieldAttributes($fieldInfo['attributes']['name'], $fieldInfo['attributes'], false), FormHelper::getFieldError($fieldInfo['attributes']['name']), true);

                    break;

                case 'timestamp without time zone':
                    $fieldInfo['tag'] = 'input';
                    $fieldInfo['attributes']['type'] = 'date';

                    $formField = new InputField($fieldInfo['label'], FormHelper::getInputFieldAttributes($fieldInfo['attributes']['name'], $fieldInfo['attributes']), FormHelper::getFieldError($fieldInfo['attributes']['name']));
                    break;

                default:
                    throw new \Exception('Undefined form field for postgres column type '.$column->getType());
            }
        }

        return $formField;
    }

    public static function getInputType(string $inputTypeOverride = '')
    {
        return (mb_strlen($inputTypeOverride) > 0) ? $inputTypeOverride : 'text';
    }
}


<?php
declare(strict_types=1);

namespace Infrastructure\Utilities;

use Infrastructure\SlimPostgres;
use Infrastructure\Functions;
use Infrastructure\Utilities;
use Entities\Events\EventsTableMapper;

class ErrorHandler
{
    private $logPath;
    private $redirectPage;
    private $emailErrors;
    private $echoErrors;
    private $mailer;
    private $emailTo;
    private $eventsTableMapper;
    private $fatalMessage;

    public function __construct(
        string $logPath,
        ?string $redirectPage,
        bool $echoErrors = false,
        bool $emailErrors = true,
        array $emailTo = [],
        ?PhpMailerService $mailer = null,
        ?string $fatalMessage = null
    )
    {
        if ($emailErrors) {
            if ($mailer === null) {
                throw new \InvalidArgumentException("mailer not set");
            }
            if (count($emailTo) == 0) {
                throw new \InvalidArgumentException("emailTo not set");
            }
        }

        $this->logPath = $logPath;
        $this->redirectPage = $redirectPage;
        $this->emailErrors = $emailErrors;
        $this->echoErrors = $echoErrors;
        $this->mailer = $mailer;
        $this->emailTo = $emailTo;
        $this->fatalMessage = $fatalMessage;
    }

    public function setEventsTableMapper(EventsTableMapper $eventsTableMapper)
    {
        $this->eventsTableMapper = $eventsTableMapper;
    }

    /*
     * 4 ways to handle:
     * -database - always (as long as database and eventsTableMapper properties have been set)
     * -log - always
     * -echo - depends on property
     * -email - depends on property. never email error deets.
     * use @ when calling fns from here to avoid infinite loop
     * die if necessary
     */
    private function handleError(string $messageBody, int $errno, bool $die = false)
    {
        // happens when an expression is prefixed with @ (meaning: ignore errors).
        if (error_reporting() == 0) {
            return;
        }

        $errorMessage = $this->generateMessage($messageBody);

        // log to file. suppress errors to prevent infinite loop.
        @error_log($errorMessage, 3, $this->logPath);

        // echo
        if ($this->echoErrors) {
            echo (Functions::isRunningFromCommandLine()) ? $errorMessage : nl2br($errorMessage, false);
        }

        // log to database
        if (isset($this->eventsTableMapper)) {
            /** determine event type */
            switch ($this->getErrorType($errno)) {
                case 'Core Error':
                case 'Parse Error':
                case 'Fatal Error':
                    $eventType = 'critical';
                    break;
                case 'Core Warning':
                case 'Warning':
                    $eventType = 'warning';
                    break;
                case 'Deprecated':
                case 'Notice':
                    $eventType = 'notice';
                    break;
                default:
                    $eventType = 'error';
            }

            $databaseErrorMessage = explode('Stack Trace:', $errorMessage)[0].'...See PHP error log for further details.';

            /** set events administrator id if logged in */
            if ((isset($_SESSION[SlimPostgres::SESSION_KEY_ADMINISTRATOR_ID]))) {
                $this->eventsTableMapper->setAdministratorId((int) $_SESSION[SlimPostgres::SESSION_KEY_ADMINISTRATOR_ID]);
            }

            // suppress errors to prevent infinite loop.
            @$this->eventsTableMapper->insertEvent('PHP Error', $eventType, ['error' => $databaseErrorMessage]);
        }

        // email
        if ($this->emailErrors) {
            @$this->mailer->send($_SERVER['SERVER_NAME'] . " Error", "Check log file for details.", $this->emailTo);
        }

        if ($die) {
            if ($this->redirectPage != null) {
                if (!is_null($this->fatalMessage)) {
                    $_SESSION[SlimPostgres::SESSION_KEY_NOTICE] = [$this->fatalMessage, 'error'];
                }
                header("Location: $this->redirectPage");
            }
            exit();
        }
    }

    /**
     * used in register_shutdown_function to see if a fatal error has occurred and handle it.
     * note, this does not occur often in php7, as almost all errors are now exceptions and will be caught by the registered exception handler. fatal errors can still occur for conditions like out of memory: https://trowski.com/2015/06/24/throwable-exceptions-and-errors-in-php7/
     * see also https://stackoverflow.com/questions/10331084/error-logging-in-a-smooth-way
     */
    public function shutdownFunction()
    {
        $error = error_get_last(); // note, stack trace is included in $error["message"]

        if (!isset($error)) {
            return;
        }

        $fatalErrorTypes = [E_USER_ERROR, E_ERROR, E_PARSE, E_CORE_ERROR, E_CORE_WARNING, E_COMPILE_ERROR, E_COMPILE_WARNING];
        if (in_array($error["type"], $fatalErrorTypes)) {
            $message = $this->generateMessageBodyCommon($error["type"], $error["message"], $error["file"], $error["line"]);
            $this->handleError($message, $error["type"], true);
        }
    }

    /** @param \Throwable $e
     * catches both Errors and Exceptions
     * create error message and send to handleError
     */
    public function throwableHandler(\Throwable $e)
    {
        $message = $this->generateMessageBodyCommon($e->getCode(), $e->getMessage(), $e->getFile(), $e->getLine());
        $message .= PHP_EOL . "Stack Trace:" . PHP_EOL . $e->getTraceAsString();

        $exitPage = ($e->getCode() == E_ERROR || $e->getCode() == E_USER_ERROR) ? true : false;

        $this->handleError($message, $e->getCode(), $exitPage);
    }

    /**
     * @param int $errno
     * @param string $errstr
     * @param string|null $errfile
     * @param string|null $errline
     * to be registered with php's set_error_handler()
     * called for script errors and trigger_error()
     */
    public function phpErrorHandler(int $errno, string $errstr, string $errfile = null, string $errline = null)
    {
        $message = $this->generateMessageBodyCommon($errno, $errstr, $errfile, $errline) . PHP_EOL . "Stack Trace:". PHP_EOL . $this->getDebugBacktraceString();

        $this->handleError($message, $errno, false);
    }

    private function generateMessage(string $messageBody): string
    {
        $message = "[".date('Y-m-d H:i:s e')."] ";

        if (Functions::isRunningFromCommandLine()) {
            global $argv;
            $message .= gethostname() . PHP_EOL . "Command line: " . $argv[0];
        } else {
            $message .= $_SERVER['SERVER_NAME'] . PHP_EOL . "Web Page: " . $_SERVER['REQUEST_METHOD'] . " " . $_SERVER['REQUEST_URI'];
            if (mb_strlen($_SERVER['QUERY_STRING']) > 0) {
                $message .= "?" . $_SERVER['QUERY_STRING'];
            }
        }
        $message .= PHP_EOL . "$messageBody" . PHP_EOL . PHP_EOL;
        return $message;
    }

    /**
     * @param int $errno
     * @param string $errstr
     * @param string|null $errfile
     * @param null $errline
     * @return string
     * errline seems to be passed in as a string or int depending on where it's coming from
     */
    private function generateMessageBodyCommon(int $errno, string $errstr, string $errfile = null, $errline = null): string
    {
        $message = $this->getErrorType($errno).": ";
        $message .= htmlspecialchars_decode($errstr) . PHP_EOL;

        if (!is_null($errfile)) {
            $message .= "$errfile";
            // note it only makes sense to have line if we have file
            if (!is_null($errline)) {
                $message .= " line: $errline";
            }
        }

        return $message;
    }

    private function getErrorType($errno)
    {
        switch ($errno) {
            case E_ERROR:
            case E_USER_ERROR:
                return 'Fatal Error';
            case E_WARNING:
            case E_USER_WARNING:
                return 'Warning';
            case E_NOTICE:
            case E_USER_NOTICE:
                return 'Notice';
            case E_DEPRECATED:
            case E_USER_DEPRECATED:
                return 'Deprecated';
            case E_PARSE:
                return 'Parse Error';
            case E_CORE_ERROR:
                return 'Core Error';
            case E_CORE_WARNING:
                return 'Core Warning';
            case E_COMPILE_ERROR:
                return 'Compile Error';
            case E_COMPILE_WARNING:
                return 'Compile Warning';
            case E_STRICT:
                return 'Strict';
            case E_RECOVERABLE_ERROR:
                return 'Recoverable Error';
            default:
                return 'Unknown error type';
        }
    }

    // note, formats of various stack traces will differ, as this is not the only way a stack trace is generated. php automatically generates one for fatal errors that are caught in the shutdown function, and throwable exceptions generate one through their getTraceAsString method.
    private function getDebugBacktraceString(): string
    {
        $out = "";

        $dbt = debug_backtrace(~DEBUG_BACKTRACE_PROVIDE_OBJECT & ~DEBUG_BACKTRACE_IGNORE_ARGS);

        // skip the first 2 entries, because they're from this file
        array_shift($dbt);
        array_shift($dbt);

        // these could be in $config, but with the various format note above, that could lead to confusion.
        // also, since the $e->getTraceAsString method shows the full file path, for consistency best to show it here
        $showVendorCalls = true;
        $showFullFilePath = true;
        $startFilePath = '/Src'; // only applies if $showFullFilePath is false
        $showClassNamespace = false;

        foreach ($dbt as $index => $call) {
            $outLine = "#$index:";
            if (isset($call['file'])) {
                if (!$showVendorCalls && strstr($call['file'], '/vendor/')) {
                    break;
                }
                $outLine .= " ";
                if ($showFullFilePath) {
                    $outLine .= $call['file'];
                } else {
                    $fileParts = explode($startFilePath, $call['file']);
                    $outLine .= (isset($fileParts[1])) ? $fileParts[1] : $call['file'];
                }
            }
            if (isset($call['line'])) {
                $outLine .= " [".$call['line']."] ";
            }
            if (isset($call['class'])) {
                $classParts = explode("\\", $call['class']);
                $outLine .= " ";
                $outLine .= ($showClassNamespace) ? $call['class'] : $classParts[count($classParts) - 1];
            }
            if (isset($call['type'])) {
                $outLine .= $call['type'];
            }
            if (isset($call['function'])) {
                $outLine .= $call['function']."()";
            }
            if (isset($call['args'])) {
                $outLine .= " {".Functions::arrayWalkToStringRecursive($call['args'], 0, 1000, PHP_EOL)."}";
            }
            $out .= "$outLine" . PHP_EOL;
        }

        return $out;
    }
}


<?php
declare(strict_types=1);

namespace Infrastructure\Database\Queries;

// helpful when adding many where clauses to a select
class SelectBuilder extends QueryBuilder
{
    private $argCounter;

    function __construct(string $selectClause, string $fromClause, ?array $whereColumnsInfo = null, ?string $orderBy = null)
    {
        parent::__construct("$selectClause $fromClause");
        if ($whereColumnsInfo != null) {
            $this->argCounter = 1;
            $this->addWhereClause($whereColumnsInfo);
        }
        if ($orderBy != null) {
            $this->add(" ORDER BY $orderBy");
        }
    }

    //$whereColumnsInfo [column name sql => ['operators' => [], 'values' => []] ]
    private function addWhereClause(array $whereColumnsInfo)
    {
        foreach ($whereColumnsInfo as $columnNameSql => $columnWhereInfoArray) {

            $operators = $columnWhereInfoArray['operators'];
            $values = $columnWhereInfoArray['values'];

            if (!isset($operators)) {
                throw new \Exception('operators key not set');
            }
            if (!is_array($operators)) {
                throw new \Exception('operators key must be array');
            }
            if (!isset($values)) {
                throw new \Exception('values key not set');
            }
            if (!is_array($values)) {
                throw new \Exception('values key must be array');
            }
            if (count($operators) != count($values)) {
                throw new \Exception("number of operators must equal number of values");
            }
            for ($i = 0; $i < count($operators); ++$i) {
                if (!parent::validateWhereOperator($operators[$i])) {
                    throw new \Exception("invalid whereOperator key ".$operators[$i]."  in whereColumnsInfo for $columnNameSql");
                }

                $this->addWhereColumn($columnNameSql, $operators[$i], $values[$i]);
            }
        }
    }

    private function addWhereColumn(string $columnNameSql, string $whereOperator, $whereValue)
    {
        $whereOperator = strtoupper($whereOperator);
        $sql = ($this->argCounter == 1) ? " WHERE " : " AND ";
        $sql .= "$columnNameSql $whereOperator ";
        if ($whereValue === null) {
            if ($whereOperator != 'IS' && $whereOperator != 'IS NOT') {
                throw new \Exception("Invalid where operator $whereOperator for null where value");
            }
            $sql .= "NULL";
            $this->add($sql);
        } else {
            $sql .= "$$this->argCounter";
            $this->add($sql, $whereValue);
            $this->argCounter++;
        }
    }
}


<?php
declare(strict_types=1);

namespace Infrastructure\Security\Authentication;

use It_All\FormFormer\Form;
use Entities\Administrators\Model\Administrator;
use Entities\Administrators\Model\AdministratorsEntityMapper;
use Entities\Administrators\Model\AdministratorsTableMapper;
use Infrastructure\SlimPostgres;
use Infrastructure\BaseEntity\DatabaseTable\View\DatabaseTableForm;
use Infrastructure\BaseEntity\BaseMVC\View\Forms\FormHelper;

class AuthenticationService
{
    private $maxFailedLogins;
    private $administratorHomeRoutes;

    /** @var string must be one of FAIL_REASONS */
    private $failReason;

    /** @var Administrator if username maps to existing administrator but authentication fails due to incorrect password or inactive administrator, this will be populated */
    private $failAdministrator;

    /** @var string stores username entered when login fails because username is nonexistent */
    private $nonexistentUsername;

    /** @var \SlimPostgres\Administrators\Administrator|null the logged in administrator model or null */
    private $administrator;

    const USERNAME_FIELD = 'username';
    const PASSWORD_FIELD = 'password_hash';

    /** incorrect password, inactive administrator, non-existent administrator */
    const FAIL_REASONS = ['password', 'inactive', 'nonexistent'];

    public function __construct(int $maxFailedLogins, array $administratorHomeRoutes)
    {
        $this->maxFailedLogins = $maxFailedLogins;
        $this->administratorHomeRoutes = $administratorHomeRoutes;
        /** set administrator property to administrator id found in session or null */
        $this->administrator = (isset($_SESSION[SlimPostgres::SESSION_KEY_ADMINISTRATOR_ID])) ? (AdministratorsEntityMapper::getInstance()->getObjectById($_SESSION[SlimPostgres::SESSION_KEY_ADMINISTRATOR_ID])) : null;
    }

    public function getFailReason(): ?string
    {
        return $this->failReason;
    }

    private function setFailReason(string $reason)
    {
        if (!\in_array($reason, self::FAIL_REASONS)) {
            throw new \InvalidArgumentException("Invalid fail reason $reason");
        }

        $this->failReason = $reason;
    }

    public function getFailAdministrator(): ?Administrator
    {
        return $this->failAdministrator;
    }

    private function setFailAdministrator(Administrator $administrator)
    {
        $this->failAdministrator = $administrator;
    }

    public function getFailAdministratorId(): ?int
    {
        if ($this->failAdministrator !== null) {
            return $this->failAdministrator->getId();
        }
        return null;
    }

    /** stores username entered when login fails because username is nonexistent */
    private function setNonexistentUsername(string $username)
    {
        $this->nonexistentUsername = $username;
    }

    public function getNonexistentUsername(): ?string
    {
        return $this->nonexistentUsername;
    }

    /** check that the administrator session is set and that the administrator is still active */
    public function isAuthenticated(): bool
    {
        if ($this->administrator !== null) {
            return $this->isAdministratorActive();
        }
        return false;
    }

    public function attemptLogin(string $username, string $password): bool
    {
        $administratorsEntityMapper = AdministratorsEntityMapper::getInstance();
        // check if administrator exists
        if (null === $administrator = $administratorsEntityMapper->getObjectByUsername($username, false)) {
            $this->loginFailed($username, 'nonexistent', $administrator);
            return false;
        }

        // verify administrator is active
        if (!$administrator->isActive()) {
            $this->loginFailed($username, 'inactive', $administrator);
            return false;
        }

        // verify password for that user
        if (password_verify($password, $administrator->getPasswordHash())) {
            $this->loginSucceeded($username, $administrator);
            return true;
        } else {
            $this->loginFailed($username, 'password', $administrator);
            return false;
        }
    }

    private function incrementNumFailedLogins()
    {
        if (isset($_SESSION[SlimPostgres::SESSION_KEY_NUM_FAILED_LOGINS])) {
            $_SESSION[SlimPostgres::SESSION_KEY_NUM_FAILED_LOGINS]++;
        } else {
            $_SESSION[SlimPostgres::SESSION_KEY_NUM_FAILED_LOGINS] = 1;
        }
    }

    private function loginSucceeded(string $username, Administrator $administrator)
    {
        $_SESSION[SlimPostgres::SESSION_KEY_ADMINISTRATOR_ID] = $administrator->getId();
        unset($_SESSION[SlimPostgres::SESSION_KEY_NUM_FAILED_LOGINS]);
        $this->administrator = $administrator;
    }

    private function loginFailed(string $username, string $reason, ?Administrator $administrator = null)
    {
        $this->setFailReason($reason);
        if (null !== $administrator) {
            $this->setFailAdministrator($administrator);
        } elseif ($reason == 'nonexistent') {
            $this->setNonexistentUsername($username);
        }
        $this->incrementNumFailedLogins();
    }

    public function tooManyFailedLogins(): bool
    {
        return isset($_SESSION[SlimPostgres::SESSION_KEY_NUM_FAILED_LOGINS]) &&
            $_SESSION[SlimPostgres::SESSION_KEY_NUM_FAILED_LOGINS] >= $this->maxFailedLogins;
    }

    public function getNumFailedLogins(): int
    {
        return (isset($_SESSION[SlimPostgres::SESSION_KEY_NUM_FAILED_LOGINS])) ? $_SESSION[SlimPostgres::SESSION_KEY_NUM_FAILED_LOGINS] : 0;
    }

    public function logout()
    {
        unset($_SESSION[SlimPostgres::SESSION_KEY_ADMINISTRATOR_ID]);
    }

    public function getLoginFields(): array
    {
        return [self::USERNAME_FIELD, self::PASSWORD_FIELD];
    }

    /** note there should be different validation rules for logging in than creating users.
     * ie no minlength or character rules on password here
     */
    public function getLoginFieldValidationRules(): array
    {
        return [
            'required' => [[self::USERNAME_FIELD], [self::PASSWORD_FIELD]]
        ];
    }

    /** no need to refresh passwordValue */
    public function getForm(string $csrfNameKey, string $csrfNameValue, string $csrfValueKey, string $csrfValueValue, string $action, ?string $usernameValue = null)
    {
        $administratorsTableMapper = AdministratorsTableMapper::getInstance();

        $fields = [];
        $fields[] = DatabaseTableForm::getFieldFromDatabaseColumn($administratorsTableMapper->getColumnByName(self::USERNAME_FIELD), null, $usernameValue);
        $fields[] = DatabaseTableForm::getFieldFromDatabaseColumn($administratorsTableMapper->getColumnByName(self::PASSWORD_FIELD), null, null, 'Password', 'password');
        $fields[] = FormHelper::getCsrfNameField($csrfNameKey, $csrfNameValue);
        $fields[] = FormHelper::getCsrfValueField($csrfValueKey, $csrfValueValue);
        $fields[] = FormHelper::getSubmitField();

        return new Form($fields, ['method' => 'post', 'action' => $action, 'novalidate' => 'novalidate'], FormHelper::getGeneralError());
    }

    /** returns logged in administrator model object */
    public function getAdministrator(): ?Administrator
    {
        return $this->administrator;
    }

    public function getAdministratorId(): ?int
    {
        if ($this->administrator !== null) {
            return $this->administrator->getId();
        }
        return null;
    }

    public function getAdministratorUsername(): ?string
    {
        if ($this->administrator !== null) {
            return $this->administrator->getUsername();
        }
        return null;
    }

    public function getAdministratorName(): ?string
    {
        if ($this->administrator !== null) {
            return $this->administrator->getName();
        }
        return null;
    }

    public function isAdministratorActive(): bool
    {
        if ($this->administrator !== null) {
            return $this->administrator->getActive();
        }
        return false;
    }

    private function getAdminHomeRouteForAdministratorByRole(): ?string
    {
        foreach ($this->administrator->getRoleNames() as $roleName) {
            if (array_key_exists($roleName, $this->administratorHomeRoutes)) {
                return $this->administratorHomeRoutes[$roleName];
            }
        }
        return null;
    }

    // determine home route: either by username, by role, or default
    public function getAdminHomeRouteForAdministrator(): string
    {
        if (array_key_exists($this->getAdministratorUsername(), $this->administratorHomeRoutes)) {
            return $this->administratorHomeRoutes[$this->getAdministratorUsername()];
        } elseif (null !== $roleRoute = $this->getAdminHomeRouteForAdministratorByRole()) {
            return $roleRoute;
        }

        // default
        return ROUTE_ADMIN_HOME_DEFAULT;
    }

    public function getUsernameFieldName(): string
    {
        return self::USERNAME_FIELD;
    }

    public function getPasswordFieldName(): string
    {
        return self::PASSWORD_FIELD;
    }
}


<?php
declare(strict_types=1);

namespace Infrastructure\Database\DataMappers;

use Infrastructure\Database\DataMappers\ListViewMappers;
use Infrastructure\Database\Queries\SelectBuilder;
use Infrastructure\Functions;

abstract class EntityMapper implements ListViewMappers
{
    protected $defaultSelectColumnsString;

    abstract protected function getFromClause();
    abstract protected function getOrderBy();

    public function setDefaultSelectColumnsString()
    {
        $selectColumnsString = "";
        foreach (static::SELECT_COLUMNS as $name => $columnSql) {
            $selectColumnsString .= "$columnSql, ";
        }
        $this->defaultSelectColumnsString = Functions::removeLastCharsFromString($selectColumnsString, 2);
    }

    /** returns array of records or null */
    public function select(?string $columns = null, ?array $whereColumnsInfo = null, ?string $orderBy = null): ?array
    {
        if ($whereColumnsInfo != null) {
            $this->validateWhere($whereColumnsInfo, static::SELECT_COLUMNS);
        }

        $columns = $columns ?? $this->defaultSelectColumnsString;
        $orderBy = $orderBy ?? $this->getOrderBy();

        $q = new SelectBuilder("SELECT $columns", $this->getFromClause(), $whereColumnsInfo, $orderBy);
        $pgResult = $q->execute();
        if (!$results = pg_fetch_all($pgResult)) {
            $results = null;
        }
        pg_free_result($pgResult);
        return $results;
    }

    // make sure each columnNameSql in columns
    protected function validateWhere(array $whereColumnsInfo)
    {
        foreach ($whereColumnsInfo as $columnNameSql => $columnWhereInfo) {
            if (!in_array($columnNameSql, static::SELECT_COLUMNS)) {
                throw new \Exception("Invalid where column $columnNameSql");
            }
        }
    }

    public function getCountSelectColumns(): int
    {
        return count(static::SELECT_COLUMNS);
    }
}


<?php
declare(strict_types=1);

namespace Infrastructure\BaseEntity\DatabaseTable\View;

use Infrastructure\SlimPostgres;
use Infrastructure\BaseEntity\BaseMVC\View\AdminListView;
use Infrastructure\BaseEntity\BaseMVC\View\InsertUpdateViews;
use Infrastructure\BaseEntity\BaseMVC\View\ResponseUtilities;
use Infrastructure\BaseEntity\DatabaseTable\View\DatabaseTableForm;
use Infrastructure\Database\DataMappers\TableMapper;
use Infrastructure\BaseEntity\BaseMVC\View\Forms\FormHelper;
use Slim\Container;
use Slim\Http\Request;
use Slim\Http\Response;

// a list view class for a single database table
abstract class DatabaseTableView extends AdminListView implements InsertUpdateViews
{
    use ResponseUtilities;

    protected $routePrefix;
    protected $tableMapper;

    public function __construct(Container $container, TableMapper $tableMapper, string $routePrefix, bool $addDeleteColumnToListView = true, string $listViewTemplate = 'Admin/Lists/resultsList.php')
    {
        $this->tableMapper = $tableMapper;
        $this->routePrefix = $routePrefix;

        parent::__construct($container, $routePrefix, SlimPostgres::getRouteName(true, $routePrefix, 'index'), $this->tableMapper, SlimPostgres::getRouteName(true, $routePrefix, 'index.reset'), $listViewTemplate);

        $this->setInsert();
        $this->setUpdate();
        $this->setDelete();
    }

    /** overrides in order to get objects and send to indexView */
    public function routeIndex(Request $request, Response $response, $args)
    {
        return $this->indexView($response);
    }

    public function routeGetInsert(Request $request, Response $response, $args)
    {
        return $this->insertView($request, $response, $args);
    }

    /** this can be called for both the initial get and the posted form if errors exist (from controller) */
    public function insertView(Request $request, Response $response, $args)
    {
        $formFieldData = ($request->isPost() && isset($args[SlimPostgres::USER_INPUT_KEY])) ? $args[SlimPostgres::USER_INPUT_KEY] : null;

        $form = new DatabaseTableForm($this->tableMapper, $this->router->pathFor(SlimPostgres::getRouteName(true, $this->routePrefix, 'insert', 'post')), $this->csrf->getTokenNameKey(), $this->csrf->getTokenName(), $this->csrf->getTokenValueKey(), $this->csrf->getTokenValue(), 'insert', $formFieldData);

        FormHelper::unsetSessionFormErrors();

        return $this->view->render(
            $response,
            'Admin/form.php',
            [
                'title' => 'Insert '. $this->tableMapper->getFormalTableName(false),
                'form' => $form,
                'navigationItems' => $this->navigationItems
            ]
        );
    }

    public function routeGetUpdate(Request $request, Response $response, $args)
    {
        return $this->updateView($request, $response, $args);
    }

    /** this can be called for both the initial get and the posted form if errors exist (from controller) */
    public function updateView(Request $request, Response $response, $args)
    {
        $primaryKeyValue = $args[ROUTEARG_PRIMARY_KEY];

        // make sure there is a record for the mapper
        if (null === $record = $this->tableMapper->selectForPrimaryKey($primaryKeyValue)) {
            return $this->databaseRecordNotFound($response, $primaryKeyValue, $this->tableMapper, 'update');
        }

        $formFieldData = ($request->isPut() && isset($args[SlimPostgres::USER_INPUT_KEY])) ? $args[SlimPostgres::USER_INPUT_KEY] : $record;

        $form = new DatabaseTableForm($this->tableMapper, $this->router->pathFor(SlimPostgres::getRouteName(true, $this->routePrefix, 'update', 'put'), [ROUTEARG_PRIMARY_KEY => $primaryKeyValue]), $this->csrf->getTokenNameKey(), $this->csrf->getTokenName(), $this->csrf->getTokenValueKey(), $this->csrf->getTokenValue(), 'update', $formFieldData);

        FormHelper::unsetSessionFormErrors();

        return $this->view->render(
            $response,
            'Admin/form.php',
            [
                'title' => 'Update ' . $this->tableMapper->getFormalTableName(false),
                'form' => $form,
                'primaryKey' => $primaryKeyValue,
                'navigationItems' => $this->navigationItems,
                'hideFocus' => true
            ]
        );
    }
}


<?php

declare(strict_types=1);

namespace Infrastructure;

// A place to store php helper functions, not App-related functions
class Functions
{
    public static function validateJson(string $json): bool
    {
        return json_decode($json) !== null;
    }

    public static function destroySession()
    {
        /** Unset all of the session variables */
        $_SESSION = [];

        /** Destroy the session */
        if (ini_get("session.use_cookies")) {
            $params = session_get_cookie_params();
            setcookie(session_name(), '', time() - 42000,
                $params["path"], $params["domain"],
                $params["secure"], $params["httponly"]
            );
        }
        session_destroy();
    }

    /** string length must be >= numChars */
    public static function removeLastCharsFromString(string $input, int $numChars = 1): string
    {
        if ($numChars > strlen($input)) {
            throw new \InvalidArgumentException("Cannot remove $numChars from $input");
        }
        return substr($input, 0, strlen($input) - $numChars);
    }

    /**
     * converts array to string
     * @param array $arr
     * @param int $level
     * @return string
     */
    public static function arrayWalkToStringRecursive(array $arr, int $level = 0, int $maxLevel = 1000, $newLine = '<br>'): string
    {
        $out = "";
        $tabs = " ";
        for ($i = 0; $i < $level; $i++) {
            $tabs .= " ^"; // use ^ to denote another level
        }
        foreach ($arr as $k => $v) {
            $out .= "$newLine$tabs$k: ";
            if (is_object($v)) {
                $out .= 'object type: '.get_class($v);
            } elseif (is_array($v)) {
                $newLevel = $level + 1;
                if ($newLevel > $maxLevel) {
                    $out .= ' array too deep, quitting';
                } else {
                    $out .= self::arrayWalkToStringRecursive($v, $newLevel, $maxLevel, $newLine);
                }
            } else {
                $out .= (string)$v;
            }
        }
        return $out;
    }

    /**
     * Returns true if the current script is running from the command line (ie, CLI).
     */
    public static function isRunningFromCommandLine(): bool
    {
        return php_sapi_name() == 'cli';
    }
}


<?php
declare(strict_types=1);

namespace Entities\Administrators\View\Forms;

use Slim\Container;
use Entities\Administrators\Model\Administrator;
use Infrastructure\BaseEntity\BaseMVC\View\Forms\FormHelper;

class AdministratorUpdateForm extends AdministratorForm
{
    // use \SlimPostgres\Forms\FormUtilities;

    public function __construct(string $formAction, Container $container, array $fieldValues = [])
    {
        $this->arePasswordFieldsRequired = false; // allow to leave blank to keep existing ps
        parent::__construct($formAction, $container, true, $fieldValues);
        parent::setPasswordLabel('[leave blank to keep existing password]');
    }

    /** note could subclass here for initial get form but simpler to just add this fn */
    public function setFieldValuesToAdministrator(Administrator $administrator)
    {
        parent::setFieldValues([
            parent::NAME_FIELD_NAME => $administrator->getName(),
            parent::USERNAME_FIELD_NAME => $administrator->getUsername(),
            parent::PASSWORD_FIELD_NAME => '',
            parent::PASSWORDCONFIRM_FIELD_NAME => '',
            parent::ROLES_FIELDSET_NAME => $administrator->getRoleIds(),
            parent::ACTIVE_FIELD_NAME => $administrator->getActive(),
        ]);
    }

    protected function getNodes(): array
    {
        $nodes = parent::getNodes();
        $nodes[] = FormHelper::getPutMethodField();
        return $nodes;
    }
}


<?php
declare(strict_types=1);

namespace Entities\Roles\Model;

use Infrastructure\Database\DataMappers\TableMapper;
use It_All\FormFormer\Fields\SelectField;
use It_All\FormFormer\Fields\SelectOption;
use Exceptions;

// fake Singleton with public constructor
final class RolesTableMapper extends TableMapper
{
    /** @var array role_id => role. instead of querying the database whenever a role is needed. all roles are loaded on construction and are then easily retrievable. note that if in the future any role records were to change via javascript this array would require updating through the setRoles method in order to stay in sync. */
    private $roles;

    const TABLE_NAME = 'roles';
    const ADMINISTRATORS_JOIN_TABLE_NAME = 'administrator_roles';
    const PERMISSIONS_JOIN_TABLE_NAME = 'roles_permissions';

    public static function getInstance()
    {
        static $instance = null;
        if ($instance === null) {
            $instance = new RolesTableMapper();
        }
        return $instance;
    }

    public function __construct()
    {
        parent::__construct(self::TABLE_NAME, 'id, role, created', 'id');
        $this->setRoles();
    }

    // this is called by constructor and also should be called after a change to roles from single page app to reset them.
    public function setRoles()
    {
        $this->roles = [];
        if (null !== $records = $this->select()) {
            foreach ($records as $record) {
                $this->roles[(int) $record['id']] = $record['role'];
            }
        }
    }

    public function getRoleIdForRole(string $roleSearch): ?int
    {
        if (!$roleId = array_search($roleSearch, $this->roles)) {
            return null;
        }
        return $roleId;
    }

    public function getRoleForRoleId(int $roleIdSearch): string
    {
        if (!array_key_exists($roleIdSearch, $this->roles)) {
            throw new \InvalidArgumentException("Invalid role id searched: $roleIdSearch");
        }
        return $this->roles[$roleIdSearch];
    }

    public function buildRole(int $id, string $role, \DateTimeImmutable $created): Role
    {
        return new Role($id, $role, $created);
    }

    public function getObjectById(int $primaryKey): ?Role
    {
        if (null !== $record = $this->selectForPrimaryKey($primaryKey)) {
            return $this->buildRole((int) $record['id'], $record['role'], new \DateTimeImmutable($record['created']));
        }

        return null;
    }

    public function getRoles(): array
    {
        return $this->roles;
    }

    public function getRoleNames(): array
    {
        return array_values($this->roles);
    }

    public function getIdSelectField(array $fieldAttributes, string $fieldLabel = 'Role', ?int $selectedOption, ?string $fieldError)
    {
        // validate a provided selectedOption by verifying it is a valid role
        $selectedOptionValid = ($selectedOption === null) ? true : false;

        // create the options
        $rolesOptions = [];
        foreach ($this->getRoles() as $roleId => $roleName) {
            $rolesOptions[] = new SelectOption($roleName, (string) $roleId);
            if (!$selectedOptionValid && $roleId == $selectedOption) {
                $selectedOptionValid = true;
            }
        }

        if (!$selectedOptionValid) {
            throw new \Exception("Invalid selected option $selectedOption does not exist in roles");
        }

        // alter fieldError to send to SelectField constructor as empty string if null

        if ($fieldError === null) {
            $fieldError = '';
        }

        return new SelectField($rolesOptions, (string) $selectedOption, $fieldLabel, $fieldAttributes, $fieldError);
    }

    public function isUpdatable(): bool
    {
        return true;
    }

    public function isDeletable(): bool
    {
        return true;
    }

    // override for validation
    public function deleteByPrimaryKey($primaryKeyValue, ?string $returnField = null): ?string
    {
        // make sure role exists and is deletable
        if (null === $role = $this->getObjectById((int) $primaryKeyValue)) {
            throw new Exceptions\QueryResultsNotFoundException("Role not found: id $primaryKeyValue");
        }

        if (!$role->isDeletable((int) $primaryKeyValue)) {
            throw new Exceptions\UnallowedActionException("Role in use: id $primaryKeyValue");
        }

        return parent::deleteByPrimaryKey($primaryKeyValue, $returnField);
    }

    /** selects roles and converts recordset to array of objects and return */
    public function getObjects(array $whereColumnsInfo = null): array
    {
        $roles = [];

        if (null !== $records = $this->select("*", $whereColumnsInfo)) {
            foreach ($records as $record) {
                $roles[] = $this->buildRole((int) $record['id'], $record['role'], new \DateTimeImmutable($record['created']));
            }
        }

        return $roles;
    }

    /** all roles must exist or exception thrown */
    public function getRoleIdsForRoles(array $roles): array
    {
        if (count($roles) == 0) {
            throw new \InvalidArgumentException("Roles array must be populated.");
        }

        $roleIds = [];

        foreach ($roles as $role) {
            if (null === $roleIds[] = $this->getRoleIdForRole($role)) {
                throw new \InvalidArgumentException("Role $role does not exist");
            }
        }

        return $roleIds;
    }

    public function getTopRoleId(): int
    {
        foreach ($this->roles as $roleId => $roleName) {
            if ($roleName == TOP_ROLE) {
                return $roleId;
            }
        }
    }
}


<?php
declare(strict_types=1);

namespace Infrastructure\BaseEntity\BaseMVC\Controller;

use Infrastructure\SlimPostgres;
use Infrastructure\Database\Postgres;
use Infrastructure\Database\Queries\QueryBuilder;
use Infrastructure\BaseEntity\BaseMVC\View\Forms\FormHelper;
use Slim\Container;
use Slim\Http\Request;
use Slim\Http\Response;
use Infrastructure\BaseEntity\BaseMVC\View\AdminListView;

abstract class BaseController
{
    protected $container; // dependency injection container
    protected $requestInput; // user input data

    public function __construct(Container $container)
    {
        $this->container = $container;
    }

    public function __get($name)
    {
        return $this->container->{$name};
    }

    protected function setRequestInput(Request $request, array $fieldNames, array $booleanFieldNames = [])
    {
        $this->requestInput = [];

        foreach ($fieldNames as $fieldName) {
            /** will be null if $fieldName does not exist in parsed body
             *  note, this is true of array fields with no checked options
             */
            $this->requestInput[$fieldName] = $request->getParsedBodyParam($fieldName);

            /** trim if necessary depending on config */
            if (is_string($this->requestInput[$fieldName]) && $this->settings['trimAllUserInput']) {
                $this->requestInput[$fieldName] = trim($this->requestInput[$fieldName]);
            }

            /** handle boolean fields
             *  if not set convert to false
             *  if 'on' convert to true
             */
            if (in_array($fieldName, $booleanFieldNames)) {
                if ($this->requestInput[$fieldName] === null) {
                    $this->requestInput[$booleanFieldName] = Postgres::BOOLEAN_FALSE;
                } elseif ($this->requestInput[$fieldName] === 'on') {
                    $this->requestInput[$booleanFieldName] = Postgres::BOOLEAN_TRUE;
                } else {
                    throw new \Exception('Invalid value for boolean input var '.$booleanFieldName.': '.$this->requestInput[$booleanFieldName]);
                }
            }
        }
    }

    /** called by children for posted filter form entry methods */
    protected function setIndexFilter(Request $request, Response $response, $args, array $listViewColumns, AdminListView $view)
    {
        $this->setRequestInput($request, [$view->getSessionFilterFieldKey()]);

        if (!isset($this->requestInput[$view->getSessionFilterFieldKey()])) {
            throw new \Exception("session filter input must be set");
        }

        $this->storeFilterFieldValueInSession($view);

        /** if there is an error in the filter field getFilterColumns will set the form error and return null */
        if (null !== $filterColumnsInfo = $this->getFilterColumns($view->getSessionFilterFieldKey(), $listViewColumns)) {
            $this->storeFilterColumnsInfoInSession($filterColumnsInfo, $view);
        }
    }

    private function storeFilterColumnsInfoInSession(array $filterColumnsInfo, AdminListView $view)
    {
        $_SESSION[SlimPostgres::SESSION_KEY_ADMIN_LIST_VIEW_FILTER][$view->getFilterKey()][$view::SESSION_FILTER_COLUMNS_KEY] = $filterColumnsInfo;
    }

    private function storeFilterFieldValueInSession(AdminListView $view)
    {
        /** store entered field value in session so form field can be repopulated */
        $_SESSION[SlimPostgres::SESSION_KEY_ADMIN_LIST_VIEW_FILTER][$view->getFilterKey()][$view::SESSION_FILTER_VALUE_KEY] = $this->requestInput[$view->getSessionFilterFieldKey()];
    }

    // parse the where filter field into [ column name => [operators, values] ]
    protected function getFilterColumns(string $filterFieldName, array $listViewColumns): ?array
    {
        $filterColumnsInfo = [];
        $filterParts = explode(",", $this->requestInput[$filterFieldName]);
        if (mb_strlen($filterParts[0]) == 0) {
            FormHelper::setFieldErrors([$filterFieldName => 'Not Entered']);
            return null;
        } else {

            foreach ($filterParts as $whereFieldOperatorValue) {
                //field:operator:value
                $whereFieldOperatorValueParts = explode(":", $whereFieldOperatorValue);
                if (count($whereFieldOperatorValueParts) != 3) {
                    FormHelper::setFieldErrors([$filterFieldName => 'Malformed']);
                    return null;
                }
                $columnName = trim($whereFieldOperatorValueParts[0]);
                $whereOperator = strtoupper(trim($whereFieldOperatorValueParts[1]));
                $whereValue = trim($whereFieldOperatorValueParts[2]);

                // validate the column name
                if (isset($listViewColumns[strtolower($columnName)])) {
                    $columnNameSql = $listViewColumns[strtolower($columnName)];
                } else {
                    FormHelper::setFieldErrors([$filterFieldName => "$columnName column not found"]);
                    return null;
                }

                // validate the operator
                if (!QueryBuilder::validateWhereOperator($whereOperator)) {
                    FormHelper::setFieldErrors([$filterFieldName => "Invalid Operator $whereOperator"]);
                    return null;
                }

                // null value only valid with IS and IS NOT operators
                if (strtolower($whereValue) == 'null') {
                    if ($whereOperator != 'IS' && $whereOperator != 'IS NOT') {
                        FormHelper::setFieldErrors([$filterFieldName => "Mismatched null, $whereOperator"]);
                        return null;
                    }
                    $whereValue = null;
                }

                if (!isset($filterColumnsInfo[$columnNameSql])) {
                    $filterColumnsInfo[$columnNameSql] = [];
                    $filterColumnsInfo[$columnNameSql]['operators'] = [];
                    $filterColumnsInfo[$columnNameSql]['values'] = [];
                }
                $filterColumnsInfo[$columnNameSql]['operators'][] = $whereOperator;
                $filterColumnsInfo[$columnNameSql]['values'][] = $whereValue;
            }
        }

        return $filterColumnsInfo;
    }

    /**
     * @param string $emailTo must be in $settings['emails'] array or error will be inserted to events
     * @param string $mainBody
     * @param bool $addEventLogStatement defaults true, if true adds 'See event log for details' after $mainBody
     * @param bool $throwExceptionOnError defaults false, if true exception is thrown if no match for $emailTo
     */
    protected function sendEventNotificationEmail(string $emailTo, string $mainBody, ?string $subjectEnd = '', ?bool $addEventLogStatement = true, ?bool $throwExceptionOnError = false)
    {
        if ($this->mailer !== null && $emailTo !== null) {
            $settings = $this->container->get('settings');
            if (isset($settings['emails'][$emailTo])) {
                $emailBody = $mainBody;
                if ($addEventLogStatement) {
                    $emailBody .= PHP_EOL . "See event log for details.";
                }
                $this->mailer->send(
                    $_SERVER['SERVER_NAME'] . " Event $subjectEnd",
                    $emailBody,
                    [$settings['emails'][$emailTo]]
                );
            } else {
                $this->events->insertError(EVENT_EMAIL_NOT_FOUND, ['email' => $emailTo]);
                if ($throwExceptionOnError) {
                    throw new \InvalidArgumentException("Email Not Found: $emailTo");
                }
            }
        }
    }
}


<?php
declare(strict_types=1);

namespace Entities\Permissions\Model;

use Infrastructure\Database\DataMappers\EntityMapper;
use Entities\Permissions\Model\Permission;
use Entities\Roles\Model\RolesTableMapper;
use Entities\Roles\Model\Role;
use Infrastructure\Database\Queries\QueryBuilder;
use Infrastructure\Database\Queries\SelectBuilder;
use Infrastructure\Database\Postgres;
use Exceptions;

// Singleton
final class PermissionsEntityMapper extends EntityMapper
{
    private $permissionsTableMapper;

    const TABLE_NAME = 'permissions';
    const ROLES_TABLE_NAME = 'roles';
    const ROLES_JOIN_TABLE_NAME = 'roles_permissions';

    const SELECT_COLUMNS = [
        'id' => self::TABLE_NAME . '.id',
        'title' => self::TABLE_NAME . '.title',
        'description' => self::TABLE_NAME . '.description',
        'roleId' => self::ROLES_JOIN_TABLE_NAME . '.role_id',
        'roles' => self::ROLES_TABLE_NAME . '.role',
        'active' => self::TABLE_NAME . '.active',
        'created' => self::TABLE_NAME . '.created',
    ];

    const ORDER_BY_COLUMN_NAME = 'title';

    public static function getInstance()
    {
        static $instance = null;
        if ($instance === null) {
            $instance = new PermissionsEntityMapper();
        }
        return $instance;
    }

    protected function __construct()
    {
        $this->permissionsTableMapper = PermissionsTableMapper::getInstance();
        parent::setDefaultSelectColumnsString();
    }

    public function getListViewTitle(): string
    {
        return $this->permissionsTableMapper->getFormalTableName();
    }

    public function getInsertTitle(): string
    {
        return "Insert Permission";
    }

    public function getUpdateTitle(): string
    {
        return "Update Permission";
    }

    public function getUpdateColumnName(): ?string
    {
        return $this->permissionsTableMapper->getUpdateColumnName();
    }

    public function getListViewSortColumn(): ?string
    {
        return $this->permissionsTableMapper->getOrderByColumnName();
    }

    public function getListViewSortAscending(): bool
    {
        return $this->permissionsTableMapper->getOrderByAsc();
    }

    /** any validation should be done prior */
    public function create(string $title, ?string $description, ?array $roleIds, bool $active): int
    {
        // add top role if not already there, as it is assigned to all permissions
        if ($roleIds === null || !in_array(TOP_ROLE, $roleIds)) {
            $roleIds[] = (RolesTableMapper::getInstance())->getTopRoleId();
        }

        // insert administrator then administrator_roles in a transaction
        pg_query("BEGIN");

        try {
            $permissionId = $this->permissionsTableMapper->callInsert($title, $description, $active);
        } catch (Exceptions\QueryFailureException $e) {
            pg_query("ROLLBACK");
            throw $e;
        }

        try {
            $this->insertPermissionRoles((int) $permissionId, $roleIds);
        } catch (Exceptions\QueryFailureException $e) {
            pg_query("ROLLBACK");
            throw $e;
        }

        pg_query("COMMIT");
        return $permissionId;
    }

    private function insertPermissionRoles(int $permissionId, array $roleIds): array
    {
        $permissionRoleIds = [];
        foreach ($roleIds as $roleId) {
            $permissionRoleIds[] = $this->doInsertPermissionRole($permissionId, (int) $roleId);
        }
        return $permissionRoleIds;
    }

    public function doInsertPermissionRole(int $permissionId, int $roleId)
    {
        $q = new QueryBuilder("INSERT INTO ".self::ROLES_JOIN_TABLE_NAME." (permission_id, role_id) VALUES($1, $2)", $permissionId, $roleId);
        return $q->executeWithReturnField('id');
    }

    // returns key of results array for matching 'id' key, null if not found
    // note, careful when checking return value as 0 can be returned (evaluates to false)
    private function getPermissionsArrayKeyForId(array $permissionsArray, int $id): ?int
    {
        foreach ($permissionsArray as $key => $permission) {
            if ($permission['id'] == $id) {
                return $key;
            }
        }

        return null;
    }

    /** returns array of records or null */
    public function select(?string $columns = "*", ?array $whereColumnsInfo = null, ?string $orderBy = null): ?array
    {
        if ($whereColumnsInfo != null) {
            $this->validateWhere($whereColumnsInfo);
        }

        /** simply adding to the where clause below with the roles field will yield incomplete results, as not all roles for an administrator will be selected, so the subquery fn is called */
        if (is_array($whereColumnsInfo) && array_key_exists('roles.role', $whereColumnsInfo)) {
            return $this->selectWithRoleSubquery($columns, $whereColumnsInfo, $orderBy);
        }

        $columns = $columns ?? $this->defaultSelectColumnsString;
        $orderBy = $orderBy ?? $this->getOrderBy();

        $q = new SelectBuilder("SELECT $columns", $this->getFromClause(), $whereColumnsInfo, $orderBy);
        $pgResult = $q->execute();
        if (!$results = pg_fetch_all($pgResult)) {
            $results = null;
        }
        pg_free_result($pgResult);
        return $results;
    }

    /** to filter the permissions with certain roles and return all the roles the permissions have */
    private function selectWithRoleSubquery(?string $columns = null, array $whereColumnsInfo = null, string $orderBy = null)
    {
        $columns = $columns ?? $this->defaultSelectColumnsString;

        /** start subquery */
        $q = new QueryBuilder("SELECT $columns ".$this->getFromClause()." WHERE permissions.id IN (SELECT permissions.id FROM permissions JOIN roles_permissions ON permissions.id=roles_permissions.permission_id JOIN roles ON roles_permissions.role_id=roles.id WHERE");

        /** build subquery */
        $opCount = 0;
        foreach ($whereColumnsInfo['roles.role']['operators'] as $op) {
            $sqlVarCount = $opCount + 1;
            if ($opCount > 0) {
                $q->add(" OR ");
            }
            $q->add(" roles.role $op $$sqlVarCount", $whereColumnsInfo['roles.role']['values'][$opCount]);
            ++$opCount;
        }
        $q->add(") ORDER BY " . $this->getOrderBy());

        return $q->execute();
    }

    /** returns array of results instead of recordset */
    private function selectArray(?string $selectColumns = null, array $whereColumnsInfo = null, string $orderBy = null): array
    {
        $columns = $selectColumns ?? $this->defaultSelectColumnsString;

        $permissionsArray = []; // populate with 1 entry per permission with an array of role objects

        if(null !== $records = $this->select($columns, $whereColumnsInfo, $orderBy)) {
            $rolesTableMapper = RolesTableMapper::getInstance();
            foreach ($records as $record) {
                // either add new permission or just new role based on whether permission already exists
                if (null === $key = $this->getPermissionsArrayKeyForId($permissionsArray, (int) $record['id'])) {
                    $permissionsArray[] = [
                        'id' => (int) $record['id'],
                        'title' => $record['title'],
                        'description' => $record['description'],
                        'roles' => [$rolesTableMapper->getObjectById((int) $record['role_id'])],
                        'active' => Postgres::convertPostgresBoolToBool($record['active']),
                        'created' => new \DateTimeImmutable($record['created']),
                    ];
                } else {
                    array_push($permissionsArray[$key]['roles'], $rolesTableMapper->getObjectById((int) $record['role_id']));
                }
            }
        }

        return $permissionsArray;
    }

    /** permissions joined with role_permissions. note that every permission must have at least 1 role assigned */
    protected function getFromClause(): string
    {
        return "FROM ".self::TABLE_NAME." JOIN ".self::ROLES_JOIN_TABLE_NAME." ON ".self::TABLE_NAME.".id = ".self::ROLES_JOIN_TABLE_NAME.".permission_id JOIN ".self::ROLES_TABLE_NAME." ON ".self::ROLES_JOIN_TABLE_NAME.".role_id=".self::ROLES_TABLE_NAME.".id";
    }

    protected function getOrderBy(): string
    {
        return SELF::TABLE_NAME . "." . SELF::ORDER_BY_COLUMN_NAME . ", " . SELF::ROLES_TABLE_NAME . ".id";
    }

    private function getObject(array $whereColumnsInfo): ?Permission
    {
        $q = new SelectBuilder("SELECT ".$this->defaultSelectColumnsString, $this->getFromClause(), $whereColumnsInfo, $this->getOrderBy());
        $pgResults = $q->execute();
        if (pg_numrows($pgResults) > 0) {
            // there will be 1 record for each role
            $roles = [];
            $rolesTableMapper = RolesTableMapper::getInstance();
            while ($row = pg_fetch_assoc($pgResults)) {
                $roles[] = $rolesTableMapper->getObjectById((int) $row['role_id']);
                $lastRow = $row;
            }
            return $this->buildPermission((int) $lastRow['id'], $lastRow['title'], $lastRow['description'], Postgres::convertPostgresBoolToBool($lastRow['active']), new \DateTimeImmutable($lastRow['created']), $roles);
        } else {
            return null;
        }
    }

    /** note roles array is validated in Permission constructor */
    public function buildPermission(int $id, string $title, ?string $description, bool $active, \DateTimeImmutable $created, array $roles): Permission
    {
        return new Permission($id, $title, $description, $active, $created, $roles);
    }

    public function getObjectById(int $id): ?Permission
    {
        $whereColumnsInfo = [
            'permissions.id' => [
                'operators' => ["="],
                'values' => [$id]
            ]
        ];
        return $this->getObject($whereColumnsInfo);
    }

    public function getObjectByTitle(string $title, ?bool $active = null): ?Permission
    {
        $whereColumnsInfo = [
            'permissions.title' => [
                'operators' => ["="],
                'values' => [$title]
            ]
        ];
        if ($active !== null) {
            $whereColumnsInfo['permissions.active'] = [
                'operators' => ["="],
                'values' => [Postgres::convertBoolToPostgresBool($active)]
            ];
        }
        return $this->getObject($whereColumnsInfo);
    }

    /** selects and converts recordset to array of objects and return */
    public function getObjects(array $whereColumnsInfo = null, string $orderBy = null): array
    {
        $permissions = [];
        foreach ($this->selectArray(null, $whereColumnsInfo, $orderBy) as $permissionArray) {
            $permissions[] = $this->buildPermission($permissionArray['id'], $permissionArray['title'], $permissionArray['description'], $permissionArray['active'], $permissionArray['created'], $permissionArray['roles']);
        }

        return $permissions;
    }

    public function doUpdate(int $permissionId, array $changedFields)
    {
        $changedPermissionFields = $this->permissionsTableMapper->getChangedFields($changedFields);

        pg_query("BEGIN");
        if (count($changedPermissionFields) > 0) {
            try {
                $this->permissionsTableMapper->updateByPrimaryKey($changedPermissionFields, $permissionId, false);
            } catch (\Exception $e) {
                pg_query("ROLLBACK");
                throw $e;
            }
        }
        if (isset($changedFields['roles']['add'])) {
            foreach ($changedFields['roles']['add'] as $addRoleId) {
                try {
                    $this->doInsertPermissionRole($permissionId, (int) $addRoleId);
                } catch (\Exception $e) {
                    pg_query("ROLLBACK");
                    throw $e;
                }
            }
        }
        if (isset($changedFields['roles']['remove'])) {
            foreach ($changedFields['roles']['remove'] as $deleteRoleId) {
                /** never remove top role from a permission */
                $deleteRole = (RolesTableMapper::getInstance())->getObjectById($deleteRoleId);
                if (!$deleteRole->isTop()) {
                    try {
                        $roleDeleteResult = $this->doDeletePermissionRole($permissionId, (int) $deleteRoleId);
                    } catch (\Exception $e) {
                        pg_query("ROLLBACK");
                        throw $e;
                    }
                    if ($roleDeleteResult === null) {
                        pg_query("ROLLBACK");
                        throw new \Exception("Role not found for permission during delete attempt");
                    }
                }
            }
        }
        pg_query("COMMIT");
    }

    /** returns deleted title */
    public function delete(int $id): string
    {
        // make sure there is a permission for the primary key
        if (null === $permission = $this->getObjectById($id)) {
            throw new Exceptions\QueryResultsNotFoundException();
        }

        $this->doDeleteTransaction($id);

        $title = $permission->getTitle();
        unset($permission);

        return $title;
    }

    /** any necessary validation should be performed prior to calling */
    private function doDeleteTransaction(int $permissionId)
    {
        pg_query("BEGIN");
        // $this->doDeletePermissionRoles($permissionId);

        try {
            $this->doDeletePermissionRoles($permissionId);
        } catch (\Exception $e) {
            pg_query("ROLLBACK");
            throw $e;
        }

        try {
            $this->permissionsTableMapper->delete($permissionId);
        } catch (\Exception $e) {
            pg_query("ROLLBACK");
            throw $e;
        }
        // $this->permissionsTableMapper->delete($permissionId);
        pg_query("COMMIT");
    }

    /** deletes the record(s) in the join table */
    private function doDeletePermissionRoles(int $permissionId)
    {
        $q = new QueryBuilder("DELETE FROM ".self::ROLES_JOIN_TABLE_NAME." WHERE permission_id = $1", $permissionId);
        $q->execute();
    }

    /** deletes a record in the join table and returns the id */
    /** returns null if not found */
    private function doDeletePermissionRole(int $permissionId, int $roleId): ?int
    {
        $q = new QueryBuilder("DELETE FROM ".self::ROLES_JOIN_TABLE_NAME." WHERE permission_id = $1 AND role_id = $2", $permissionId, $roleId);
        try {
            $deletedId = $q->executeWithReturnField('id');
        } catch (QueryResultsNotFoundException $e) {
            return null;
        }
        return (int) $deletedId;
    }
}


<?php
declare(strict_types=1);

namespace Infrastructure\BaseEntity\BaseMVC\View;

use Infrastructure\SlimPostgres;
use Exceptions\QueryFailureException;
use Infrastructure\Database\Queries\QueryBuilder;
use Infrastructure\Database\DataMappers\ListViewMappers;
use Infrastructure\BaseEntity\BaseMVC\View\Forms\FormHelper;
use Slim\Container;
use Slim\Http\Request;
use Slim\Http\Response;

abstract class AdminListView extends AdminView
{
    /** user entered value of the filter field. subkey of request input */
    protected $sessionFilterFieldKey;

    protected $filterKey;

    protected $indexRoute;
    protected $mapper;

    /** defaults to resultsList but objectsList can be passed in */
    protected $template;
    protected $filterResetRoute;

    /** false or ['text' => {link text}, 'route' => {route}] */
    protected $insertLinkInfo;

    protected $updatesPermitted;
    protected $updateColumn;
    protected $updateRoute;
    protected $deletesPermitted;
    protected $deleteRoute;

    const SESSION_FILTER_COLUMNS_KEY = 'columns';
    const SESSION_FILTER_VALUE_KEY = 'value';

    public function __construct(Container $container, string $filterFieldsPrefix, string $indexRoute, ListViewMappers $mapper, string $filterResetRoute, string $template = 'Admin/Lists/resultsList.php')
    {
        parent::__construct($container);
        $this->sessionFilterFieldKey = $filterFieldsPrefix . 'Filter';
        $this->filterKey = $filterFieldsPrefix;

        $this->indexRoute = $indexRoute;
        $this->mapper = $mapper;
        $this->template = $template;
        $this->filterResetRoute = $filterResetRoute;

        /** initialize insert, update, delete properties to disallow. children can override by calling setInsert, setUpdate, setDelete methods */
        $this->insertLinkInfo = null;
        $this->updatesPermitted = false;
        $this->updateColumn = null;
        $this->updateRoute = null;
        $this->deletesPermitted = false;
        $this->deleteRoute = null;

    }

    public function getFilterKey(): string
    {
        return $this->filterKey;
    }

    protected function setInsert()
    {
        $this->insertLinkInfo = ($this->authorization->isAuthorized($this->getResource('insert'))) ? [
            'text' => $this->mapper->getInsertTitle(),
            'route' => SlimPostgres::getRouteName(true, $this->routePrefix, 'insert')
        ] : null;
    }

    protected function setUpdate()
    {
        /** can be null */
        $this->updateColumn = $this->mapper->getUpdateColumnName();

        $this->updatesPermitted = $this->authorization->isAuthorized($this->getResource('update')) && $this->updateColumn !== null;

        $this->updateRoute = SlimPostgres::getRouteName(true, $this->routePrefix, 'update');
    }

    protected function setDelete()
    {
        $this->deletesPermitted = $this->container->authorization->isAuthorized($this->getResource('delete'));

        $this->deleteRoute = SlimPostgres::getRouteName(true, $this->routePrefix, 'delete');
    }

    public function routeIndex(Request $request, Response $response, $args)
    {
        return $this->indexView($response);
    }

    public function routeIndexResetFilter(Request $request, Response $response, $args)
    {
        return $this->resetFilter($response, $this->indexRoute);
    }

    /** get display items (array of recordset) from the mapper. special handling when filtering */
    private function getDisplayItems(): ?array
    {
        /** squelch the sql warning in case of ill-formed filter field and catch the exception instead in order to alert the administrator of mistake. note, ideally any value that causes a query failure will be invalidated in the controller, but this is an extra measure of avoiding an unhandled exception while still logging/displaying the alert */
        if (null !== $filterColumnsInfo = $this->getFilterColumnsInfo()) {
            try {
                $displayItems = @$this->mapper->select(null, $filterColumnsInfo);
            } catch (QueryFailureException $e) {
                $this->events->insertError(EVENT_LIST_VIEW_FILTER_QUERY_FAIL, ['error' => $e->getMessage()]);
                SlimPostgres::setAdminNotice('Query Failed', 'failure');
            }
        } else {
            $displayItems = $this->mapper->select();
        }

        return $displayItems;
    }

    /** display items can be passed in as an array of records or objects, if objects, the appropriate template should be passed to this constructor. */
    public function indexView(Response $response, ?array $displayItems = null)
    {
        /** if display items have not been passed in, get them from the mapper */
        if ($displayItems === null) {
            $displayItems = $this->getDisplayItems();
        }

        /** save error in var prior to unsetting */
        $filterErrorMessage = FormHelper::getFieldError($this->sessionFilterFieldKey);
        FormHelper::unsetSessionFormErrors();

        return $this->view->render(
            $response,
            $this->template,
            [
                'title' => $this->mapper->getListViewTitle(),
                'insertLinkInfo' => $this->insertLinkInfo,
                'filterOpsList' => QueryBuilder::getWhereOperatorsText(),
                'filterValue' => $this->getFilterFieldValue(),
                'filterErrorMessage' => $filterErrorMessage,
                'filterFormActionRoute' => $this->indexRoute,
                'filterFieldName' => $this->sessionFilterFieldKey,
                'isFiltered' => $this->getFilterFieldValue() != '',
                'resetFilterRoute' => $this->filterResetRoute,
                'updatesPermitted' => $this->updatesPermitted,
                'updateColumn' => $this->updateColumn,
                'updateRoute' => $this->updateRoute,
                'deletesPermitted' => $this->deletesPermitted,
                'deleteRoute' => $this->deleteRoute,
                'displayItems' => $displayItems,
                'columnCount' => $this->mapper->getCountSelectColumns(),
                'sortColumn' => $this->mapper->getListViewSortColumn(),
                'sortAscending' => $this->mapper->getListViewSortAscending(),
                'navigationItems' => $this->navigationItems
            ]
        );
    }

    protected function getFilterColumnsInfo(): ?array
    {
        return (isset($_SESSION[SlimPostgres::SESSION_KEY_ADMIN_LIST_VIEW_FILTER][$this->getFilterKey()][self::SESSION_FILTER_COLUMNS_KEY])) ? $_SESSION[SlimPostgres::SESSION_KEY_ADMIN_LIST_VIEW_FILTER][$this->getFilterKey()][self::SESSION_FILTER_COLUMNS_KEY] : null;
    }

    /** either session value or empty string */
    protected function getFilterFieldValue(): string
    {
        if (isset($_SESSION[SlimPostgres::SESSION_KEY_ADMIN_LIST_VIEW_FILTER][$this->getFilterKey()][self::SESSION_FILTER_VALUE_KEY])) {
            return $_SESSION[SlimPostgres::SESSION_KEY_ADMIN_LIST_VIEW_FILTER][$this->getFilterKey()][self::SESSION_FILTER_VALUE_KEY];
        } else {
            return '';
        }
    }

    protected function resetFilter(Response $response, string $redirectRoute)
    {
        if (isset($_SESSION[SlimPostgres::SESSION_KEY_ADMIN_LIST_VIEW_FILTER][$this->getFilterKey()][self::SESSION_FILTER_COLUMNS_KEY])) {
            unset($_SESSION[SlimPostgres::SESSION_KEY_ADMIN_LIST_VIEW_FILTER][$this->getFilterKey()][self::SESSION_FILTER_COLUMNS_KEY]);
        }

        if (isset($_SESSION[SlimPostgres::SESSION_KEY_ADMIN_LIST_VIEW_FILTER][$this->getFilterKey()][self::SESSION_FILTER_VALUE_KEY])) {
            unset($_SESSION[SlimPostgres::SESSION_KEY_ADMIN_LIST_VIEW_FILTER][$this->getFilterKey()][self::SESSION_FILTER_VALUE_KEY]);
        }

        // redirect to the clean url
        return $response->withRedirect($this->router->pathFor($redirectRoute));
    }

    public function getSessionFilterFieldKey(): string
    {
        return $this->sessionFilterFieldKey;
    }
}


<?php
declare(strict_types=1);

namespace Entities\Administrators\View;

use It_All\FormFormer\Fields\InputFields\CheckboxRadioInputField;
use It_All\FormFormer\Fieldset;
use Exceptions\QueryFailureException;
use Entities\Administrators\Model\Administrator;
use Entities\Administrators\Model\AdministratorsEntityMapper;
use Entities\Administrators\Model\AdministratorsTableMapper;
use Entities\Administrators\View\Forms\AdministratorInsertForm;
use Entities\Administrators\View\Forms\AdministratorUpdateForm;
use It_All\FormFormer\Fields\InputField;
use It_All\FormFormer\Form;
use Infrastructure\SlimPostgres;
use Infrastructure\BaseEntity\BaseMVC\View\ObjectsListViews;
use Infrastructure\BaseEntity\BaseMVC\View\InsertUpdateViews;
use Infrastructure\BaseEntity\BaseMVC\View\ResponseUtilities;
use Infrastructure\Database\Queries\QueryBuilder;
use Infrastructure\BaseEntity\BaseMVC\View\AdminListView;
use Infrastructure\BaseEntity\DatabaseTable\View\DatabaseTableForm;
use Infrastructure\BaseEntity\BaseMVC\View\Forms\FormHelper;
use Slim\Container;
use Slim\Http\Request;
use Slim\Http\Response;

class AdministratorsView extends AdminListView implements ObjectsListViews, InsertUpdateViews
{
    use ResponseUtilities;

    private $administratorsEntityMapper;
    private $administratorsTableMapper;
    protected $routePrefix;

    const FILTER_FIELDS_PREFIX = 'administrators';

    public function __construct(Container $container)
    {
        $this->administratorsEntityMapper = AdministratorsEntityMapper::getInstance();
        $this->administratorsTableMapper = AdministratorsTableMapper::getInstance();
        $this->routePrefix = ROUTEPREFIX_ADMINISTRATORS;

        parent::__construct($container, self::FILTER_FIELDS_PREFIX, ROUTE_ADMINISTRATORS, $this->administratorsEntityMapper, ROUTE_ADMINISTRATORS_RESET, 'Admin/Lists/objectsList.php');

        $this->setInsert();
        $this->setUpdate();
        $this->setDelete();
    }

    protected function getResource(string $which): string
    {
        switch ($which) {
            case 'insert':
                return ADMINISTRATORS_INSERT_RESOURCE;
                break;
            case 'update':
                return ADMINISTRATORS_UPDATE_RESOURCE;
                break;
            case 'delete':
                return ADMINISTRATORS_DELETE_RESOURCE;
                break;
            default:
                throw new \InvalidArgumentException("Undefined resource $which");
        }
    }

    /** overrides in order to get administrator objects and send to indexView */
    public function routeIndex(Request $request, Response $response, $args)
    {
        return $this->indexViewObjects($response);
    }

    /** overrides in order to get administrator objects and send to indexView */
    public function routeIndexResetFilter(Request $request, Response $response, $args)
    {
        // redirect to the clean url
        return $this->indexViewObjects($response, true);
    }

    /** get objects and send to parent indexView */
    public function indexViewObjects(Response $response, bool $resetFilter = false)
    {
        if ($resetFilter) {
            return $this->resetFilter($response, $this->indexRoute);
        }

        try {
            $administrators = $this->administratorsEntityMapper->getObjects($this->getFilterColumnsInfo(), null, $this->authentication, $this->authorization);
        } catch (QueryFailureException $e) {
            $administrators = [];
            // warning event is inserted when query fails
            SlimPostgres::setAdminNotice('Query Failed', 'failure');
        }

        return $this->indexView($response, $administrators);
    }

    public function routeGetInsert(Request $request, Response $response, $args)
    {
        return $this->insertView($request, $response, $args);
    }

    /** this can be called for both the initial get and the posted form if errors exist (from controller) */
    public function insertView(Request $request, Response $response, $args)
    {
        $formAction = $this->router->pathFor(SlimPostgres::getRouteName(true, $this->routePrefix, 'insert', 'post'));
        $fieldValues = ($request->isPost() && isset($args[SlimPostgres::USER_INPUT_KEY])) ? $args[SlimPostgres::USER_INPUT_KEY] : [];

        return $this->view->render(
            $response,
            'Admin/form.php',
            [
                'title' => $this->administratorsEntityMapper->getInsertTitle(),
                'form' => (new AdministratorInsertForm($formAction, $this->container, $fieldValues))->getForm(),
                'navigationItems' => $this->navigationItems
            ]
        );
    }

    public function routeGetUpdate(Request $request, Response $response, $args)
    {
        return $this->updateView($request, $response, $args);
    }

    /** this can be called for both the initial get and the posted form if errors exist (from controller) */
    public function updateView(Request $request, Response $response, $args)
    {
        // make sure there is an administrator for the primary key
        if (null === $administrator = $this->administratorsEntityMapper->getObjectById((int) $args[ROUTEARG_PRIMARY_KEY])) {
            return $this->databaseRecordNotFound($response, $args[ROUTEARG_PRIMARY_KEY], $this->administratorsTableMapper, 'update');
        }

        $formAction = $this->router->pathFor(SlimPostgres::getRouteName(true, $this->routePrefix, 'update', 'put'), [ROUTEARG_PRIMARY_KEY => $args[ROUTEARG_PRIMARY_KEY]]);

        /** if input set we are redisplaying after invalid form submission */
        if ($request->isPut() && isset($args[SlimPostgres::USER_INPUT_KEY])) {
            $updateForm = new AdministratorUpdateForm($formAction, $this->container, $args[SlimPostgres::USER_INPUT_KEY]);
        } else {
            $updateForm = new AdministratorUpdateForm($formAction, $this->container);
            $updateForm->setFieldValuesToAdministrator($administrator);
        }

        return $this->view->render(
            $response,
            'Admin/form.php',
            [
                'title' => $this->administratorsEntityMapper->getUpdateTitle(),
                'form' => $updateForm->getForm(),
                'primaryKey' => $args[ROUTEARG_PRIMARY_KEY],
                'navigationItems' => $this->navigationItems,
                'hideFocus' => true
            ]
        );
    }
}


<?php
declare(strict_types=1);

namespace Infrastructure\Utilities;

/**
 * Service Layer for PHPMailer
 */
class PhpMailerService {

    private $logPath;
    private $defaultFromEmail;
    private $defaultFromName;
    private $protocol;
    private $smtpHost;
    private $smtpPort;
    private $phpMailer;

    /** @var bool this can allow disabling service entirely, ie for a dev site */
    private $disableSend;

    public function __construct(string $logPath, string $defaultFromEmail, string $defaultFromName, string $protocol = 'smtp', string $smtpHost = null, int $smtpPort = null, bool $disableSend = false)
    {
        $this->logPath = $logPath;
        $this->defaultFromEmail = $defaultFromEmail;
        $this->defaultFromName = $defaultFromName;
        $this->protocol = strtolower($protocol);
        $this->smtpHost = $smtpHost;
        $this->smtpPort = $smtpPort;
        $this->phpMailer = $this->create();
        $this->disableSend = $disableSend;
    }

    public function send(string $subject, string $body, array $toEmails, string $fromEmail = null, string $fromName = null)
    {
        if ($this->disableSend) {
            return;
        }

        $toEmails = array_unique($toEmails);
        $this->phpMailer->Subject = $subject;
        $this->phpMailer->Body = $body;
        $toEmailsString = '';
        foreach ($toEmails as $email) {
            $toEmailsString .= "$email ";
            $this->phpMailer->addAddress(strtolower($email));
        }
        if ($fromEmail == null) {
            $fromEmail = $this->defaultFromEmail;
        }
        if ($fromName == null) {
            $fromName = $this->defaultFromName;
        }
        $this->phpMailer->setFrom($fromEmail, $fromName);
        if (!$this->phpMailer->send()) {
            // note do not throw exception here because could get stuck in loop trying to email
            $errorMessage = "[".date('Y-m-d H:i:s e')."]" . PHP_EOL .
                "PhpMailer::send() failed: ".$this->phpMailer->ErrorInfo . PHP_EOL .
                "subject: $subject" . PHP_EOL .
                "body: $body" . PHP_EOL .
                "to: $toEmailsString" . PHP_EOL .
                "from: $fromEmail" . PHP_EOL . PHP_EOL;
                error_log($errorMessage, 3, $this->logPath);
        }
        $this->clear();
    }

    /**
     * clears the current phpmailer
     */
    private function clear() {
        if (!isset($this->phpMailer)) {
            return;
        }
        $this->phpMailer->clearAddresses();
        $this->phpMailer->clearCCs();
        $this->phpMailer->clearBCCs();
        $this->phpMailer->clearReplyTos();
        $this->phpMailer->clearAllRecipients();
        $this->phpMailer->clearAttachments();
        $this->phpMailer->clearCustomHeaders();
    }

    /**
     * Creates a fresh mailer
     */
    private function create() {
        $m = new \PHPMailer();
        switch ($this->protocol) {
            case 'sendmail':
                $m->isSendmail();
                break;
            case 'smtp':
                $m->isSMTP();
                $m->Host = $this->smtpHost;
                $m->SMTPAuth = false;
                $m->SMTPAutoTLS = false;
                $m->Port = $this->smtpPort;
                break;
            case 'mail':
                $m->isMail();
                break;
            case 'qmail':
                $m->isQmail();
                break;
            default:
                throw new \Exception('bad phpmailerType: '.$this->protocol);
        }
        return $m;
    }
}