Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions backend/app/DomainObjects/Enums/OrganizerReportTypes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace HiEvents\DomainObjects\Enums;

enum OrganizerReportTypes: string
{
use BaseEnum;

case REVENUE_SUMMARY = 'revenue_summary';
case EVENTS_PERFORMANCE = 'events_performance';
case TAX_SUMMARY = 'tax_summary';
case CHECK_IN_SUMMARY = 'check_in_summary';
}
69 changes: 69 additions & 0 deletions backend/app/Http/Actions/Reports/GetOrganizerReportAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

namespace HiEvents\Http\Actions\Reports;

use HiEvents\DomainObjects\Enums\OrganizerReportTypes;
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Http\Request\Report\GetOrganizerReportRequest;
use HiEvents\Services\Application\Handlers\Reports\DTO\GetOrganizerReportDTO;
use HiEvents\Services\Application\Handlers\Reports\GetOrganizerReportHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;

class GetOrganizerReportAction extends BaseAction
{
public function __construct(private readonly GetOrganizerReportHandler $reportHandler)
{
}

/**
* @throws ValidationException
*/
public function __invoke(GetOrganizerReportRequest $request, int $organizerId, string $reportType): JsonResponse
{
$this->isActionAuthorized($organizerId, OrganizerDomainObject::class);

$this->validateDateRange($request);

if (!in_array($reportType, OrganizerReportTypes::valuesArray(), true)) {
throw new BadRequestHttpException(__('Invalid report type.'));
}

$reportData = $this->reportHandler->handle(
reportData: new GetOrganizerReportDTO(
organizerId: $organizerId,
reportType: OrganizerReportTypes::from($reportType),
startDate: $request->validated('start_date'),
endDate: $request->validated('end_date'),
currency: $request->validated('currency'),
),
);

return $this->jsonResponse(
data: $reportData,
wrapInData: true,
);
}

/**
* @throws ValidationException
*/
private function validateDateRange(GetOrganizerReportRequest $request): void
{
$startDate = $request->validated('start_date');
$endDate = $request->validated('end_date');

if (!$startDate || !$endDate) {
return;
}

$diffInDays = Carbon::parse($startDate)->diffInDays(Carbon::parse($endDate));

if ($diffInDays > 370) {
throw ValidationException::withMessages(['start_date' => __('Date range must be less than 370 days.')]);
}
}
}
17 changes: 17 additions & 0 deletions backend/app/Http/Request/Report/GetOrganizerReportRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace HiEvents\Http\Request\Report;

use HiEvents\Http\Request\BaseRequest;

