namespace App\Http\Services;
use App\Jobs\Cpd\ProcessPublishedEvidence;
use App\Models\Evidence;
class EvidenceToleranceService
{
private const MINUTES_PER_DAY = 1440;
/**
* @return array{
* ok: bool,
* allowed_min: ?float,
* allowed_max: ?float,
* tolerance_minutes: ?float,
* outside_by_minutes: float,
* direction: 'inside'|'below'|'above'|'no_range'|'invalid_config'|'invalid_value'
* }
*/
public function checkMinutesLogged(Evidence $evidence): array
{
$durationMinutes = (float) $evidence->duration_minutes;
$unit = $evidence->cpdCategory->unit;
$expectedMinMinutes = $evidence->cpdCategory->expected_min_duration_minutes;
$expectedMaxMinutes = $evidence->cpdCategory->expected_max_duration_minutes;
if ($unit === 'single') {
if ($evidence->points_earned === $evidence->cpdCategory->expected_points) {
return [
'ok' => true,
'allowed_min' => null,
'allowed_max' => null,
'tolerance_minutes' => null,
'outside_by_minutes' => 0.0,
'direction' => 'inside',
];
} else {
return [
'ok' => false,
'allowed_min' => null,
'allowed_max' => null,
'tolerance_minutes' => null,
'outside_by_minutes' => 0.0,
'direction' => 'invalid_points',
];
}
}
// Basic value sanity
if ($durationMinutes < 0) {
return [
'ok' => false,
'allowed_min' => null,
'allowed_max' => null,
'tolerance_minutes' => null,
'outside_by_minutes' => 0.0,
'direction' => 'invalid_value',
];
}
if ($unit === 'day') {
$remainder = fmod($durationMinutes, self::MINUTES_PER_DAY);
$epsilon = 0.000001;
if ($remainder > $epsilon && (self::MINUTES_PER_DAY - $remainder) > $epsilon) {
return [
'ok' => false,
'allowed_min' => null,
'allowed_max' => null,
'tolerance_minutes' => 0.0,
'outside_by_minutes' => 0.0,
'direction' => 'invalid_value',
];
}
}
if ($expectedMinMinutes === null || $expectedMaxMinutes === null) {
return [
'ok' => true,
'allowed_min' => null,
'allowed_max' => null,
'tolerance_minutes' => null,
'outside_by_minutes' => 0.0,
'direction' => 'no_range',
];
}
$min = (float) $expectedMinMinutes;
$max = (float) $expectedMaxMinutes;
// Bad config: min > max
if ($min > $max) {
return [
'ok' => true,
'allowed_min' => null,
'allowed_max' => null,
'tolerance_minutes' => null,
'outside_by_minutes' => 0.0,
'direction' => 'invalid_config',
];
}
if ($unit === null || $min === null || $max === null) {
$tol = 0.0;
} else {
$tol = self::toleranceMinutes($unit, $min, $max);
}
$allowedMin = max(0.0, $min - $tol);
$allowedMax = $max + $tol;
if ($durationMinutes < $allowedMin) {
return [
'ok' => false,
'allowed_min' => $allowedMin,
'allowed_max' => $allowedMax,
'tolerance_minutes' => $tol,
'outside_by_minutes' => $allowedMin - $durationMinutes,
'direction' => 'below',
];
}
if ($durationMinutes > $allowedMax) {
return [
'ok' => false,
'allowed_min' => $allowedMin,
'allowed_max' => $allowedMax,
'tolerance_minutes' => $tol,
'outside_by_minutes' => $durationMinutes - $allowedMax,
'direction' => 'above',
];
}
return [
'ok' => true,
'allowed_min' => $allowedMin,
'allowed_max' => $allowedMax,
'tolerance_minutes' => $tol,
'outside_by_minutes' => 0.0,
'direction' => 'inside',
];
}
/**
* Tolerance in minutes around the expected [min,max] band.
*
* Since day-based categories only accept whole days, use *zero tolerance* and rely on:
* - the expected range itself, and
* - the "whole day multiple" validation.
*
* Hour categories get a small cushion: max(15 mins, 10% of range width).
*/
public static function toleranceMinutes(string $unit, float $expectedMinMinutes, float $expectedMaxMinutes): float
{
$rangeWidth = max(0.0, $expectedMaxMinutes - $expectedMinMinutes);
$relativeTol = round(0.10 * $rangeWidth); // 10% of range width
return match ($unit) {
'day' => 0.0,
'hour' => (float) max(15, $relativeTol),
default => (float) max(15, $relativeTol),
};
}
/**
* @return 'low'|'medium'|'high'|'none'
*/
public function minutesSeverity($unit, float $outsideByMinutes, $severity = 'none'): string
{
if (is_null($unit)) {
return ProcessPublishedEvidence::SEVERITY_NONE;
}
if ($outsideByMinutes <= 0) {
return ProcessPublishedEvidence::SEVERITY_NONE;
}
return match ($unit) {
'day' => $outsideByMinutes <= self::MINUTES_PER_DAY ? ProcessPublishedEvidence::SEVERITY_LOW
: ($outsideByMinutes <= 2 * self::MINUTES_PER_DAY ? ProcessPublishedEvidence::SEVERITY_MEDIUM : ProcessPublishedEvidence::SEVERITY_HIGH),
default => $outsideByMinutes <= 30 ? ProcessPublishedEvidence::SEVERITY_LOW
: ($outsideByMinutes <= 120 ? ProcessPublishedEvidence::SEVERITY_MEDIUM : ProcessPublishedEvidence::SEVERITY_HIGH),
};
}
public function checkTotalLoggedForDay(Evidence $evidence): array
{
$day = $evidence->recorded_at;
$user = $evidence->user;
$total_logged_minutes = (int) $user->evidence()->whereDate('recorded_at', $day)->sum('duration_minutes');
$result = [
'total' => $total_logged_minutes,
'ok' => true,
'severity' => ProcessPublishedEvidence::SEVERITY_NONE,
];
if ($total_logged_minutes > 1440) {
$result['ok'] = false;
$result['severity'] = ProcessPublishedEvidence::SEVERITY_CRITICAL;
}
if ($evidence->cpdCategory->unit === 'hour') {
if ($total_logged_minutes > 960) {
$result['ok'] = false;
$result['severity'] = ProcessPublishedEvidence::SEVERITY_HIGH;
}
if ($total_logged_minutes > 720) {
$result['ok'] = false;
$result['severity'] = ProcessPublishedEvidence::SEVERITY_MEDIUM;
}
}
return $result;
}
}
namespace App\Services;
use App\Models\Configuration;
class ConfigurationLoader
{
public function load(): void
{
$configurations = Configuration::query()->get();
$grouped = $configurations->groupBy('prefix');
foreach ($grouped as $prefix => $configs) {
config([
$prefix => $configs->pluck('value', 'name')->toArray(),
]);
}
}
}
namespace App\Http\Controllers;
use App\Http\Resources\FileResource;
use App\Models\File;
use App\Models\Log;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* @group File Upload
*
* API for uploading files
*
* @authenticated
*/
class FileController extends Controller
{
/**
* Upload File
*
* Upload a file to the server. The file will be stored with a unique name and associated with the authenticated user.
*
* @bodyParam file file required The file to upload.
*
* @response 200 {
* "message": "File uploaded successfully.",
* "file": {
* "id": 1,
* "name": "a1b2c3-example-avatar.jpg",
* "path": "storage/files/a1b2c3-example-avatar.jpg",
* "size": 204800,
* "user_id": 1,
* "file_type": "image",
* "created_at": "2024-01-15T10:30:00.000000Z",
* "updated_at": "2024-01-15T10:30:00.000000Z"
* }
* }
* @response 400 {
* "message": "No file was uploaded."
* }
*/
public function storeFile(Request $request)
{
if ($request->hasFile('file')) {
$public = $request->get('public', false);
$file = $request->file('file');
$uniqueFileName = Str::random(6).'-'.Str::slug($file->getClientOriginalName(), '-').'.'.$file->getClientOriginalExtension();
$mime_type = $file->getMimeType();
$s3Path = 'files/'.Auth::user()->api_id;
$key = $s3Path.'/'.$uniqueFileName;
Storage::disk('s3'.($public ? '_public' : ''))->putFileAs(
$s3Path,
$file,
$uniqueFileName
);
$payload = [
'name' => $uniqueFileName,
'path' => $key,
'size' => $file->getSize(),
'user_id' => Auth::user()->id,
'file_type' => explode('/', $mime_type)[0],
'api_id' => Str::ulid(),
'is_public' => $public ? 1 : 0,
];
$file = File::create($payload);
Log::create([
'action' => 'create',
'description' => 'Successful',
'loggable_id' => $file->id,
'loggable_type' => File::class,
'user_id' => Auth::user()->id,
'ip_address' => request()->ip(),
'meta' => json_encode($payload),
]);
return response()->json([
'message' => 'File uploaded successfully.',
'file' => new FileResource($file),
]);
}
return response()->json([
'message' => 'No file was uploaded.',
], 400);
}
}
namespace App\Http\Controllers;
use App\Models\Asset;
use App\Models\AssetFeature;
use App\Models\AssetRecommendationBox;
use App\Models\Country;
use App\Models\Education;
use App\Models\Faq;
use App\Models\Page;
use App\Models\Stock;
use App\Services\AssetService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class PageController extends Controller
{
public function showHomepage()
{
return Cache::remember(createCacheKey(), config('cache.public_file_cache_seconds'), function () {
$page = Page::where('slug', 'homepage')->first();
$articles = Education::where('is_translation', false)->paginate(20);
if ($articles) {
foreach ($articles as $article) {
$article->translate();
}
}
$faqs = Faq::where('is_translation', false)->paginate(4);
if ($faqs) {
foreach ($faqs as $faq) {
$faq->translate();
}
}
$tickers = Stock::where('is_ticker', true)->get();
$country = getUserLocation(false,false,true);
$service = new AssetService();
$guide_assets = $service->getBestAssetsAvailableInCountry($country['id'], 'total',4);
$currencies = Country::select('currency','currency_symbol')->where('currency', '<>','')->distinct('currency')->orderBy('currency','asc')->get();
$preferred_currencies = Country::select('currency','currency_symbol')
->whereIn('currency',['USD','GBP','EUR','DKK','AUD','NOK','SEK'])
->orderByRaw("FIELD(currency, 'USD', 'GBP', 'EUR', 'DKK', 'AUD', 'NOK', 'SEK')")
->distinct('currency')->get();
$leverages = AssetFeature::where('name', 'like', '%leverage%')->orderBy('order','desc')->get();
$highest_leverage = $service->getAssetsByLeverageSizeInCountry($country['id'], $leverages,4);
$homepage_assets = $service->getAssetsFeaturedOnHomepage();
return view('public.home', compact('homepage_assets','highest_leverage','leverages','page', 'preferred_currencies', 'currencies','articles', 'faqs', 'tickers', 'guide_assets'))->render() . '<!-- cached: ' . now()->format('H:i:s jS F Y') . '/' . createCacheKey() . ' -->';
});
}
public function HomepageData(){
return Cache::remember(createCacheKey() . '_homepage_data', config('cache.public_cache_seconds'), function () {
$page = Page::where('slug', 'homepage')->first();
$articles = Education::where('is_translation', false)->paginate(20);
if ($articles) {
foreach ($articles as $article) {
$article->translate();
}
}
$faqs = Faq::where('is_translation', false)->paginate(4);
if ($faqs) {
foreach ($faqs as $faq) {
$faq->translate();
}
}
$recommendation_boxes = AssetRecommendationBox::where('is_translation', false)->orderBy('order')->get();
if($recommendation_boxes){
foreach($recommendation_boxes as $recommendation_box){
$recommendation_box->translate();
}
}
$tickers = Stock::where('is_ticker', true)->get();
$country = getUserLocation(false,false,true);
$service = new AssetService();
$featured_assets = $service->getBestAssetsAvailableInCountry($country['id'], 'total',4);
$guide_assets = $service->getBestAssetsAvailableInCountry($country['id'], 'total',4, false,true);
$currencies = Country::select('currency','currency_symbol')->where('currency', '<>','')->distinct('currency')->orderBy('currency','asc')->get();
$preferred_currencies = Country::select('currency','currency_symbol')
->whereIn('currency',['USD','GBP','EUR','DKK','AUD','NOK','SEK'])
->orderByRaw("FIELD(currency, 'USD', 'GBP', 'EUR', 'DKK', 'AUD', 'NOK', 'SEK')")
->distinct('currency')->get();
$leverages = AssetFeature::where('name', 'like', '%leverage%')->orderBy('order','desc')->get();
$highest_leverage = $service->getAssetsByLeverageSizeInCountry($country['id'], $leverages,4);
$homepage_assets = $service->getAssetsFeaturedOnHomepage();
return [
'homepage_assets' => $homepage_assets,
'featured_assets' => $featured_assets,
'recommendation_boxes' => $recommendation_boxes,
'highest_leverage' => $highest_leverage,
'leverages' => $leverages,
'page' => $page,
'preferred_currencies' => $preferred_currencies,
'currencies' => $currencies,
'articles' => $articles,
'faqs' => $faqs,
'tickers' => $tickers,
'guide_assets' => $guide_assets
];
});
}
public function showCampaign(Request $request, $campaign){
if (!view()->exists('public.campaign.' . $campaign)) {
abort(404);
}
$utmParams = [
'utm_source' => $request->query('utm_source'),
'utm_medium' => $request->query('utm_medium'),
'utm_campaign' => $request->query('utm_campaign'),
'fbclid' => $request->query('fbclid'),
];
foreach (array_filter($utmParams) as $key => $value) {
session()->put($key, $value);
}
return view('public.campaign.' . $campaign, compact('utmParams'));
}
}
About me
I'm Danny, and I have written and rewritten this bio so many more
times
than I care to admit. Writing about yourself is hard, I find showing off my work hard.
But I'll give both a go...
I'm a 39 year-old Web Developer, I have over 17 years of
commercial experience under my
belt.
In that time I've built bespoke websites, CMSs, CRMs, APIs, Bespoke
solutions to
unusual problems, WordPress plugins, WordPress themes, some fun projects, and lots
more.
From developing a bridge connecting an offline stock management system with an eCommerce
website, to creating website builders, there's a lot I've done, a lot I've not done, but
I'm excited to do more.
Projects
I have developed a plethora of websites and applications, the majority of them for other agencies or
under NDA.
The ██████████ Project
I’d tell you about the project where I led the development in 5 systems that spoke to each other:
████ ██████████ (Laravel)
██████ ███ (Laravel)
██████ ██████ ███ (Laravel)
███ ██████ (Electron)
████ ██████████ (React)
and how the ██████ ███ would feed ████ data to the ████ ██████████ which would distribute ████ data
to multiple ███ ██████'s. I would love to tell you how the ████ ██████████ would control the ███ ██████'s with
controls being routed through ████ ██████████.
Not forgetting how ██████ ██████ ███ takes snapshots of ███████ █████ from ██████ ███ and makes them
publically accessible.
...but the legal team says if I said any of that I'd have to go live in a windowless room. So
instead, let's just imagine I was the conductor in a large ensemble.
But here's some buzz-words that you may have heard of: Full Stack, Zero Trust Architecture, Green Web
Dev, FinOps, DevOps, E2E encryption, Object Oriented Programming, Laravel, Wordpress, Rest API,
Tailwind, Vanilla Javascript, MVC framework.
CLOSE
Up,
Up,
Down,
Down,
Left,
Right,
Left,
Right,
B,
A,
Start