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.

Want to chat?

Drop me an email [email protected]