class GetOrganizerReportRequest extends BaseRequest
{
public function rules(): array
{
return [
'start_date' => 'date|before:end_date|required_with:end_date|nullable',
'end_date' => 'date|after:start_date|required_with:start_date|nullable',
'currency' => 'string|size:3|nullable',
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace HiEvents\Services\Application\Handlers\Reports\DTO;

use HiEvents\DataTransferObjects\BaseDataObject;
use HiEvents\DomainObjects\Enums\OrganizerReportTypes;

class GetOrganizerReportDTO extends BaseDataObject
{
public function __construct(
public readonly int $organizerId,
public readonly OrganizerReportTypes $reportType,
public readonly ?string $startDate,
public readonly ?string $endDate,
public readonly ?string $currency,
)
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace HiEvents\Services\Application\Handlers\Reports;

use HiEvents\Services\Application\Handlers\Reports\DTO\GetOrganizerReportDTO;
use HiEvents\Services\Domain\Report\Factory\OrganizerReportServiceFactory;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;

class GetOrganizerReportHandler
{
public function __construct(
private readonly OrganizerReportServiceFactory $reportServiceFactory,
)
{
}

public function handle(GetOrganizerReportDTO $reportData): Collection
{
return $this->reportServiceFactory
->create($reportData->reportType)
->generateReport(
organizerId: $reportData->organizerId,
currency: $reportData->currency,
startDate: $reportData->startDate ? Carbon::parse($reportData->startDate) : null,
endDate: $reportData->endDate ? Carbon::parse($reportData->endDate) : null,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

namespace HiEvents\Services\Domain\Report;

use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
use Illuminate\Cache\Repository;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;

abstract class AbstractOrganizerReportService
{
private const CACHE_TTL_SECONDS = 30;

public function __construct(
private readonly Repository $cache,
private readonly DatabaseManager $queryBuilder,
private readonly OrganizerRepositoryInterface $organizerRepository,
)
{
}

public function generateReport(
int $organizerId,
?string $currency = null,
?Carbon $startDate = null,
?Carbon $endDate = null
): Collection
{
$organizer = $this->organizerRepository->findById($organizerId);
$timezone = $organizer->getTimezone();

$endDate = $endDate
? $endDate->copy()->setTimezone($timezone)->startOfDay()
: now($timezone)->startOfDay();
$startDate = $startDate
? $startDate->copy()->setTimezone($timezone)->startOfDay()
: $endDate->copy()->subDays(30)->startOfDay();

$reportResults = $this->cache->remember(
key: $this->getCacheKey($organizerId, $currency, $startDate, $endDate),
ttl: Carbon::now()->addSeconds(self::CACHE_TTL_SECONDS),
callback: fn() => $this->queryBuilder->select(
$this->getSqlQuery($startDate, $endDate, $currency),
[
'organizer_id' => $organizerId,
]
)
);

return collect($reportResults);
}

abstract protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?string $currency = null): string;

protected function buildCurrencyFilter(string $column, ?string $currency): string
{
if ($currency === null) {
return '';
}
$escapedCurrency = addslashes($currency);
return "AND $column = '$escapedCurrency'";
}

protected function getCacheKey(int $organizerId, ?string $currency, ?Carbon $startDate, ?Carbon $endDate): string
{
return static::class . "$organizerId.$currency.{$startDate?->toDateString()}.{$endDate?->toDateString()}";
}
}
8 changes: 6 additions & 2 deletions backend/app/Services/Domain/Report/AbstractReportService.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@ public function generateReport(int $eventId, ?Carbon $startDate = null, ?Carbon
$event = $this->eventRepository->findById($eventId);
$timezone = $event->getTimezone();

$endDate = Carbon::parse($endDate ?? now(), $timezone);
$startDate = Carbon::parse($startDate ?? $endDate->copy()->subDays(30), $timezone);
$endDate = $endDate
? $endDate->copy()->setTimezone($timezone)->startOfDay()
: now($timezone)->startOfDay();
$startDate = $startDate
? $startDate->copy()->setTimezone($timezone)->startOfDay()
: $endDate->copy()->subDays(30)->startOfDay();

$reportResults = $this->cache->remember(
key: $this->getCacheKey($eventId, $startDate, $endDate),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace HiEvents\Services\Domain\Report\Factory;

use HiEvents\DomainObjects\Enums\OrganizerReportTypes;
use HiEvents\Services\Domain\Report\AbstractOrganizerReportService;
use HiEvents\Services\Domain\Report\OrganizerReports\CheckInSummaryReport;
use HiEvents\Services\Domain\Report\OrganizerReports\EventsPerformanceReport;
use HiEvents\Services\Domain\Report\OrganizerReports\RevenueSummaryReport;
use HiEvents\Services\Domain\Report\OrganizerReports\TaxSummaryReport;
use Illuminate\Support\Facades\App;

class OrganizerReportServiceFactory
{
public function create(OrganizerReportTypes $reportType): AbstractOrganizerReportService
{
return match ($reportType) {
OrganizerReportTypes::REVENUE_SUMMARY => App::make(RevenueSummaryReport::class),
OrganizerReportTypes::EVENTS_PERFORMANCE => App::make(EventsPerformanceReport::class),
OrganizerReportTypes::TAX_SUMMARY => App::make(TaxSummaryReport::class),
OrganizerReportTypes::CHECK_IN_SUMMARY => App::make(CheckInSummaryReport::class),
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

namespace HiEvents\Services\Domain\Report\OrganizerReports;

use HiEvents\DomainObjects\Status\AttendeeStatus;
use HiEvents\Services\Domain\Report\AbstractOrganizerReportService;
use Illuminate\Support\Carbon;

class CheckInSummaryReport extends AbstractOrganizerReportService
{
protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?string $currency = null): string
{
$activeStatus = AttendeeStatus::ACTIVE->name;

return <<<SQL
WITH organizer_events AS (
SELECT id
FROM events
WHERE organizer_id = :organizer_id
AND deleted_at IS NULL
)
SELECT
e.id AS event_id,
e.title AS event_name,
e.start_date,
COALESCE(attendee_counts.total_attendees, 0) AS total_attendees,
COALESCE(checkin_counts.total_checked_in, 0) AS total_checked_in,
CASE
WHEN COALESCE(attendee_counts.total_attendees, 0) = 0 THEN 0
ELSE ROUND((COALESCE(checkin_counts.total_checked_in, 0)::numeric / attendee_counts.total_attendees) * 100, 1)
END AS check_in_rate,
COALESCE(list_counts.check_in_lists_count, 0) AS check_in_lists_count
FROM events e
LEFT JOIN (
SELECT
event_id,
COUNT(*) AS total_attendees
FROM attendees
WHERE event_id IN (SELECT id FROM organizer_events)
AND status = '$activeStatus'
AND deleted_at IS NULL
GROUP BY event_id
) attendee_counts ON e.id = attendee_counts.event_id
LEFT JOIN (
SELECT
event_id,
COUNT(DISTINCT attendee_id) AS total_checked_in
FROM attendee_check_ins
WHERE event_id IN (SELECT id FROM organizer_events)
AND deleted_at IS NULL
GROUP BY event_id
) checkin_counts ON e.id = checkin_counts.event_id
LEFT JOIN (
SELECT
event_id,
COUNT(*) AS check_in_lists_count
FROM check_in_lists
WHERE event_id IN (SELECT id FROM organizer_events)
AND deleted_at IS NULL
GROUP BY event_id
) list_counts ON e.id = list_counts.event_id
WHERE e.organizer_id = :organizer_id
AND e.deleted_at IS NULL
ORDER BY e.start_date DESC NULLS LAST
SQL;
}
}
Loading
Loading