Initial commit

This commit is contained in:
Chris Smith
2025-02-19 14:51:16 +01:00
commit d82a6cad96
198 changed files with 13819 additions and 0 deletions

3
.bowerrc Normal file
View File

@@ -0,0 +1,3 @@
{
"directory" : "vendor/bower-asset"
}

47
.gitignore vendored Normal file
View File

@@ -0,0 +1,47 @@
.vscode
# yii console commands
/yii
/yii_test
/yii_test.bat
mysql-data
# phpstorm project files
.idea
# netbeans project files
nbproject
# zend studio for eclipse project files
.buildpath
.project
.settings
# windows thumbnail cache
Thumbs.db
# composer vendor dir
/vendor
# composer itself is not needed
composer.phar
# Mac DS_Store Files
.DS_Store
# phpunit itself is not needed
phpunit.phar
# local phpunit config
/phpunit.xml
# vagrant runtime
/.vagrant
# ignore generated files
/frontend/web/index.php
/frontend/web/index-test.php
/frontend/web/robots.txt
/backend/web/index.php
/backend/web/index-test.php
/backend/web/robots.txt
/frontend/web/uploads/

17
CHANGELOG.md Normal file
View File

@@ -0,0 +1,17 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
* Initial release
## [0.1.0] - 2025-02-10
* First release
[Unreleased]: https://github.com/cgsmith/calorie/compare/0.0.1...HEAD
[0.1.0]: https://github.com/cgsmith/calorie/releases/tag/0.1.0

110
README.md Normal file
View File

