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
16 changes: 16 additions & 0 deletions backend/app/DomainObjects/Enums/AttributionGroupBy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace HiEvents\DomainObjects\Enums;

enum AttributionGroupBy: string
{
use BaseEnum;

case SOURCE = 'source';
case MEDIUM = 'medium';
case CAMPAIGN = 'campaign';
case CONTENT = 'content';
case TERM = 'term';
case CTA = 'cta';
case SOURCE_TYPE = 'source_type';
}
12 changes: 12 additions & 0 deletions backend/app/DomainObjects/Enums/AttributionSourceType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace HiEvents\DomainObjects\Enums;

enum AttributionSourceType: string
{
use BaseEnum;

case PAID = 'paid';
case ORGANIC = 'organic';
case REFERRAL = 'referral';
}
3 changes: 2 additions & 1 deletion backend/app/Exports/AnswersExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@
use HiEvents\Exports\AnswerExportSheets\ProductAnswersSheet;
use HiEvents\Services\Domain\Question\QuestionAnswerFormatter;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\Export;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;

class AnswersExport implements WithMultipleSheets
class AnswersExport implements Export, WithMultipleSheets
{
private Collection $answers;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,27 @@

use HiEvents\DomainObjects\Enums\Role;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Http\Request\Admin\GetUtmAttributionStatsRequest;
use HiEvents\Services\Application\Handlers\Admin\DTO\GetUtmAttributionStatsDTO;
use HiEvents\Services\Application\Handlers\Admin\GetUtmAttributionStatsHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class GetUtmAttributionStatsAction extends BaseAction
{
public function __construct(
private readonly GetUtmAttributionStatsHandler $handler,
) {}

public function __invoke(Request $request): JsonResponse
public function __invoke(GetUtmAttributionStatsRequest $request): JsonResponse
{
$this->minimumAllowedRole(Role::SUPERADMIN);

$dto = GetUtmAttributionStatsDTO::from([
'group_by' => $request->query('group_by', 'source'),
'date_from' => $request->query('date_from'),
'date_to' => $request->query('date_to'),
'per_page' => (int) $request->query('per_page', 20),
'page' => (int) $request->query('page', 1),
'group_by' => $request->validated('group_by') ?? 'source',
'date_from' => $request->validated('date_from'),
'date_to' => $request->validated('date_to'),
'per_page' => (int) ($request->validated('per_page') ?? 20),
'page' => (int) ($request->validated('page') ?? 1),
]);

$result = $this->handler->handle($dto);
Expand Down
23 changes: 23 additions & 0 deletions backend/app/Http/Request/Admin/GetUtmAttributionStatsRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace HiEvents\Http\Request\Admin;

use HiEvents\DomainObjects\Enums\AttributionGroupBy;
use HiEvents\Http\Request\BaseRequest;
use Illuminate\Validation\Rule;

class GetUtmAttributionStatsRequest extends BaseRequest
{
public function rules(): array
{
return [
'group_by' => ['nullable', 'string', Rule::in(AttributionGroupBy::valuesArray())],
'date_from' => ['nullable', 'date'],
'date_to' => ['nullable', 'date', 'after_or_equal:date_from'],
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
];
}
}
159 changes: 115 additions & 44 deletions backend/app/Repository/Eloquent/AccountAttributionRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
namespace HiEvents\Repository\Eloquent;

use HiEvents\DomainObjects\AccountAttributionDomainObject;
use HiEvents\DomainObjects\Enums\AttributionGroupBy;
use HiEvents\DomainObjects\Enums\AttributionSourceType;
use HiEvents\DomainObjects\Status\EventStatus;
use HiEvents\Models\AccountAttribution;
use HiEvents\Repository\Interfaces\AccountAttributionRepositoryInterface;
use Illuminate\Database\Query\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;

Expand All @@ -16,6 +19,8 @@
*/
class AccountAttributionRepository extends BaseRepository implements AccountAttributionRepositoryInterface
{
private const NOT_SET = '(not set)';

protected function getModel(): string
{
return AccountAttribution::class;
Expand All @@ -27,77 +32,65 @@ public function getDomainObject(): string
}

public function getAttributionStats(
string $groupBy,
AttributionGroupBy $groupBy,
?string $dateFrom,
?string $dateTo,
int $perPage,
int $page
): LengthAwarePaginator {
$groupByMap = [
'source' => 'utm_source',
'campaign' => 'utm_campaign',
'medium' => 'utm_medium',
'source_type' => 'source_type',
];

$groupColumn = $groupByMap[$groupBy] ?? 'utm_source';
$groupExpression = $this->groupExpression($groupBy);
$liveStatus = EventStatus::LIVE->name;

$query = DB::table('account_attributions as aa')
$stats = $this->attributedAccountsQuery($dateFrom, $dateTo)
->select([
DB::raw("COALESCE(aa.{$groupColumn}, '(not set)') as attribution_value"),
DB::raw("{$groupExpression} as attribution_value"),
DB::raw('COUNT(DISTINCT aa.account_id) as total_accounts'),
DB::raw('COUNT(DISTINCT e.id) as total_events'),
DB::raw("COUNT(DISTINCT CASE WHEN e.status = '{$liveStatus}' THEN e.id END) as live_events"),
DB::raw('COUNT(DISTINCT CASE WHEN EXISTS (SELECT 1 FROM organizers o2 JOIN organizer_stripe_platforms osp2 ON osp2.organizer_id = o2.id WHERE o2.account_id = aa.account_id AND o2.deleted_at IS NULL AND osp2.deleted_at IS NULL AND osp2.stripe_setup_completed_at IS NOT NULL) THEN aa.account_id END) as stripe_connected'),
DB::raw('COUNT(DISTINCT CASE WHEN a.is_manually_verified = true THEN aa.account_id END) as verified_accounts'),
DB::raw('COALESCE(SUM(es.sales_total_gross), 0) as total_revenue'),
DB::raw('COALESCE(SUM(es.orders_created), 0) as total_orders'),
])
->join('accounts as a', 'aa.account_id', '=', 'a.id')
->leftJoin('events as e', function ($join) {
$join->on('a.id', '=', 'e.account_id')
->whereNull('e.deleted_at');
})
->leftJoin('event_statistics as es', 'e.id', '=', 'es.event_id')
->whereNull('a.deleted_at');

if ($dateFrom) {
$query->where('aa.created_at', '>=', $dateFrom);
}

if ($dateTo) {
$query->where('aa.created_at', '<=', $dateTo);
}
->leftJoin('event_statistics as es', function ($join) {
$join->on('e.id', '=', 'es.event_id')
->whereNull('es.deleted_at');
})
->groupBy(DB::raw($groupExpression))
->orderByDesc('total_accounts')
->orderBy('attribution_value')
->paginate(
perPage: min($perPage, $this->maxPerPage),
page: $page
);

$revenueByValue = $this->revenueByCurrency(
groupExpression: $groupExpression,
attributionValues: $stats->getCollection()->pluck('attribution_value')->all(),
dateFrom: $dateFrom,
dateTo: $dateTo,
);

$query->groupBy(DB::raw("COALESCE(aa.{$groupColumn}, '(not set)')"))
->orderByDesc('total_accounts');
return $stats->through(function (object $row) use ($revenueByValue) {
$row->revenue_by_currency = (object) ($revenueByValue[$row->attribution_value] ?? []);

return $query->paginate(
perPage: min($perPage, $this->maxPerPage),
page: $page
);
return $row;
});
}

public function getAttributionSummary(?string $dateFrom, ?string $dateTo): array
{
$attributedQuery = DB::table('account_attributions as aa')
$attributed = $this->attributedAccountsQuery($dateFrom, $dateTo)
->select([
DB::raw("COUNT(DISTINCT CASE WHEN aa.source_type = 'paid' THEN aa.account_id END) as paid_accounts"),
DB::raw("COUNT(DISTINCT CASE WHEN aa.source_type = 'organic' THEN aa.account_id END) as organic_accounts"),
DB::raw("COUNT(DISTINCT CASE WHEN aa.source_type = 'referral' THEN aa.account_id END) as referral_accounts"),
DB::raw($this->countBySourceType(AttributionSourceType::PAID, 'paid_accounts')),
DB::raw($this->countBySourceType(AttributionSourceType::ORGANIC, 'organic_accounts')),
DB::raw($this->countBySourceType(AttributionSourceType::REFERRAL, 'referral_accounts')),
DB::raw('COUNT(DISTINCT aa.account_id) as attributed_accounts'),
]);

if ($dateFrom) {
$attributedQuery->where('aa.created_at', '>=', $dateFrom);
}

if ($dateTo) {
$attributedQuery->where('aa.created_at', '<=', $dateTo);
}

$attributed = $attributedQuery->first();
])
->first();

$totalQuery = DB::table('accounts')
->whereNull('deleted_at');
Expand All @@ -122,4 +115,82 @@ public function getAttributionSummary(?string $dateFrom, ?string $dateTo): array
'total_accounts' => $totalAccounts,
];
}

private function attributedAccountsQuery(?string $dateFrom, ?string $dateTo): Builder
{
$query = DB::table('account_attributions as aa')
->join('accounts as a', 'aa.account_id', '=', 'a.id')
->whereNull('a.deleted_at');

if ($dateFrom) {
$query->where('a.created_at', '>=', $dateFrom);
}

if ($dateTo) {
$query->where('a.created_at', '<=', $dateTo);
}

return $query;
}

/**
* @return array<string, array<string, float>>
*/
private function revenueByCurrency(
string $groupExpression,
array $attributionValues,
?string $dateFrom,
?string $dateTo,
): array {
if ($attributionValues === []) {
return [];
}

$rows = $this->attributedAccountsQuery($dateFrom, $dateTo)
->select([
DB::raw("{$groupExpression} as attribution_value"),
'e.currency',
DB::raw('SUM(es.sales_total_gross) as revenue'),
])
->join('events as e', function ($join) {
$join->on('a.id', '=', 'e.account_id')
->whereNull('e.deleted_at');
})
->join('event_statistics as es', function ($join) {
$join->on('e.id', '=', 'es.event_id')
->whereNull('es.deleted_at');
})
->whereIn(DB::raw($groupExpression), $attributionValues)
->groupBy(DB::raw($groupExpression), 'e.currency')
->having(DB::raw('SUM(es.sales_total_gross)'), '>', 0)
->get();

$revenue = [];

foreach ($rows as $row) {
$revenue[$row->attribution_value][$row->currency] = (float) $row->revenue;
}

return $revenue;
}

private function groupExpression(AttributionGroupBy $groupBy): string
{
$column = match ($groupBy) {
AttributionGroupBy::SOURCE => 'aa.utm_source',
AttributionGroupBy::MEDIUM => 'aa.utm_medium',
AttributionGroupBy::CAMPAIGN => 'aa.utm_campaign',
AttributionGroupBy::CONTENT => 'aa.utm_content',
AttributionGroupBy::TERM => 'aa.utm_term',
AttributionGroupBy::CTA => "aa.utm_raw->>'ref'",
AttributionGroupBy::SOURCE_TYPE => 'aa.source_type',
};

return sprintf("COALESCE(%s, '%s')", $column, self::NOT_SET);
}

private function countBySourceType(AttributionSourceType $type, string $alias): string
{
return sprintf("COUNT(DISTINCT CASE WHEN aa.source_type = '%s' THEN aa.account_id END) as %s", $type->value, $alias);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace HiEvents\Repository\Interfaces;

use HiEvents\DomainObjects\AccountAttributionDomainObject;
use HiEvents\DomainObjects\Enums\AttributionGroupBy;
use Illuminate\Pagination\LengthAwarePaginator;

/**
Expand All @@ -13,7 +14,7 @@
interface AccountAttributionRepositoryInterface extends RepositoryInterface
{
public function getAttributionStats(
string $groupBy,
AttributionGroupBy $groupBy,
?string $dateFrom,
?string $dateTo,
int $perPage,
Expand Down
Loading
Loading