@@ -0,0 +1,110 @@
# Food Tracker Application - Yii2 MVP
This document outlines the development plan for a minimal viable product (MVP) of a food tracking application built
using the Yii2 framework.
Read about it in the [MVP](mvp.md)
## Application Overview
The application allows users to log meals, including uploading images for analysis, and view daily nutritional
summaries. This MVP focuses on core functionality, prioritizing ease of development and rapid iteration. Social login is
not included in this version.
## Development Setup
> [!IMPORTANT]
> If you have any problems with these steps please [create an issue](../../issues/new)
You will need a few items locally before setting up. Everything runs in `docker` except for the first few setup items.
On your host download or make sure you have:
* [PHP 8.3+](https://www.php.net)
* [composer](https://getcomposer.org/)
* [docker](https://docs.docker.com/desktop/)
After having the necessary software then you can perform the following steps to setup your test instance:
1. `git clone git@github.com:cgsmith/calorie.git`
2. `cd calorie`
3. `composer install`
4. `docker compose up -d`
5. `php init --env=Development`
You're application should be running at http://localhost:20080!
### Database initialization
1. `docker exec -it calorie-frontend-1 bash`
2. `yii migrate`
3. `yii fixture/load "*"` (creates chris@fivedevs.com with password of `password`)
🎉 You should be able to login!
### Setting up Xdebug Locally
Xdebug is installed and configured on the docker container.
In [PhpStorm](https://www.jetbrains.com/help/phpstorm/configuring-xdebug.html#integrationWithProduct) you will need
to still configure it.
#### PhpStorm Setup
1. `Ctrl + Alt + S` to open settings
2. Goto `PHP > Servers`
3. Add a new server called 'Calorie'
1. Host: `localhost`
2. Port: `20080`
3. Check `Use path mappings`
4. Map the repo to the app folder: `~/calorie -> /app`
4. Under `PHP > Debug` in the settings screen add the following ports to listen on: `9005`
You can add the port by adding a comma to separate.
#### VSCode setup
1. Open extensions `Ctrl + Shift + X`
2. Download PHP Debug extension (publisher is xdebug.org)
3. Goto `Run > Add Configuration` menu
4. Select PHP
5. Change the port setting to `9005`
Your VSCode IDE is now ready to start receiving Xdebug signals! For more documentation on setup please
see [Xdebug extension documentation](https://github.com/xdebug/vscode-php-debug)
## Testing
> [!NOTE]
> Tests should run within the docker container
> Run with `docker exec -e XDEBUG_MODE=off calorie-frontend-1 ./vendor/bin/codecept run` from your
> host.
For running tests the project uses [Codeception](https://codeception.com). To run these tests just run `composer test`.
You can also run this by running `./vendor/bin/codecept run` which will take the entire `codeception.yml` and run the
tests.
These will also run automatically on deployment.
## Deployment
> [!IMPORTANT]
> Follow Semantic Versioning and update the CHANGELOG when making a release! Sentry manages releases with the SHA
> from git - while we manage the release with version numbers in a sane way.
[Deployer](https://deployer.org) is used for the atomic deployments. An atomic deployment simply changes the symlink for
the webserver and then restarts the webserver after running any database migrations. This process, like all processes,
can always be improved upon. An atomic deployment allows a server administrator to symlink to a prior version of working
code as long as they navigate to the correct git SHA and change the symlink.
Deployer can be run from the command line with a command like below:
** Deploy to testing **
```shell
./vendor/bin/dep deploy test.calorie
```
** Deploy to production **
```shell
./vendor/bin/dep deploy calorie --tag=1.0.0 # change your tag here
```

0
api/Dockerfile Normal file
View File

1
api/assets/AppAsset.php Normal file
View File

@@ -0,0 +1 @@
<?php

15
api/codeception.yml Normal file
View File

@@ -0,0 +1,15 @@
namespace: backend\tests
actor_suffix: Tester
paths:
tests: tests
output: tests/_output
data: tests/_data
support: tests/_support
bootstrap: _bootstrap.php
settings:
colors: true
memory_limit: 1024M
modules:
config:
Yii2:
configFile: 'config/codeception-local.php'

0
api/config/.gitignore vendored Normal file
View File

0
api/config/bootstrap.php Normal file
View File

View File

25
api/config/main-local.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
$config = [
'components' => [
'request' => [
// !!! insert a secret key in the following (if it is empty) - this is required by cookie validation
'cookieValidationKey' => 'fKCQhZtDFPI3Xt13xTqMdvn16F_Yd1Sl',
],
],
];
if (!YII_ENV_TEST) {
// configuration adjustments for 'dev' environment
$config['bootstrap'][] = 'debug';
$config['modules']['debug'] = [
'class' => \yii\debug\Module::class,
];
$config['bootstrap'][] = 'gii';
$config['modules']['gii'] = [
'class' => \yii\gii\Module::class,
];
}
return $config;

50
api/config/main.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
$params = array_merge(
require __DIR__ . '/../../common/config/params.php',
require __DIR__ . '/../../common/config/params-local.php',
require __DIR__ . '/params.php',
require __DIR__ . '/params-local.php'
);
return [
'id' => 'app-backend',
'basePath' => dirname(__DIR__),
'controllerNamespace' => 'backend\controllers',
'bootstrap' => ['log'],
'modules' => [],
'components' => [
'request' => [
'csrfParam' => '_csrf-backend',
],
'user' => [
'identityClass' => 'common\models\User',
'enableAutoLogin' => true,
'identityCookie' => ['name' => '_identity-backend', 'httpOnly' => true],
],
'session' => [
// this is the name of the session cookie used for login on the backend
'name' => 'advanced-backend',
],
'log' => [
'traceLevel' => YII_DEBUG ? 3 : 0,
'targets' => [
[
'class' => \yii\log\FileTarget::class,
'levels' => ['error', 'warning'],
],
],
],
'errorHandler' => [
'errorAction' => 'site/error',
],
/*
'urlManager' => [
'enablePrettyUrl' => true,
'showScriptName' => false,
'rules' => [
],
],
*/
],
'params' => $params,
];

View File

0
api/config/params.php Normal file
View File

View File

0
api/config/test.php Normal file
View File

View File

@@ -0,0 +1,106 @@
<?php
namespace backend\controllers;
use common\models\LoginForm;
use Yii;
use yii\filters\VerbFilter;
use yii\filters\AccessControl;
use yii\web\Controller;
use yii\web\Response;
/**
* Site controller
*/
class SiteController extends Controller
{
/**
* {@inheritdoc}
*/
public function behaviors()
{
return [
'access' => [
'class' => AccessControl::class,
'rules' => [
[
'actions' => ['login', 'error'],
'allow' => true,
],
[
'actions' => ['logout', 'index'],
'allow' => true,
'roles' => ['@'],
],
],
],
'verbs' => [
'class' => VerbFilter::class,
'actions' => [
'logout' => ['post'],
],
],
];
}
/**
* {@inheritdoc}
*/
public function actions()
{
return [
'error' => [
'class' => \yii\web\ErrorAction::class,
],
];
}
/**
* Displays homepage.
*
* @return string
*/
public function actionIndex()
{
return $this->render('index');
}
/**
* Login action.
*
* @return string|Response
*/
public function actionLogin()
{
if (!Yii::$app->user->isGuest) {
return $this->goHome();
}
$this->layout = 'blank';
$model = new LoginForm();
if ($model->load(Yii::$app->request->post()) && $model->login()) {
return $this->goBack();
}
$model->password = '';
return $this->render('login', [
'model' => $model,
]);
}
/**
* Logout action.
*
* @return Response
*/
public function actionLogout()
{
Yii::$app->user->logout();
return $this->goHome();
}
}

0
api/models/.gitkeep Normal file
View File

0
api/runtime/.gitignore vendored Normal file
View File

0
api/tests/_bootstrap.php Normal file
View File

0
api/tests/_data/.gitignore vendored Normal file
View File

View File

@@ -0,0 +1,13 @@
<?php
return [
[
'username' => 'erau',
'auth_key' => 'tUu1qHcde0diwUol3xeI-18MuHkkprQI',
// password_0
'password_hash' => '$2y$13$nJ1WDlBaGcbCdbNC5.5l4.sgy.OMEKCqtDQOdQ2OWpgiKRWYyzzne',
'password_reset_token' => 'RkD_Jw0_8HEedzLk7MM-ZKEFfYR7VbMr_1392559490',
'created_at' => '1392559490',
'updated_at' => '1392559490',
'email' => 'sfriesen@jenkins.info',
],
];

0
api/tests/_output/.gitignore vendored Normal file
View File

0
api/tests/_support/.gitignore vendored Normal file
View File

View File

View File

View File

@@ -0,0 +1,5 @@
suite_namespace: backend\tests\functional
actor: FunctionalTester
modules:
enabled:
- Yii2

View File

@@ -0,0 +1,44 @@
<?php
namespace backend\tests\functional;
use backend\tests\FunctionalTester;
use common\fixtures\UserFixture;
/**
* Class LoginCest
*/
class LoginCest
{
/**
* Load fixtures before db transaction begin
* Called in _before()
* @see \Codeception\Module\Yii2::_before()
* @see \Codeception\Module\Yii2::loadFixtures()
* @return array
*/
public function _fixtures()
{
return [
'user' => [
'class' => UserFixture::class,
'dataFile' => codecept_data_dir() . 'login_data.php'
]
];
}
/**
* @param FunctionalTester $I
*/
public function loginUser(FunctionalTester $I)
{
$I->amOnRoute('/site/login');
$I->fillField('Username', 'erau');
$I->fillField('Password', 'password_0');
$I->click('login-button');
$I->see('Logout (erau)', 'form button[type=submit]');
$I->dontSeeLink('Login');
$I->dontSeeLink('Signup');
}
}

View File

2
api/tests/unit.suite.yml Normal file
View File

@@ -0,0 +1,2 @@
suite_namespace: backend\tests\unit
actor: UnitTester

View File

View File

0
api/views/site/error.php Normal file
View File

53
api/views/site/index.php Normal file
View File

@@ -0,0 +1,53 @@
<?php
/** @var yii\web\View $this */
$this->title = 'My Yii Application';
?>
<div class="site-index">
<div class="jumbotron text-center bg-transparent">
<h1 class="display-4">Welcome!</h1>
<p class="lead">We are excited for you to get started.</p>
<p><a class="btn btn-lg btn-success" href="http://www.yiiframework.com">Get started with Yii</a></p>
</div>
<div class="body-content">
<div class="row">
<div class="col-lg-4">
<h2>Heading</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip
ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu
fugiat nulla pariatur.</p>
<p><a class="btn btn-outline-secondary" href="http://www.yiiframework.com/doc/">Yii Documentation &raquo;</a></p>
</div>
<div class="col-lg-4">
<h2>Heading</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip
ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu
fugiat nulla pariatur.</p>
<p><a class="btn btn-outline-secondary" href="http://www.yiiframework.com/forum/">Yii Forum &raquo;</a></p>
</div>
<div class="col-lg-4">
<h2>Heading</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip
ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu
fugiat nulla pariatur.</p>
<p><a class="btn btn-outline-secondary" href="http://www.yiiframework.com/extensions/">Yii Extensions &raquo;</a></p>
</div>
</div>
</div>
</div>

0
api/web/assets/.gitignore vendored Normal file
View File

0
api/web/css/site.css Normal file
View File

0
api/web/favicon.ico Normal file
View File

0
api/web/index-test.php Normal file
View File

0
api/web/index.php Normal file
View File

0
api/web/robots.txt Normal file
View File

8
codeception.yml Normal file
View File

@@ -0,0 +1,8 @@
# global codeception file to run tests from all apps
include:
- common
- frontend
paths:
output: console/runtime/output
settings:
colors: true

15
common/codeception.yml Normal file
View File

@@ -0,0 +1,15 @@
namespace: common\tests
actor_suffix: Tester
paths:
tests: tests
output: tests/_output
data: tests/_data
support: tests/_support
bootstrap: _bootstrap.php
settings:
colors: true
memory_limit: 1024M
modules:
config:
Yii2:
configFile: 'config/codeception-local.php'

View File

@@ -0,0 +1,19 @@
<?php
namespace common\components;
use Postmark\PostmarkClient;
use yii\base\Component;
class PostmarkComponent extends Component
{
/** @var PostmarkClient */
public PostmarkClient $client;
public mixed $serverToken;
public function init()
{
parent::init();
$this->client = new PostmarkClient($this->serverToken);
}
}

View File

@@ -0,0 +1,469 @@
<?php
namespace common\components;
use common\jobs\EmailJob;
use common\models\Account;
use common\models\InvoiceItem;
use common\models\SalesAgent;
use common\models\Service;
use Yii;
use yii\helpers\Url;
use yii\httpclient\Client;
use yii\httpclient\Request;
use yii\httpclient\RequestEvent;
class SonarApiComponent extends \yii\base\Component
{
public Client $client;
public string $baseUrl;
public string $bearerToken;
public function init()
{
parent::init();
$this->client = new Client([
'baseUrl' => $this->baseUrl,
'requestConfig' => [
'format' => Client::FORMAT_JSON
],
'responseConfig' => [
'format' => Client::FORMAT_JSON
],
]);
// Setup event for auth before each send
$this->client->on(Request::EVENT_BEFORE_SEND, function (RequestEvent $event) {
$event->request->addHeaders(['Authorization' => 'Bearer ' . $this->bearerToken]);
});
}
public function getAccounts(int $page = 1, int $limit = 100)
{
$data = [
'form_params' => [
'query' => 'query accountsWithSalesAgents(
$paginator: Paginator,
$search: [Search],
$sorter: [Sorter]
) {
accounts(
paginator: $paginator
search: $search
sorter: $sorter
reverse_relation_filters: {
relation: "custom_field_data",
search: {
integer_fields: {
attribute: "custom_field_id",
search_value: 12,
operator: EQ
}
}
}
general_search_mode: ROOT_PLUS_RELATIONS
account_status_id: 1
) {
entities {
id
name
account_status {
id
name
}
account_services {
entities {
id
quantity
name_override
price_override
price_override_reason
service {
id
name
amount
enabled
application
type
}
}
page_info {
page
records_per_page
total_count
total_pages
}
}
custom_field_data(custom_field_id:12) {
entities {
id
custom_field_id
value
}
}
}
page_info {
page
records_per_page
total_count
total_pages
}
}
}',
'variables' => [
'paginator' => [
'page' => $page,
'records_per_page' => $limit
],
'search' => [],
'sorter' => [
[
'attribute' => 'updated_at',
'direction' => 'ASC',
]
],
]
]
];
$response = $this->client->createRequest()
->setMethod('POST')
->setData($data)
->send();
$account = json_decode($response->getContent(), true);
return $account['form_params']['data'];
}
public function getInvoices(string $startDate, string $endDate)
{
$page = 1;
$limit = 100;
$invoices = [];
do {
$data = [
'form_params' => [
'query' => 'query accountInvoice($paginator: Paginator, $search: [Search], $sorter: [Sorter]) {
invoices(
paginator: $paginator
search: $search
sorter: $sorter
general_search_mode: ROOT_PLUS_RELATIONS
) {
entities {
id
account_id
total_debits
void
remaining_due
date
due_date
end_date
delinquent
debits {
entities {
id
quantity
service_id
service_name
amount
}
}
credits {
entities {
amount
}
}
}
page_info {
page
records_per_page
total_count
total_pages
}
}
}',
'variables' => [
'paginator' => [
'page' => $page,
'records_per_page' => $limit
],
'search' => [
[
'date_fields' => [
['attribute' => 'date', 'search_value' => $startDate, 'operator' => 'GTE'],
['attribute' => 'date', 'search_value' => $endDate, 'operator' => 'LTE'],
]
]
]
],
'sorter' => [
[
'attribute' => 'updated_at',
'direction' => 'ASC',
]
],
]
];
$response = $this->client->createRequest()
->setMethod('POST')
->setData($data)
->send();
$responseData = json_decode($response->getContent(), true);
$invoices = array_merge($invoices, $responseData['form_params']['data']['invoices']['entities']);
$page++;
} while ($page < ($responseData['form_params']['data']['invoices']['page_info']['total_pages'] + 1));
return $invoices;
}
public function getInvoice(int $invoiceId = 1)
{
$page = 1;
$limit = 100;
$data = [
'form_params' => [
'query' => 'query accountInvoice($paginator: Paginator, $search: [Search], $sorter: [Sorter]) {
invoices(
id: ' . $invoiceId . '
paginator: $paginator
search: $search
sorter: $sorter
general_search_mode: ROOT_PLUS_RELATIONS
) {
entities {
id
account_id
total_debits
void
remaining_due
date
due_date
end_date
delinquent
debits {
entities {
id
quantity
service_id
service_name
amount
}
}
credits {
entities {
amount
}
}
}
page_info {
page
records_per_page
total_count
total_pages
}
}
}',
'variables' => [
'paginator' => [
'page' => $page,
'records_per_page' => $limit
],
'search' => [],
'sorter' => [
[
'attribute' => 'updated_at',
'direction' => 'ASC',
]
],
]
]
];
$response = $this->client->createRequest()
->setMethod('POST')
->setData($data)
->send();
$invoice = json_decode($response->getContent(), true);
return $invoice['form_params']['data']['invoices']['entities'][0];
}
public function storeInvoices($invoices)
{
foreach ($invoices as $invoice) {
$this->storeInvoice($invoice);
}
}
public function storeInvoice($invoice)
{
\Yii::debug($invoice);
// $remainingDue is the Entire Invoice remaining to be paid amount, 0 = everything paid
$remainingDue = $invoice['remaining_due'];
// debits = charges on the account
// credits = payments on the account
foreach ($invoice['debits']['entities'] as $i => $rawItem) {
$invoiceItem = InvoiceItem::find()->where(['sonar_id' => (int)$rawItem['id']])->one();
if (null === $invoiceItem) { // create new invoice item
$account = Account::findOne(['sonar_id' => (int)$invoice['account_id']]);
$service = Service::findOne(['sonar_id' => (int)$rawItem['service_id']]);
\Yii::debug($rawItem);
if ($service && $account) {
\Yii::debug($invoice);
$payment = (isset($invoice['credits']['entities'][$i]['amount'])) ? $invoice['credits']['entities'][$i]['amount'] : 0;
// @todo check payment - i think it is wrong to assume we have the same credits and debits ^ CGS
$invoiceItem = new InvoiceItem([
'sonar_id' => (int)$rawItem['id'],
'account_id' => $account->id,
'service_id' => $service->id,
'name' => $rawItem['service_name'],
'status' => InvoiceItem::STATUS_OPEN,
'charge' => $rawItem['amount'],
'payment' => $payment,
'is_commissionable' => $service->hasCommission(),
]);
$invoiceItem->save();
}
}
// is the invoice item paid?
if ($invoiceItem && $remainingDue == 0) {
$invoiceItem->status = InvoiceItem::STATUS_PAYMENT_RECEIVED;
$invoiceItem->save();
}
}
}
private function mapAccounts($accounts)
{
$mapped = [];
$i = 0;
$db = \Yii::$app->db;
foreach ($accounts as $account) {
$mapped[$i]['sonar_id'] = (int)$account['id'];
$mapped[$i]['name'] = $account['name'];
/**
* [
* 'id' => '132'
* 'quantity' => 1
* 'name_override' => 'Bradley'
* 'price_override' => 0
* 'price_override_reason' => 'testing'
* 'service' => [
* 'id' => '10'
* 'name' => 'Business Giga Speed Internet'
* 'amount' => 18000
* 'enabled' => true
* 'application' => 'DEBIT'
* 'type' => 'DATA'
* ]
* ]
*/
$mapped[$i]['services'] = []; // init empty array
foreach ($account['account_services']['entities'] as $key => $account_service) {
$mapped[$i]['services'][$key]['sonar_id'] = (int)$account_service['service']['id'];
$mapped[$i]['services'][$key]['name'] = (!empty($account_service['name_override'])) ? $account_service['name_override'] : $account_service['service']['name'];
$mapped[$i]['services'][$key]['price'] = (!empty($account_service['price_override'])) ? $account_service['price_override'] : $account_service['service']['amount'];
if ($account_service['service']['application'] === 'CREDIT') {
$mapped[$i]['services'][$key]['price'] = -1 * $mapped[$i]['services'][$key]['price'];// store as a negative
}
// set to 0 if null after credit
if (null === $mapped[$i]['services'][$key]['price']) {
$mapped[$i]['services'][$key]['price'] = 0;
}
}
$name = $account['custom_field_data']['entities'][0]['value'];
$salesAgent = SalesAgent::findOne(['name' => $name]);
if (null === $salesAgent) {
$salesAgent = new SalesAgent(['name' => $name]);
$salesAgent->save();
}
$mapped[$i]['sales_agent_id'] = $salesAgent->id;
$i++;
}
return $mapped;
}
public function storeAccounts()
{
$page = 1;
do {
$batch = $this->getAccounts($page, 100);
$accounts = $this->mapAccounts($batch['accounts']['entities']);
foreach ($accounts as $account) {
$accountModel = Account::findOne(['sonar_id' => $account['sonar_id']]);
if (null === $accountModel) {
$accountModel = new Account([
'sonar_id' => $account['sonar_id'],
'name' => $account['name'],
'sales_agent_id' => $account['sales_agent_id'],
]);
$accountModel->save();
} else {
//$accountModel->sonar_id = $account['sonar_id'];
$accountModel->name = $account['name'];
$accountModel->sales_agent_id = $account['sales_agent_id'];
$accountModel->save();
}
foreach ($account['services'] as $rawServiceData) {
$serviceModel = Service::findOne(['sonar_id' => (int)$rawServiceData['sonar_id']]);
if (null === $serviceModel) {
$serviceModel = new Service([
'sonar_id' => $rawServiceData['sonar_id'],
'name' => $rawServiceData['name'],
'price' => $rawServiceData['price'],
'account_id' => $accountModel->id,
'active' => 1, // @todo pull active state in from sonar api
]);
$serviceModel->save();
} else {
$serviceModel->commission = $serviceModel->getFormattedDollar($serviceModel->commission, false);
$serviceModel->sonar_id = $rawServiceData['sonar_id'];
$serviceModel->name = $rawServiceData['name'];
$serviceModel->price = $rawServiceData['price'];
$serviceModel->account_id = $accountModel->id;
if (!empty($serviceModel->dirtyAttributes)) {
if (isset($serviceModel->dirtyAttributes['price'])) {
//Yii::$app->queue->push(new EmailJob([
// 'templateAlias' => EmailJob::PRICE_CHANGE,
// "email" => Yii::$app->params['adminEmail'],
// 'templateModel' => [
// "action_edit_url" => Yii::$app->urlManager->createAbsoluteUrl(
// ['service/update', 'id' => $serviceModel->id]
// ),
// ]
//]));
}
}
$serviceModel->save();
}
}
}
$page++;
} while ($page < ($batch['accounts']['page_info']['total_pages'] + 1));
}
public function processInvoices(int $invoiceId)
{
dump($this->getInvoice($invoiceId));
}
}

4
common/config/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
codeception-local.php
main-local.php
params-local.php
test-local.php

View File

@@ -0,0 +1,37 @@
<?php
/**
* This class only exists here for IDE (PHPStorm/Netbeans/...) autocompletion.
* This file is never included anywhere.
* Adjust this file to match classes configured in your application config, to enable IDE autocompletion for custom components.
* Example: A property phpdoc can be added in `__Application` class as `@property \vendor\package\Rollbar|__Rollbar $rollbar` and adding a class in this file
* ```php
* // @property of \vendor\package\Rollbar goes here
* class __Rollbar {
* }
* ```
*/
class Yii {
/**
* @var \yii\web\Application|\yii\console\Application|__Application
*/
public static $app;
}
/**
* @property yii\rbac\DbManager $authManager
* @property \Da\User\Model\User $user
* @property \common\components\SonarApiComponent $sonar
* @property \common\components\HubspotApiComponent $hubspot
* @property \common\components\PostmarkComponent $postmark
* @property \yii\queue\db\Queue $queue
*
*/
class __Application {
}
/**
* @property app\models\User $identity
*/
class __WebUser {
}

View File

@@ -0,0 +1,5 @@
<?php
Yii::setAlias('@common', dirname(__DIR__));
Yii::setAlias('@frontend', dirname(dirname(__DIR__)) . '/frontend');
Yii::setAlias('@backend', dirname(dirname(__DIR__)) . '/api');
Yii::setAlias('@console', dirname(dirname(__DIR__)) . '/console');

44
common/config/main.php Normal file
View File

@@ -0,0 +1,44 @@
<?php
use common\components\PostmarkComponent;
use common\components\SonarApiComponent;
use common\components\HubspotApiComponent;
use yii\caching\FileCache;
use yii\queue\db\Queue;
$params = array_merge(
require __DIR__ . '/params.php',
require __DIR__ . '/params-local.php'
);
return [
'name' => $params['company_name'] . ' - ' . $params['product_name'],
'aliases' => [
'@bower' => '@vendor/bower-asset',
'@npm' => '@vendor/npm-asset',
],
'vendorPath' => dirname(dirname(__DIR__)) . '/vendor',
'components' => [
'cache' => [
'class' => FileCache::class,
],
'sonar' => [
'class' => SonarApiComponent::class,
'baseUrl' => $params['sonar.url'] . '/api/graphql',
'bearerToken' => $params['sonar.bearerToken'],
],
'postmark' => [
'class' => PostmarkComponent::class,
'serverToken' => $params['postmark.serverToken'],
],
'authManager' => [
'class' => 'yii\rbac\DbManager',
],
'urlManager' => [
'enablePrettyUrl' => true,
'showScriptName' => false,
'rules' => [
],
]
],
];

14
common/config/params.php Normal file
View File

@@ -0,0 +1,14 @@
<?php
return [
'adminEmail' => 'admin@example.com',
'supportEmail' => 'support@example.com',
'senderEmail' => 'noreply@example.com',
'senderName' => 'Example.com mailer',
'user.passwordResetTokenExpire' => 3600,
'user.passwordMinLength' => 8,
'sonar.url' => 'https://yourname.sonar.software',
'sonar.bearerToken' => '',
'postmark.serverToken' => 'postmark-server-key',
'postmark.messageStream' => 'outbound',
'sentry.dsn' => 'https://asdf@o4507934844780544.ingest.us.sentry.io/4508006893158400',
];

11
common/config/test.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
return [
'id' => 'app-common-tests',
'basePath' => dirname(__DIR__),
'components' => [
'user' => [
'class' => \yii\web\User::class,
'identityClass' => 'common\models\User',
],
],
];

View File

@@ -0,0 +1,11 @@
<?php
namespace common\fixtures;
use yii\test\ActiveFixture;
class AuthAssignmentFixture extends ActiveFixture
{
public $tableName = '{{%auth_assignment}}';
public $depends = [UserFixture::class];
}

View File

@@ -0,0 +1,11 @@
<?php
namespace common\fixtures;
use yii\test\ActiveFixture;
class UserFixture extends ActiveFixture
{
public $modelClass = 'common\models\User';
public $depends = [];
}

View File

@@ -0,0 +1,12 @@
<?php
return [
[
'item_name' => 'admin',
'user_id' => 1,
],
[
'item_name' => 'user',
'user_id' => 2,
],
];

View File

@@ -0,0 +1,22 @@
<?php
return [
[
//password
'password_hash' => '$2y$13$k2hQ8aV/z6V0Y7pRbq1ufOUSaJI7EhNvvTUIoj2s/rxAmgyY95KPa',
'email' => 'admin@example.com',
'auth_key' => '1',
'status' => '1',
'created_at' => '1402312317',
'updated_at' => '1402312317',
],
[
//password
'password_hash' => '$2y$13$k2hQ8aV/z6V0Y7pRbq1ufOUSaJI7EhNvvTUIoj2s/rxAmgyY95KPa',
'email' => 'user@example.com',
'auth_key' => '1',
'status' => '1',
'created_at' => '1402312317',
'updated_at' => '1402312317',
],
];

65
common/jobs/EmailJob.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
namespace common\jobs;
use Yii;
use yii\base\BaseObject;
use yii\queue\RetryableJobInterface;
class EmailJob extends BaseObject implements RetryableJobInterface
{
const MAX_ATTEMPTS = 3;
const TTR = 600; // in seconds
public const PASSWORD_RESET = 'password-reset';
public const PASSWORD_HAS_BEEN_RESET = 'password-has-been-reset';
public const WELCOME_EMAIL = 'welcome-email';
public const VERIFY_EMAIL = 'verify-email';
public const ADMIN_NOTIFY = 'admin-new-user';
public const PRICE_CHANGE = 'price-change';
public const PAYOUT_NOTIFY = 'payout-notify';
public array $templateModel;
public string $email;
public string $templateAlias;
/**
* @inheritDoc
*/
public function execute($queue)
{
// Merge these values which are always on emails
$this->templateModel = array_merge($this->templateModel, [
"product" => Yii::$app->params['product_name'],
"product_name" => Yii::$app->params['product_name'],
"support_url" => Yii::$app->params['support_url'],
"product_url" => Yii::$app->urlManager->createAbsoluteUrl(['site/index']),
"company_name" => Yii::$app->params['company_name'],
"company_address" => Yii::$app->params['company_address'],
]);
Yii::$app->postmark->client->sendEmailWithTemplate(
Yii::$app->params['supportEmail'],
$this->email,
$this->templateAlias,
$this->templateModel,
messageStream: Yii::$app->params['postmark.messageStream']
);
}
/**
* @inheritDoc
*/
public function getTtr()
{
return self::TTR;
}
/**
* @inheritDoc
*/
public function canRetry($attempt, $error)
{
return ($attempt < self::MAX_ATTEMPTS);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace common\models;
use yii\db\ActiveRecord;
class AuthAssignment extends ActiveRecord
{
public static function tableName()
{
return '{{%auth_assignment}}';
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace common\models;
use Yii;
use yii\base\Model;
/**
* Login form
*/
class LoginForm extends Model
{
public $email;
public $password;
public $rememberMe = true;
private $_user;
/**
* {@inheritdoc}
*/
public function rules()
{
return [
// username and password are both required
[['email', 'password'], 'required'],
// rememberMe must be a boolean value
['rememberMe', 'boolean'],
// password is validated by validatePassword()
['password', 'validatePassword'],
];
}
/**
* Validates the password.
* This method serves as the inline validation for password.
*
* @param string $attribute the attribute currently being validated
* @param array $params the additional name-value pairs given in the rule
*/
public function validatePassword($attribute, $params)
{
if (!$this->hasErrors()) {
$user = $this->getUser();
if (!$user || !$user->validatePassword($this->password)) {
$this->addError($attribute, 'Incorrect email or password.');
}
}
}
/**
* Logs in a user using the provided email and password.
*
* @return bool whether the user is logged in successfully
*/
public function login()
{
if ($this->validate()) {
return Yii::$app->user->login($this->getUser(), $this->rememberMe ? 3600 * 24 * 30 : 0);
}
return false;
}
/**
* Finds user by [[email]]
*
* @return User|null
*/
protected function getUser()
{
if ($this->_user === null) {
$this->_user = User::findByEmail($this->email);
}
return $this->_user;
}
}

61
common/models/Meal.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
namespace common\models;
/**
* This is the model class for table "meal".
*
* @property int $id
* @property string $file_name
* @property int $calories
* @property int $protein
* @property int $fat
* @property int $carbohydrates
* @property int $fiber
* @property int $meal
* @property int $created_at
* @property int $updated_at
*/
class Meal extends \yii\db\ActiveRecord
{
/**
* {@inheritdoc}
*/
public static function tableName()
{
return 'meal';
}
/**
* {@inheritdoc}
*/
public function rules()
{
return [
[['file_name', 'calories', 'protein', 'fat', 'carbohydrates', 'fiber', 'meal', 'created_at', 'updated_at'], 'required'],
[['calories', 'protein', 'fat', 'carbohydrates', 'fiber', 'meal', 'created_at', 'updated_at'], 'integer'],
[['file_name'], 'string', 'max' => 255],
];
}
/**
* {@inheritdoc}
*/
public function attributeLabels()
{
return [
'id' => 'ID',
'file_name' => 'File Name',
'calories' => 'Calories',
'protein' => 'Protein',
'fat' => 'Fat',
'carbohydrates' => 'Carbohydrates',
'fiber' => 'Fiber',
'meal' => 'Meal',
'created_at' => 'Created At',
'updated_at' => 'Updated At',
];
}
}

332
common/models/User.php Normal file
View File

@@ -0,0 +1,332 @@
<?php
namespace common\models;
use common\jobs\EmailJob;
use Yii;
use yii\base\NotSupportedException;
use yii\behaviors\TimestampBehavior;
use yii\db\ActiveRecord;
use yii\db\AfterSaveEvent;
use yii\web\IdentityInterface;
/**
* User model
*
* @property integer $id
* @property string $password_hash
* @property string $password_reset_token
* @property string $verification_token
* @property string $email
* @property string $first_name
* @property string $name
* @property string $auth_key
* @property integer $status
* @property integer $sales_agent_id
* @property integer $created_at
* @property integer $updated_at
* @property string $password write-only password
*/
class User extends ActiveRecord implements IdentityInterface
{
// User statuses
public const STATUS_UNVERIFIED = 10;
public const STATUS_INACTIVE = 0;
public const STATUS_ACTIVE = 1;
public const STATUS_VERIFIED = 2;
public const PAYOUT_INTERVAL_MONTHLY = 0;
public array $userStatusArray;
public string $role = '';
public bool $welcomeEmailSent = false;
public string $firstName;
public function init()
{
parent::init();
$this->userStatusArray = [
self::STATUS_UNVERIFIED => Yii::t('app', 'Unverified'),
self::STATUS_INACTIVE => Yii::t('app', 'Inactive'),
self::STATUS_ACTIVE => Yii::t('app', 'Active'),
self::STATUS_VERIFIED => Yii::t('app', 'Verified (not active)'),
];
// register event
$this->on(self::EVENT_AFTER_INSERT, [$this, 'emailTrigger']);
$this->on(self::EVENT_AFTER_UPDATE, [$this, 'emailTrigger']);
}
public function emailTrigger(AfterSaveEvent $event)
{
if ($event->sender->status == self::STATUS_ACTIVE && !$event->sender->welcome_email_sent) {
Yii::$app->queue->push(new EmailJob([
'templateAlias' => EmailJob::WELCOME_EMAIL,
'email' => $event->sender->email,
'templateModel' => [
"name" => $event->sender->first_name,
"action_url" => Yii::$app->urlManager->createAbsoluteUrl(['site/login']),
]
]));
$event->sender->welcome_email_sent = true;
$event->sender->save(false);
}
}
/**
* {@inheritdoc}
*/
public static function tableName()
{
return '{{%user}}';
}
/**
* {@inheritdoc}
*/
public function rules()
{
return [
['status', 'default', 'value' => self::STATUS_UNVERIFIED],
[['email'], 'email'],
[['email'], 'unique'],
[['sales_agent_id', 'created_at', 'updated_at'], 'integer'],
[
'sales_agent_id',
'required',
'when' => function ($model) {
return $model->role === 'sales-agent';
},
'whenClient' => "function (attribute, value) {
return $('#role').val() == 'sales-agent';
}"
],
[
'status',
'in',
'range' => [
self::STATUS_ACTIVE,
self::STATUS_UNVERIFIED,
self::STATUS_VERIFIED,
self::STATUS_INACTIVE,
]
],
[['role'], 'string'],
];
}
public function afterSave($insert, $changedAttributes)
{
$auth = Yii::$app->authManager;
// delete exiting roles if set
$auth->revokeAll($this->id);
// assign new role
if (!empty($this->role)) {
$auth->assign($auth->getRole($this->role), $this->id);
}
parent::afterSave($insert, $changedAttributes);
}
public function afterFind()
{
$rolesAssignedToUser = Yii::$app->authManager->getRolesByUser($this->id);
// we only use one role for the user
if (!empty($rolesAssignedToUser)) {
$this->role = array_key_first($rolesAssignedToUser);
}
parent::afterFind();
}
/**
* {@inheritdoc}
*/
public function behaviors()
{
return [
TimestampBehavior::class,
];
}
/**
* Get names for dropdown lists
*
* @param $dropdown
* @return array|mixed
*/
public function getStatusName($dropdown = false)
{
return $dropdown ? $this->userStatusArray : $this->userStatusArray[$this->status];
}
public function getAuthAssignment()
{
return $this->hasOne(\common\models\AuthAssignment::class, ['user_id' => 'id']);
}
/**
* {@inheritdoc}
*/
public static function findIdentity($id)
{
return static::findOne(['id' => $id, 'status' => self::STATUS_ACTIVE]);
}
/**
* Finds user by email
*
* @param string $email
* @return ActiveRecord|array|null
*/
public static function findByEmail($email)
{
return static::findOne(['email' => $email, 'status' => self::STATUS_ACTIVE]);
}
/**
* {@inheritdoc}
*/
public static function findIdentityByAccessToken($token, $type = null)
{
throw new NotSupportedException('"findIdentityByAccessToken" is not implemented.');
}
/**
* Finds user by password reset token
*
* @param string $token password reset token
* @return ActiveRecord|array|null
*/
public static function findByPasswordResetToken($token)
{
if (!static::isPasswordResetTokenValid($token)) {
return null;
}
return static::findOne(['password_reset_token' => $token, 'status' => self::STATUS_ACTIVE]);
}
/**
* Finds user by verification email token
*
* @param string $token verify email token
* @return static|null
*/
public static function findByVerificationToken($token)
{
return static::findOne([
'verification_token' => $token,
'status' => self::STATUS_UNVERIFIED
]);
}
/**
* Finds out if password reset token is valid
*
* @param string $token password reset token
* @return bool
*/
public static function isPasswordResetTokenValid($token)
{
if (empty($token)) {
return false;
}
$timestamp = (int)substr($token, strrpos($token, '_') + 1);
$expire = Yii::$app->params['user.passwordResetTokenExpire'];
return $timestamp + $expire >= time();
}
/**
* {@inheritdoc}
*/
public function getId()
{
return $this->getPrimaryKey();
}
/**
* {@inheritdoc}
*/
public function getAuthKey()
{
return $this->auth_key;
}
/**
* {@inheritdoc}
*/
public function validateAuthKey($authKey)
{
return $this->getAuthKey() === $authKey;
}
/**
* Validates password
*
* @param string $password password to validate
* @return bool if password provided is valid for current user
*/
public function validatePassword($password)
{
return Yii::$app->security->validatePassword($password, $this->password_hash);
}
/**
* Generates password hash from password and sets it to the model
*
* @param string $password
*/
public function setPassword($password)
{
$this->password_hash = Yii::$app->security->generatePasswordHash($password);
}
/**
* Generates "remember me" authentication key
*/
public function generateAuthKey()
{
$this->auth_key = Yii::$app->security->generateRandomString();
}
/**
* Generates new password reset token
*/
public function generatePasswordResetToken()
{
$this->password_reset_token = Yii::$app->security->generateRandomString() . '_' . time();
}
/**
* Generates new token for email verification
*/
public function generateEmailVerificationToken()
{
$this->verification_token = Yii::$app->security->generateRandomString() . '_' . time();
}
/**
* Removes password reset token
*/
public function removePasswordResetToken()
{
$this->password_reset_token = null;
}
/**
* Gets query for [[SalesAgent]].
*
* @return \yii\db\ActiveQuery|yii\db\ActiveQuery
*/
public function getSalesAgent()
{
return $this->hasOne(SalesAgent::class, ['id' => 'sales_agent_id']);
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace common\models\search;
use common\models\User as UserModel;
use Yii;
use yii\base\Model;
use yii\data\ActiveDataProvider;
use common\models\SalesAgent;
class User extends UserModel
{
public string $role = '';
/**
* {@inheritdoc}
*/
public function rules()
{
return [
[['id', 'status'], 'integer'],
[['email', 'first_name', 'role'], 'safe'],
];
}
/**
* Creates data provider instance with search query applied
*
* @param array $params
*
* @return ActiveDataProvider
*/
public function search($params)
{
$query = UserModel::find()
->joinWith('authAssignment', 'salesAgent');
$dataProvider = new ActiveDataProvider([
'query' => $query,
]);
$dataProvider->sort->attributes['salesAgentName'] = [
'asc' => ['sales_agent_id' => SORT_ASC],
'desc' => ['sales_agent_id' => SORT_DESC],
];
$dataProvider->sort->attributes['role'] = [
'asc' => ['auth_assignment.item_name' => SORT_ASC],
'desc' => ['auth_assignment.item_name' => SORT_DESC],
];
$this->load($params);
if (!$this->validate()) {
return $dataProvider;
}
if (!empty($this->salesAgentName)) {
$salesAgent = SalesAgent::find()->where(['name' => $this->salesAgentName])->one();
$this->sales_agent_id = $salesAgent ? $salesAgent->id : null;
}
$query->andFilterWhere([
'id' => $this->id,
'status' => $this->status,
'sales_agent_id' => $this->sales_agent_id,
'auth_assignment.item_name' => $this->role
]);
$query->andFilterWhere(['like', 'email', $this->email]);
return $dataProvider;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace common\rbac;
class SalesAgentRule extends \yii\rbac\Rule
{
public $name = 'assignedToSalesAgent';
/**
* @inheritDoc
*/
public function execute($user, $item, $params)
{
return isset($params['post']) && $params['post']->sales_agent_id == $user;
}
}

View File

@@ -0,0 +1,9 @@
<?php
defined('YII_DEBUG') or define('YII_DEBUG', true);
defined('YII_ENV') or define('YII_ENV', 'test');
defined('YII_APP_BASE_PATH') or define('YII_APP_BASE_PATH', __DIR__.'/../../');
require_once __DIR__ . '/../../vendor/autoload.php';
require_once __DIR__ . '/../../vendor/yiisoft/yii2/Yii.php';
require __DIR__ . '/../config/bootstrap.php';

View File

@@ -0,0 +1,23 @@
<?php
return [
[
'auth_key' => 'HP187Mvq7Mmm3CTU80dLkGmni_FUH_lR',
//password_0
'password_hash' => '$2y$13$.NVUBcghbQPiEcBh4LmI7.ctT3FDofwpgrKIAfwWvsr.wBM0l6Y7u',
'password_reset_token' => 'ExzkCOaYc1L8IOBs4wdTGGbgNiG3Wz1I_1402312317',
'created_at' => '1402312317',
'updated_at' => '1402312317',
'email' => 'nicole.paucek@schultz.info',
],
[
//password
'password_hash' => '$2y$13$k2hQ8aV/z6V0Y7pRbq1ufOUSaJI7EhNvvTUIoj2s/rxAmgyY95KPa',
'email' => 'admin@example.com',
'auth_key' => '1',
'status' => '1',
'sales_agent_id' => '0',
'created_at' => '1402312317',
'updated_at' => '1402312317',
],
];

2
common/tests/_output/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

1
common/tests/_support/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
_generated

View File

@@ -0,0 +1,26 @@
<?php
namespace common\tests;
/**
* Inherited Methods
* @method void wantToTest($text)
* @method void wantTo($text)
* @method void execute($callable)
* @method void expectTo($prediction)
* @method void verify($prediction)
* @method void amGoingTo($argumentation)
* @method void am($role)
* @method void lookForwardTo($achieveValue)
* @method void comment($description)
* @method \Codeception\Lib\Friend haveFriend($name, $actorClass = NULL)
*
* @SuppressWarnings(PHPMD)
*/
class UnitTester extends \Codeception\Actor
{
use _generated\UnitTesterActions;
/**
* Define custom actions here
*/
}

View File

@@ -0,0 +1,7 @@
suite_namespace: common\tests\unit
actor: UnitTester
bootstrap: false
modules:
enabled:
- Yii2:
part: fixtures

View File

@@ -0,0 +1,67 @@
<?php
namespace unit\models;
use Yii;
use common\models\LoginForm;
use common\fixtures\UserFixture;
/**
* Login form test
*/
class LoginFormTest extends \Codeception\Test\Unit
{
/**
* @var \common\tests\UnitTester
*/
protected $tester;
/**
* @return array
*/
public function _fixtures()
{
return [
'user' => [
'class' => UserFixture::class,
'dataFile' => codecept_data_dir() . 'user.php'
]
];
}
public function testLoginNoUser()
{
$model = new LoginForm([
'email' => 'not_existing_username',
'password' => 'not_existing_password',
]);
verify($model->login())->false();
verify(Yii::$app->user->isGuest)->true();
}
public function testLoginWrongPassword()
{
$model = new LoginForm([
'email' => 'nicole.paucek@schultz.info',
'password' => 'wrong_password',
]);
verify($model->login())->false();
verify( $model->errors)->arrayHasKey('password');
verify(Yii::$app->user->isGuest)->true();
}
public function testLoginCorrect()
{
$model = new LoginForm([
'email' => 'admin@example.com',
'password' => 'password',
]);
verify($model->login())->true();
verify($model->errors)->arrayHasNotKey('password');
verify(Yii::$app->user->isGuest)->false();
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace unit\traits;
use common\traits\FormattedDollarTrait;
use Yii;
use function PHPUnit\Framework\assertEquals;
/**
* Formatted dollar trait test
*/
class FormattedDollarTraitTest extends \Codeception\Test\Unit
{
/**
* @dataProvider floatDataProvider
* @return void
*/
public function testConvertToCents($test, $expected)
{
$mock = $this->getMockForTrait(FormattedDollarTrait::class);
$this->assertEquals($expected, $mock->convertToCents($test));
}
public function floatDataProvider()
{
return [
[12.445, 1244],
[-13.678901234, -1367],
["-10.4", -1040],
["-10", -1000],
["11.445", 1144],
["533.3.3533.11,445", 533335331144],
["1,40032,0030.445", 140032003044],
[124.99, 12499],
[-1.4, -140],
[14, 1400],
[.99, 99],
[2.3, 230],
[-30, -3000],
];
}
}

76
common/widgets/Alert.php Normal file
View File

@@ -0,0 +1,76 @@
<?php
namespace common\widgets;
use Yii;
/**
* Alert widget renders a message from session flash. All flash messages are displayed
* in the sequence they were assigned using setFlash. You can set message as following:
*
* ```php
* Yii::$app->session->setFlash('error', 'This is the message');
* Yii::$app->session->setFlash('success', 'This is the message');
* Yii::$app->session->setFlash('info', 'This is the message');
* ```
*
* Multiple messages could be set as follows:
*
* ```php
* Yii::$app->session->setFlash('error', ['Error 1', 'Error 2']);
* ```
*
* @author Kartik Visweswaran <kartikv2@gmail.com>
* @author Alexander Makarov <sam@rmcreative.ru>
*/
class Alert extends \yii\bootstrap5\Widget
{
/**
* @var array the alert types configuration for the flash messages.
* This array is setup as $key => $value, where:
* - key: the name of the session flash variable
* - value: the bootstrap alert type (i.e. danger, success, info, warning)
*/
public $alertTypes = [
'error' => 'alert-danger',
'danger' => 'alert-danger',
'success' => 'alert-success',
'info' => 'alert-info',
'warning' => 'alert-warning'
];
/**
* @var array the options for rendering the close button tag.
* Array will be passed to [[\yii\bootstrap\Alert::closeButton]].
*/
public $closeButton = [];
/**
* {@inheritdoc}
*/
public function run()
{
$session = Yii::$app->session;
$flashes = $session->getAllFlashes();
$appendClass = isset($this->options['class']) ? ' ' . $this->options['class'] : '';
foreach ($flashes as $type => $flash) {
if (!isset($this->alertTypes[$type])) {
continue;
}
foreach ((array) $flash as $i => $message) {
echo \yii\bootstrap5\Alert::widget([
'body' => $message,
'closeButton' => $this->closeButton,
'options' => array_merge($this->options, [
'id' => $this->getId() . '-' . $type . '-' . $i,
'class' => $this->alertTypes[$type] . $appendClass,
]),
]);
}
$session->removeFlash($type);
}
}
}

57
composer.json Normal file
View File

@@ -0,0 +1,57 @@
{
"license": "proprietary",
"require": {
"php": ">=7.4.0",
"ext-json": "*",
"yiisoft/yii2": "~2.0.45",
"yiisoft/yii2-bootstrap5": "@dev",
"yiisoft/yii2-authclient": "^2.2",
"yiisoft/yii2-httpclient": "~2.0",
"cgsmith/yii2-flatpickr-widget": "~1.1",
"yiisoft/yii2-queue": "^2.3",
"wildbit/postmark-php": "^6.0",
"donatj/phpuseragentparser": "^1.9",
"twbs/bootstrap-icons": "^1.11",
"sentry/sentry": "^4.9",
"kartik-v/yii2-widget-select2": "^2.2",
"kartik-v/yii2-grid": "dev-master",
"kartik-v/yii2-editable": "^1.8",
"kartik-v/yii2-icons": "^1.4"
},
"require-dev": {
"codeception/codeception": "^5.1",
"codeception/lib-innerbrowser": "^3.0 || ^1.1",
"codeception/module-asserts": "^3.0 || ^1.1",
"codeception/module-filesystem": "^3.0 || ^1.1",
"codeception/module-yii2": "^1.1",
"codeception/specify": "^2.0",
"codeception/verify": "^3.0",
"deployer/deployer": "^7.4",
"phpunit/phpunit": "~9.5.0",
"symfony/browser-kit": "^6.0 || >=2.7 <=4.2.4",
"yiisoft/yii2-debug": "~2.1.0",
"yiisoft/yii2-faker": "~2.0.0",
"yiisoft/yii2-gii": "~2.2.0"
},
"config": {
"sort-packages": true,
"allow-plugins": {
"yiisoft/yii2-composer": true
},
"process-timeout": 1800,
"fxp-asset": {
"enabled": false
}
},
"scripts": {
"test": [
"phpunit"
]
},
"repositories": [
{
"type": "composer",
"url": "https://asset-packagist.org"
}
]
}

6917
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

3
console/config/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
main-local.php
params-local.php
test-local.php

View File

@@ -0,0 +1,2 @@
<?php
Yii::setAlias('@console', dirname(__DIR__));

58
console/config/main.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
use yii\console\controllers\FixtureController;
use yii\console\controllers\MigrateController;
use yii\log\FileTarget;
use function Sentry\init;
$params = array_merge(
require __DIR__ . '/../../common/config/params.php',
require __DIR__ . '/../../common/config/params-local.php',
require __DIR__ . '/params.php',
require __DIR__ . '/params-local.php'
);
init([
'dsn' => $params['sentry.dsn'],
// Specify a fixed sample rate
'traces_sample_rate' => 1.0,
// Set a sampling rate for profiling - this is relative to traces_sample_rate
'profiles_sample_rate' => 1.0,
]);
return [
'id' => 'app-console',
'basePath' => dirname(__DIR__),
'bootstrap' => ['log', 'queue'],
'controllerNamespace' => 'console\controllers',
'aliases' => [
'@bower' => '@vendor/bower-asset',
'@npm' => '@vendor/npm-asset',
],
'controllerMap' => [
'fixture' => [
'class' => FixtureController::class,
'namespace' => 'common\fixtures',
],
'migrate' => [
'class' => MigrateController::class,
'newFileOwnership' => '1000:1000', # Default WSL user id
'newFileMode' => 0660,
'migrationPath' => [
'@app/migrations',
'@yii/rbac/migrations',
],
],
],
'components' => [
'log' => [
'targets' => [
[
'class' => FileTarget::class,
'levels' => ['error', 'warning'],
],
],
]
],
'params' => $params,
];

View File

@@ -0,0 +1,5 @@
<?php
return [
'adminEmail' => 'admin@example.com',
];

4
console/config/test.php Normal file
View File

@@ -0,0 +1,4 @@
<?php
return [
];

View File

@@ -0,0 +1,96 @@
<?php
namespace console\controllers;
use yii\console\{Controller, ExitCode};
// To create/edit crontab file: crontab -e
// To list: crontab -l
// // m h dom mon dow command
// */5 * * * * /var/www/html/yii cron/frequent
// */15 * * * * /var/www/html/yii cron/quarter
// */30 * * * * /var/www/html/yii cron/halfhourly
// 0 * * * * /var/www/html/yii cron/hourly
// 15 1 * * * /var/www/html/yii cron/overnight
// 15 3 * * 5 /var/www/html/yii cron/weekly
/**
* Class CronController
*
* @package console\controllers
*/
class CronController extends Controller
{
/**
* @var boolean whether to run the command interactively.
*/
public $interactive = false;
/**
* Action Index
* @return int exit code
*/
public function actionIndex()
{
$this->stdout("Yes, service cron is running\n");
return ExitCode::OK;
}
/**
* Action Frequent
* Called every five minutes
* @return int exit code
*/
public function actionFrequent()
{
return ExitCode::OK;
}
/**
* Action Quarter
* Called every fifteen minutes
*
* @return int exit code
*/
public function actionQuarter()
{
//
return ExitCode::OK;
}
/**
* Action Half Hourly
* Called every 30 minutes
*
* @return int exit code
*/
public function actionHalfhourly()
{
return ExitCode::OK;
}
/**
* Action Hourly
* @return int exit code
*/
public function actionHourly()
{
return ExitCode::OK;
}
/**
* Action Overnight
* Called every night
*
* @return int exit code
*/
public function actionOvernight()
{
return ExitCode::OK;
}
}

View File

@@ -0,0 +1,45 @@
<?php
use common\models\User;
use yii\db\Migration;
/**
* Handles the creation of table `{{%user}}`.
*/
class m221203_160610_create_user_table extends Migration
{
/**
* {@inheritdoc}
*/
public function safeUp()
{
$tableOptions = null;
if ($this->db->driverName === 'mysql') {
// http://stackoverflow.com/questions/766809/whats-the-difference-between-utf8-general-ci-and-utf8-unicode-ci
$tableOptions = 'CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB';
}
$this->createTable('{{%user}}', [
'id' => $this->primaryKey(),
'auth_key' => $this->string(32)->notNull(),
'password_hash' => $this->string()->notNull(),
'password_reset_token' => $this->string()->unique(),
'verification_token' => $this->string()->defaultValue(null),
'first_name' => $this->string(64),
'email' => $this->string()->notNull()->unique(),
'status' => $this->smallInteger()->notNull()->defaultValue(User::STATUS_UNVERIFIED),
'welcome_email_sent' => $this->boolean()->defaultValue(false),
'created_at' => $this->integer()->notNull(),
'updated_at' => $this->integer()->notNull(),
], $tableOptions);
}
/**
* {@inheritdoc}
*/
public function safeDown()
{
$this->dropTable('{{%user}}');
}
}

View File

@@ -0,0 +1,87 @@
<?php
use yii\db\Migration;
/**
* Class m230120_214209_init_rbac
*/
class m230120_214209_init_rbac extends Migration
{
public function up()
{
$auth = Yii::$app->authManager;
/**
* Permissions for future use?
* Maybe...
*/
// add "view meal" permission
$viewMeal = $auth->createPermission('viewMeal');
$viewMeal->description = 'View own meal records';
$auth->add($viewMeal);
// add "create meal" permission
$createMeal = $auth->createPermission('createMeal');
$createMeal->description = 'Create a new meal record';
$auth->add($createMeal);
// add "update meal" permission
$updateMeal = $auth->createPermission('updateMeal');
$updateMeal->description = 'Update own meal records';
$auth->add($updateMeal);
// add "delete meal" permission
$deleteMeal = $auth->createPermission('deleteMeal');
$deleteMeal->description = 'Delete own meal records';
$auth->add($deleteMeal);
// add "view all meals" permission (for admin)
$viewAllMeals = $auth->createPermission('viewAllMeals');
$viewAllMeals->description = 'View all meal records';
$auth->add($viewAllMeals);
// add "update all meals" permission (for admin)
$updateAllMeals = $auth->createPermission('updateAllMeals');
$updateAllMeals->description = 'Update any meal record';
$auth->add($updateAllMeals);
// add "delete all meals" permission (for admin)
$deleteAllMeals = $auth->createPermission('deleteAllMeals');
$deleteAllMeals->description = 'Delete any meal record';
$auth->add($deleteAllMeals);
// Add roles
$user = $auth->createRole('user');
$auth->add($user);
$admin = $auth->createRole('admin');
$auth->add($admin);
// Add permissions to roles (crucially important):
$auth->addChild($user, $viewMeal);
$auth->addChild($user, $createMeal);
$auth->addChild($user, $updateMeal);
$auth->addChild($user, $deleteMeal);
$auth->addChild($admin, $viewMeal);
$auth->addChild($admin, $createMeal);
$auth->addChild($admin, $updateMeal);
$auth->addChild($admin, $deleteMeal);
$auth->addChild($admin, $viewAllMeals);
$auth->addChild($admin, $updateAllMeals);
$auth->addChild($admin, $deleteAllMeals);
// Assign roles to users. 1 and 2 are IDs returned by IdentityInterface::getId()
// usually implemented in your User model.
$auth->assign($admin, 1);
}
public function down()
{
$auth = Yii::$app->authManager;
$auth->removeAll();
}
}

View File

@@ -0,0 +1,47 @@
<?php
use yii\db\Migration;
/**
* Class m230210_155341_queue_table
*/
class m230210_155341_queue_table extends Migration
{
public $tableName = '{{%queue}}';
/**
* {@inheritdoc}
*/
public function safeUp()
{
$tableOptions = null;
if ($this->db->driverName === 'mysql') {
// http://stackoverflow.com/questions/766809/whats-the-difference-between-utf8-general-ci-and-utf8-unicode-ci
$tableOptions = 'CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB';
}
$this->createTable($this->tableName, [
'id' => $this->primaryKey(),
'channel' => $this->string()->notNull(),
'job' => $this->binary()->notNull(),
'pushed_at' => $this->integer()->notNull(),
'ttr' => $this->integer()->notNull(),
'delay' => $this->integer()->notNull(),
'priority' => $this->integer()->unsigned()->notNull()->defaultValue(1024),
'reserved_at' => $this->integer(),
'attempt' => $this->integer(),
'done_at' => $this->integer(),
], $tableOptions);
$this->createIndex('channel', $this->tableName, 'channel');
$this->createIndex('reserved_at', $this->tableName, 'reserved_at');
$this->createIndex('priority', $this->tableName, 'priority');
}
/**
* {@inheritdoc}
*/
public function safeDown()
{
$this->dropTable($this->tableName);
}
}

View File

@@ -0,0 +1,36 @@
<?php
use yii\db\Migration;
/**
* Handles the creation of table `{{%meal}}`.
*/
class m250219_133939_create_meal_table extends Migration
{
/**
* {@inheritdoc}
*/
public function safeUp()
{
$this->createTable('{{%meal}}', [
'id' => $this->primaryKey(),
'file_name' => $this->string()->notNull(),
'calories' => $this->integer()->notNull(),
'protein' => $this->integer()->notNull(),
'fat' => $this->integer()->notNull(),
'carbohydrates' => $this->integer()->notNull(),
'fiber' => $this->integer()->notNull(),
'meal' => $this->integer()->notNull(),
'created_at' => $this->integer()->notNull(),
'updated_at' => $this->integer()->notNull(),
]);
}
/**
* {@inheritdoc}
*/
public function safeDown()
{
$this->dropTable('{{%meal}}');
}
}

1
console/models/.gitkeep Normal file
View File

@@ -0,0 +1 @@
*

2
console/runtime/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

54
deploy.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
namespace Deployer;
require 'recipe/yii.php';
// Config
set('repository', 'git@github.com:cgsmith/calorie.git');
add('shared_files', [
//'yii',
'common/config/main-local.php',
'common/config/params-local.php',
'frontend/config/main-local.php',
'frontend/config/params-local.php',
]);
add('shared_dirs', []);
add('writable_dirs', []);
// Hosts
host('calorie')
->set('remote_user', 'root')
->set('deploy_path', '/var/www/calorie')
->set('environment', 'Production')
->setLabels([
'env' => 'prod',
]);
host('test.calorie')
->set('composer_options', '--verbose --prefer-dist --no-progress --no-interaction')
->set('remote_user', 'root')
->set('deploy_path', '/var/www/test.calorie')
->set('environment', 'Testing')
->setLabels([
'env' => 'test',
]);
// Tasks
task('init-app', function () {
run('cd {{release_or_current_path}} && {{bin/php}} init --env={{environment}} --overwrite=n');
});
desc('Restart yii queue workers');
task('yii:queue:restart', function () {
run('systemctl restart yii-queue@*');
});
task('deploy:prod', function() {
invoke('yii:queue:restart');
})->select('env=prod');
// Hooks
after('deploy:vendors', 'init-app');
after('deploy:failed', 'deploy:unlock');
after('deploy', 'deploy:prod');

27
docker-compose.yml Normal file
View File

@@ -0,0 +1,27 @@
services:
frontend:
build: frontend
ports:
- "20080:80"
environment:
- PHP_ENABLE_XDEBUG=1
volumes:
# Re-use local composer cache via host-volume
- ~/.composer-docker/cache:/root/.composer/cache:delegated
# Mount source-code for development
- ./:/app
extra_hosts: # https://stackoverflow.com/a/67158212/1106908
- "host.docker.internal:host-gateway"
mysql:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: 123
MYSQL_DATABASE: app
MYSQL_USER: app
MYSQL_PASSWORD: 123
ports:
- "20083:3306"
volumes:
- ./mysql-data/var/lib/mysql:/var/lib/mysql

View File

@@ -0,0 +1,16 @@
<?php
return yii\helpers\ArrayHelper::merge(
require __DIR__ . '/main.php',
require __DIR__ . '/main-local.php',
require __DIR__ . '/test.php',
require __DIR__ . '/test-local.php',
[
'components' => [
'request' => [
// !!! insert a secret key in the following (if it is empty) - this is required by cookie validation
'cookieValidationKey' => '',
],
],
]
);

View File

@@ -0,0 +1,23 @@
<?php
return [
'components' => [
'db' => [
'class' => \yii\db\Connection::class,
'dsn' => 'mysql:host=mysql;dbname=app',
'username' => 'app',
'password' => '123',
'charset' => 'utf8',
],
'queue' => [
'class' => \yii\queue\sync\Queue::class,
'handle' => true, // whether tasks should be executed immediately
],
'mailer' => [
'class' => \yii\symfonymailer\Mailer::class,
'viewPath' => '@common/mail',
// send all mails to a file by default.
'useFileTransport' => true,
],
],
];

View File

@@ -0,0 +1,8 @@
<?php
return [
'company_name' => 'Five Devs',
'product_name' => 'Calorie',
'sonar.url' => 'https://yourname.sonar.software', # no trailing slash
'sonar.bearerToken' => '',
];

View File

@@ -0,0 +1,9 @@
<?php
return [
'components' => [
'db' => [
'dsn' => 'mysql:host=localhost;dbname=yii2advanced_test',
],
],
];

View File

@@ -0,0 +1,8 @@
<?php
return [
'bootstrap' => ['gii'],
'modules' => [
'gii' => 'yii\gii\Module',
],
];

View File

@@ -0,0 +1,4 @@
<?php
return [
];

View File

@@ -0,0 +1,4 @@
<?php
return [
];

View File

@@ -0,0 +1,11 @@
<?php
return yii\helpers\ArrayHelper::merge(
require dirname(dirname(__DIR__)) . '/common/config/codeception-local.php',
require __DIR__ . '/main.php',
require __DIR__ . '/main-local.php',
require __DIR__ . '/test.php',
require __DIR__ . '/test-local.php',
[
]
);

View File

@@ -0,0 +1,27 @@
<?php
$config = [
'components' => [
'request' => [
// !!! insert a secret key in the following (if it is empty) - this is required by cookie validation
'cookieValidationKey' => '',
],
],
];
if (!YII_ENV_TEST) {
// configuration adjustments for 'dev' environment
$config['bootstrap'][] = 'debug';
$config['modules']['debug'] = [
'class' => \yii\debug\Module::class,
'allowedIPs' => ['*'],
];
$config['bootstrap'][] = 'gii';
$config['modules']['gii'] = [
'class' => \yii\gii\Module::class,
'allowedIPs' => ['*'],
];
}
return $config;

View File

@@ -0,0 +1,4 @@
<?php
return [
];

View File

@@ -0,0 +1,4 @@
<?php
return [
];

View File

@@ -0,0 +1,28 @@
<?php
// NOTE: Make sure this file is not accessible when deployed to production
if (!in_array(@$_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1'])) {
die('You are not allowed to access this file.');
}
defined('YII_DEBUG') or define('YII_DEBUG', true);
defined('YII_ENV') or define('YII_ENV', 'test');
require __DIR__ . '/../../vendor/autoload.php';
require __DIR__ . '/../../vendor/yiisoft/yii2/Yii.php';
require __DIR__ . '/../../common/config/bootstrap.php';
require __DIR__ . '/../config/bootstrap.php';
$config = yii\helpers\ArrayHelper::merge(
require __DIR__ . '/../../common/config/main.php',
require __DIR__ . '/../../common/config/main-local.php',
require __DIR__ . '/../../common/config/test.php',
require __DIR__ . '/../../common/config/test-local.php',
require __DIR__ . '/../config/main.php',
require __DIR__ . '/../config/main-local.php',
require __DIR__ . '/../config/test.php',
require __DIR__ . '/../config/test-local.php'
);
(new yii\web\Application($config))->run();

View File

@@ -0,0 +1,18 @@
<?php
defined('YII_DEBUG') or define('YII_DEBUG', true);
defined('YII_ENV') or define('YII_ENV', 'dev');
require __DIR__ . '/../../vendor/autoload.php';
require __DIR__ . '/../../vendor/yiisoft/yii2/Yii.php';
require __DIR__ . '/../../common/config/bootstrap.php';
require __DIR__ . '/../config/bootstrap.php';
$config = yii\helpers\ArrayHelper::merge(
require __DIR__ . '/../../common/config/main.php',
require __DIR__ . '/../../common/config/main-local.php',
require __DIR__ . '/../config/main.php',
require __DIR__ . '/../config/main-local.php'
);
(new yii\web\Application($config))->run();

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

Some files were not shown because too many files have changed in this diff Show More