feat: sem codebase
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled

This commit is contained in:
brian-inspiren 2026-05-21 11:28:03 +08:00
parent b63462fc9e
commit 221d3f8173
287 changed files with 43430 additions and 0 deletions

0
.codex Normal file
View File

18
.editorconfig Normal file
View File

@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[compose.yaml]
indent_size = 4

65
.env.example Normal file
View File

@ -0,0 +1,65 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

11
.gitattributes vendored Normal file
View File

@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
CHANGELOG.md export-ignore
README.md export-ignore
.github/workflows/browser-tests.yml export-ignore

45
.github/workflows/lint.yml vendored Normal file
View File

@ -0,0 +1,45 @@
name: linter
on:
push:
branches:
- develop
- main
pull_request:
branches:
- develop
- main
permissions:
contents: write
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- name: Install Dependencies
run: |
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
npm install
- name: Run Pint
run: vendor/bin/pint
- name: Format Frontend
run: npm run format
- name: Lint Frontend
run: npm run lint
# - name: Commit Changes
# uses: stefanzweifel/git-auto-commit-action@v7
# with:
# commit_message: fix code style
# commit_options: '--no-verify'

50
.github/workflows/tests.yml vendored Normal file
View File

@ -0,0 +1,50 @@
name: tests
on:
push:
branches:
- develop
- main
pull_request:
branches:
- develop
- main
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
tools: composer:v2
coverage: xdebug
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install Node Dependencies
run: npm ci
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Build Assets
run: npm run build
- name: Copy Environment File
run: cp .env.example .env
- name: Generate Application Key
run: php artisan key:generate
- name: Tests
run: ./vendor/bin/pest

28
.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
/.phpunit.cache
/bootstrap/ssr
/node_modules
/public/build
/public/hot
/public/storage
/resources/js/actions
/resources/js/routes
/resources/js/wayfinder
/storage/*.key
/storage/pail
/vendor
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
/auth.json
/.fleet
/.idea
/.nova
/.vscode
/.zed

2
.prettierignore Normal file
View File

@ -0,0 +1,2 @@
resources/js/components/ui/*
resources/views/mail/*

25
.prettierrc Normal file
View File

@ -0,0 +1,25 @@
{
"semi": true,
"singleQuote": true,
"singleAttributePerLine": false,
"htmlWhitespaceSensitivity": "css",
"printWidth": 80,
"plugins": [
"prettier-plugin-organize-imports",
"prettier-plugin-tailwindcss"
],
"tailwindFunctions": [
"clsx",
"cn"
],
"tailwindStylesheet": "resources/css/app.css",
"tabWidth": 4,
"overrides": [
{
"files": "**/*.yml",
"options": {
"tabWidth": 2
}
}
]
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*/
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique(User::class),
],
'password' => $this->passwordRules(),
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
]);
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Actions\Fortify;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => $input['password'],
])->save();
}
}

View File

@ -0,0 +1,110 @@
<?php
namespace App\Console\Commands;
use App\Models\Client;
use App\Models\GoogleCampaign;
use App\Models\GoogleClient;
use App\Models\ClientUserAssignation;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Services\GoogleAdsService;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Carbon\Carbon;
class CampaignEndingNotifiy extends Command
{
protected $signature = 'campaign:ending-notify';
protected $description = 'Notify about campaigns that are ending soon';
public function handle()
{
try {
DB::beginTransaction();
$this->info("Fetching campaign details from Google Ads...");
$adsService = new GoogleAdsService();
$today = Carbon::today();
$threshold = Carbon::today()->addDays(30);
// Get all assignations with users
$assignations = ClientUserAssignation::with('user')->where('role',ClientUserAssignation::ROLE_ASSIGNED_PERSON)->get();
// Group assignations by user_id and role
$groupedAssignations = $assignations->groupBy(['user_id', 'role']);
foreach ($groupedAssignations as $userId => $roles) {
foreach ($roles as $role => $assigns) {
$clientIds = $assigns->pluck('client_id');
$clients = Client::whereIn('id', $clientIds)->where('status', 'ENABLED')->get();
$endingCampaigns = [];
foreach ($clients as $client) {
$campaigns = $adsService->listCampaigns($client->customer_id);
foreach ($campaigns as $campaign) {
if (empty($campaign['end_date'])) {
continue; // skip if no end date
}
$endDate = Carbon::parse($campaign['end_date']);
// Condition: ending within 30 days AND not already ended
if ($endDate->between($today, $threshold)) {
$endingCampaigns[] = [
'client' => $client,
'campaign' => $campaign,
'end_date' => $endDate,
];
}
}
}
// Notify the user if there are ending campaigns
if (!empty($endingCampaigns)) {
$user = $assigns->first()->user;
$roleLabel = $role == ClientUserAssignation::ROLE_ASSIGNED_PERSON ? 'Assigned Person' : 'Sales Person';
if ($user && !empty($user->email)) {
$mailData = [
'user_name' => $user->name,
'role_label' => $roleLabel,
'to_expiry' => array_map(function ($item) use ($today) {
return [
'id'=>$item['client']->customer_id,
'title' => $item['client']->name,
'name' => $item['campaign']['name'],
'iteration_end_date' => $item['end_date']->format('Y-m-d'),
'number_of_days' => $today->diffInDays($item['end_date']),
];
}, $endingCampaigns),
];
Mail::send('mail.campaign_expiry', ['data' => $mailData], function ($message) use ($user) {
$message->to($user->email, $user->name)
->subject('Campaign Expiry Alert');
});
$this->info("Sent campaign expiry email to {$user->email} ({$user->name}, {$roleLabel}).");
} else {
$this->warn("Skipping email for assignation group without valid email: user_id={$userId}, role={$role}");
}
}
}
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
Log::error('Error getting campaign details: ' . $e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
return 1;
}
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Console\Commands;
use App\Models\Client;
use App\Models\Customers;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Services\GoogleAdsService;
use Illuminate\Support\Facades\Log;
class CompareCustomer extends Command
{
protected $signature = 'customer:compare-customers';
protected $description = 'Compare customer details';
public function handle()
{
try {
DB::beginTransaction();
$customers = Customers::all();
foreach ($customers as $customer) {
$cleanedInput = preg_replace('/[^A-Za-z0-9]/', '', $customer->company_name);
$client = Client::whereRaw("REGEXP_REPLACE(name, '[^A-Za-z0-9]', '') = ?", [$cleanedInput])
->first();
if ($client) {
// Update exact match
$client->update([
'sql_acc_code' => $customer->sql_acc_code,
]);
} else {
$this->info("Skipped Match Found for: {$customer->company_name}");
}
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
Log::error('Error project linkage : '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
return 1;
}
}
}

View File

@ -0,0 +1,126 @@
<?php
namespace App\Console\Commands;
use App\Models\Client;
use App\Models\Customers;
use App\Models\ClientInvoice;
use App\Models\ClientUserAssignation;
use App\Models\User;
use App\Services\ClientInvoiceApprovalService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Services\GoogleAdsService;
use Illuminate\Support\Facades\Log;
use Rap2hpoutre\FastExcel\FastExcel;
use Carbon\Carbon;
class CreateClientInvoice extends Command
{
protected $signature = 'customer:create-invoice';
protected $description = 'Create client invoice';
public function handle()
{
$adsService = new GoogleAdsService();
$approvalService = new ClientInvoiceApprovalService();
try {
DB::beginTransaction();
$collection = (new FastExcel)->import(storage_path('app/public/csv/Fixed_EJ.csv'));
$array = $collection->toArray();
foreach ($array as $row) {
$startDate = Carbon::parse($row['start_date'])->format('Y-m-d');
$endDate = Carbon::parse($row['end_date'])->format('Y-m-d');
$client = Client::where('customer_id', str_replace('-', '', $row['customer_id']))->first();
if ($client) {
$client->update([
'industry' => $row['industry'],
]);
$salesUser = User::where('name', $row['sales'])->first();
$pic = User::where('name', $row['pic'])->first();
if ($pic) {
ClientUserAssignation::updateOrCreate(
[
'client_id' => $client->id,
'role' => ClientUserAssignation::ROLE_ASSIGNED_PERSON,
],
[
'user_id' => $pic->id,
]
);
}
if ($salesUser) {
ClientUserAssignation::updateOrCreate(
[
'client_id' => $client->id,
'role' => ClientUserAssignation::ROLE_SALES_PERSON,
],
[
'user_id' => $salesUser->id,
]
);
}
$row['client_id'] = $client->id;
// if ($client->status != 'CANCELED') {
// $campaigns = $adsService->listCampaigns($row['customer_id']);
// Log::info('Hydrated client data', [
// 'campaigns' => $campaigns,
// ]);
// foreach ($campaigns as $campaign) {
// Log::info('Hydrated client data', [
// 'campaigns' => $campaign['id'],
// ]);
// if (empty($invoice->start_date) || empty($invoice->end_date)) {
// $totalSpend = 0;
// $spend += number_format($totalSpend, 2, '.', '');
// } else {
// $metrics = $adsService->listCampaignsMetricsById(
// $row['customer_id'],
// $campaign['id'],
// $startDate,
// $endDate
// );
// Log::info('Hydrated client data', [
// 'metrics' => $metrics,
// ]);
// $totalSpend = array_sum(array_column($metrics, 'actual_spend'));
// $spend += number_format($totalSpend, 2, '.', '');
// }
// }
// } else {
$spend = 0;
// }
$invoice = ClientInvoice::updateOrCreate(
['invoice_no' => $row['invoice_no']],
[
'client_id' => $row['client_id'],
'is_credit_card' => intval(str_replace(',', '',$row['media_fee'])) == 0 ? 1 : 0,
'start_date' => $startDate,
'end_date' => $endDate,
'management_fee' => intval(str_replace(',', '',$row['management_fee'])) ?? 0,
'media_fee' => intval(str_replace(',', '',$row['media_fee'])) ?? 0,
'tax_percent' => 8,
'nett_amount' => intval(str_replace(',', '',$row['media_fee'])) > 0 ? intval(str_replace(',', '',$row['media_fee'])) / 1.08 : 0,
'total_spending' => $spend,
]
);
$approvalService->approve($invoice);
} else {
Log::warning('Client not found for customer_id: '.str_replace('-', '', $row['customer_id']));
continue; // Skip this row if client not found
}
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
Log::error('Error project linkage : '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
return 1;
}
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Console\Commands;
use App\Models\Client;
use App\Models\GoogleAd;
use App\Models\GoogleAdGroup;
use App\Models\GoogleCampaign;
use App\Models\GoogleClient;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Services\GoogleAdsService;
use Illuminate\Support\Facades\Log;
class GetGoogleAdDetails extends Command
{
protected $signature = 'google-ads:get-ads-details';
protected $description = 'Get ads details from Google Ads';
public function handle()
{
try {
DB::beginTransaction();
$this->info("Fetching campaign details from Google Ads...");
$adsService = new GoogleAdsService();
$clients = Client::where('status', 'ENABLED')->get();
foreach ($clients as $client) {
$campaigns = GoogleCampaign::where('client_id', $client->id)->get();
if ($campaigns->isEmpty()) {
$this->info("No campaigns found for Client ID: {$client->customer_id}");
continue;
} else {
foreach ($campaigns as $campaign) {
$adGroups = GoogleAdGroup::where('google_campaign_id', $campaign->id)->get();
if (! empty($adGroups)) {
foreach ($adGroups as $adGroup) {
$ads = $adsService->listAdsByAdGroupId($client->customer_id, $adGroup->ad_group_id);
if (! empty($ads)) {
foreach ($ads as $ad) {
GoogleAd::updateOrCreate(
[
'google_ad_group_id' => $adGroup->id,
'ad_id' => $ad['ad_id'],
],
[
'type' => $ad['type'],
'status'=> $ad['status'],
'approval_status' => $ad['approval_status'],
'final_urls' => json_encode($ad['final_urls']),
]
);
$this->info("Fetched Ad ID: {$ad['ad_id']} for Ad Group: {$adGroup->name} in Campaign: {$campaign->name}");
}
} else {
$this->info("No ads found for Client ID: {$client->customer_id}, Ad Group Name: {$adGroup->name}");
}
}
} else {
$this->info("No ad group found for Client ID: {$client->customer_id}, Campaign Name: {$campaign->name}");
}
}
}
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
Log::error('Error getting campaign details: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
return 1;
}
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Console\Commands;
use App\Models\Client;
use App\Models\GoogleAdGroup;
use App\Models\GoogleCampaign;
use App\Models\GoogleClient;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Services\GoogleAdsService;
use Illuminate\Support\Facades\Log;
class GetGoogleAdGroupDetails extends Command
{
protected $signature = 'google-ads:get-adgroup-details';
protected $description = 'Get ad group details from Google Ads';
public function handle()
{
try {
DB::beginTransaction();
$this->info("Fetching campaign details from Google Ads...");
$adsService = new GoogleAdsService();
$clients = Client::where('status', 'ENABLED')->get();
foreach ($clients as $client) {
$campaigns = GoogleCampaign::where('client_id', $client->id)->get();
if ($campaigns->isEmpty()) {
$this->info("No campaigns found for Client ID: {$client->customer_id}");
continue;
} else {
foreach ($campaigns as $campaign) {
//Beginning of campaign
$adGroups = $adsService->listAdGroupsByCampaignId($client->customer_id, $campaign->campaign_id);
if (! empty($adGroups)) {
foreach ($adGroups as $adGroup) {
GoogleAdGroup::updateOrCreate(
[
'google_campaign_id' => $campaign->id,
'ad_group_id' => $adGroup['ad_group_id'],
],
[
'name' => $adGroup['ad_group_name'],
'status' => $adGroup['status'],
'type' => $adGroup['type'],
'cpc_bid_micros' => $adGroup['cpc_bid_micros'],
]
);
$this->info("Saved Ad Group: {$adGroup['ad_group_name']} for Campaign: {$campaign->name}");
}
} else {
$this->info("No ad group found for Client ID: {$client->customer_id}, Campaign Name: {$campaign->name}");
}
}
}
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
Log::error('Error getting campaign details: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
return 1;
}
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace App\Console\Commands;
use App\Models\Client;
use App\Models\GoogleAd;
use App\Models\GoogleAdGroup;
use App\Models\GoogleAdGroupMetric;
use App\Models\GoogleCampaign;
use App\Models\GoogleCampaignMetric;
use App\Models\GoogleClient;
use App\Models\GoogleKeywordMetric;
use App\Models\GoogleKeywords;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Services\GoogleAdsService;
use Illuminate\Support\Facades\Log;
class GetGoogleAdGroupMetric extends Command
{
protected $signature = 'google-ads:get-adgroup-metric';
protected $description = 'Get ad group metric from Google Ads';
public function handle()
{
try {
DB::beginTransaction();
$this->info("Fetching campaign details from Google Ads...");
$adsService = new GoogleAdsService();
$clients = Client::where('status', 'ENABLED')->where('customer_id', '2048068576')->get();
foreach ($clients as $client) {
$campaigns = GoogleCampaign::where('client_id', $client->id)->get();
if ($campaigns->isEmpty()) {
$this->info("No campaigns found for Client ID: {$client->customer_id}");
continue;
} else {
foreach ($campaigns as $campaign) {
$adGroups = GoogleAdGroup::where('google_campaign_id', $campaign->id)->get();
if ($adGroups->isEmpty()) {
$this->info("No ad groups found for Client ID: {$client->customer_id}");
continue;
} else {
foreach ($adGroups as $adGroup) {
$metrics = $adsService->getAdGroupMetricsById($client->customer_id, $adGroup->ad_group_id, '1970-01-01');
// $metrics = $adsService->getAdGroupMetricsById($client->customer_id, $adGroup->ad_group_id);
if (! empty($metrics)) {
foreach ($metrics as $metric) {
GoogleAdGroupMetric::updateOrCreate(
[
'google_ad_group_id' => $adGroup->id,
'date' => $metric['date'],
],
[
'impressions' => $metric['impressions'],
'clicks' => $metric['clicks'],
'actual_spend' => $metric['actual_spend'],
'conversions' => $metric['conversions'],
]
);
$this->info("Ad Group Metric for Ad Group ID: {$adGroup->ad_group_id} under Client ID: {$client->customer_id} for Date: {$metric['date']} has been updated/created.");
}
}
else{
$this->info("No metrics found for Ad Group ID: {$adGroup->ad_group_id} under Client ID: {$client->customer_id}");
}
}
}
}
}
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
Log::error('Error getting campaign details: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
return 1;
}
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Console\Commands;
use App\Models\Client;
use App\Models\GoogleAd;
use App\Models\GoogleAdGroup;
use App\Models\GoogleAdGroupMetric;
use App\Models\GoogleAdMetric;
use App\Models\GoogleCampaign;
use App\Models\GoogleCampaignMetric;
use App\Models\GoogleClient;
use App\Models\GoogleKeywordMetric;
use App\Models\GoogleKeywords;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Services\GoogleAdsService;
use Illuminate\Support\Facades\Log;
class GetGoogleAdsMetric extends Command
{
protected $signature = 'google-ads:get-ads-metric';
protected $description = 'Get ads metric from Google Ads';
public function handle()
{
try {
DB::beginTransaction();
$this->info("Fetching campaign details from Google Ads...");
$adsService = new GoogleAdsService();
$clients = Client::where('status', 'ENABLED')->where('customer_id', '2048068576')->get();
foreach ($clients as $client) {
$campaigns = GoogleCampaign::where('client_id', $client->id)->get();
if ($campaigns->isEmpty()) {
$this->info("No campaigns found for Client ID: {$client->customer_id}");
continue;
} else {
foreach ($campaigns as $campaign) {
$adGroups = GoogleAdGroup::where('google_campaign_id', $campaign->id)->get();
if ($adGroups->isEmpty()) {
$this->info("No ad groups found for Client ID: {$client->customer_id}");
continue;
} else {
foreach ($adGroups as $adGroup) {
$ads = GoogleAd::where('google_ad_group_id', $adGroup->id)->get();
if ($ads->isEmpty()) {
$this->info("No ads found for Ad Group ID: {$adGroup->ad_group_id} under Client ID: {$client->customer_id}");
continue;
} else {
foreach ($ads as $ad) {
$metrics = $adsService->getAdMetricsById($client->customer_id, $ad->ad_id, '1970-01-01');
// $metrics = $adsService->getAdMetricsById($client->customer_id, $ad->ad_id);
if (! empty($metrics)) {
foreach ($metrics as $metric) {
GoogleAdMetric::updateOrCreate(
[
'google_ad_id' => $ad->id,
'date' => $metric['date'],
],
[
'impressions' => $metric['impressions'],
'clicks' => $metric['clicks'],
'actual_spend' => $metric['spend'],
'conversions' => $metric['conversions'],
]
);
$this->info("Ad Metric for Ad ID: {$ad->ad_id} under Client ID: {$client->customer_id} for Date: {$metric['date']} has been updated/created.");
}
} else {
$this->info("No metrics found for Ad ID: {$ad->ad_id} under Client ID: {$client->customer_id}");
}
}
}
}
}
}
}
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
Log::error('Error getting campaign details: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
return 1;
}
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class GetGoogleAdsRefreshToken extends Command
{
protected $signature = 'google-ads:get-refresh-token';
protected $description = 'Generate a Google Ads API refresh token';
public function handle()
{
$client_id = $this->ask('Enter your Google OAuth Client ID');
$client_secret = $this->ask('Enter your Google OAuth Client Secret');
$redirect_uri = $this->ask('Enter your Redirect URI (e.g., http://localhost/oauth2callback)');
$authUrl = 'https://accounts.google.com/o/oauth2/v2/auth?' . http_build_query([
'scope' => 'https://www.googleapis.com/auth/adwords',
'access_type' => 'offline',
'include_granted_scopes' => 'true',
'response_type' => 'code',
'client_id' => $client_id,
'redirect_uri' => $redirect_uri
]);
$this->info("Open this URL in your browser:");
$this->line($authUrl);
$this->info("After approving access, you will be redirected to your redirect URI with a 'code' parameter.");
$code = $this->ask('Enter the code from the URL');
// Exchange code for refresh token
$response = $this->postToken($client_id, $client_secret, $redirect_uri, $code);
if(isset($response['refresh_token'])) {
$this->info("Success! Your refresh token is:");
$this->line($response['refresh_token']);
} else {
$this->error('Failed to get refresh token.');
$this->line(json_encode($response, JSON_PRETTY_PRINT));
}
return 0;
}
protected function postToken($client_id, $client_secret, $redirect_uri, $code)
{
$ch = curl_init('https://oauth2.googleapis.com/token');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
'code' => $code,
'client_id' => $client_id,
'client_secret' => $client_secret,
'redirect_uri' => $redirect_uri,
'grant_type' => 'authorization_code'
]));
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true);
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Console\Commands;
use App\Models\Client;
use App\Models\GoogleAsset;
use App\Models\GoogleCampaign;
use App\Models\GoogleCampaignMetric;
use App\Models\GoogleClient;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Services\GoogleAdsService;
use Illuminate\Support\Facades\Log;
class GetGoogleAssetDetails extends Command
{
protected $signature = 'google-ads:get-asset-details';
protected $description = 'Get asset details from Google Ads';
public function handle()
{
try {
DB::beginTransaction();
$this->info("Fetching campaign details from Google Ads...");
$adsService = new GoogleAdsService();
$clients = Client::where('status', 'ENABLED')->where('customer_id', '4744166776')->get();
// $clients = Client::where('status', 'ENABLED')->get();
foreach ($clients as $client) {
$campaigns = GoogleCampaign::where('client_id', $client->id)->get();
if ($campaigns->isEmpty()) {
$this->info("No campaigns found for Client ID: {$client->customer_id}");
continue;
} else {
foreach ($campaigns as $campaign) {
$assets = $adsService->listAssetsByCampaignId($client->customer_id, $campaign->campaign_id);
if (! empty($assets)) {
foreach ($assets as $asset) {
// $this->info('Here:'.$asset['status']);
GoogleAsset::updateOrCreate(
[
'google_campaign_id' => $campaign->id,
'asset_id' => $asset['id'],
],
[
'name' => $asset['name'],
'type' => $asset['type'],
'added_by' => $asset['added_by'],
'use_case' => $asset['use_case'],
'approval_status' => $asset['approval_status'],
'review_status' => $asset['review_status'],
'status' => $asset['status'],
]
);
$this->info("Fetched Asset: {$asset['name']} (Type: {$asset['type']}) for Campaign: {$campaign->name}");
}
} else {
$this->info("No assets found for Client ID: {$client->customer_id}, Campaign Name: {$campaign->name}");
}
}
}
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
Log::error('Error getting campaign details: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
return 1;
}
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use App\Models\Client;
use App\Models\GoogleAsset;
use App\Models\GoogleAssetMetric;
use App\Models\GoogleCampaign;
use App\Models\GoogleCampaignMetric;
use App\Models\GoogleClient;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Services\GoogleAdsService;
use Illuminate\Support\Facades\Log;
class GetGoogleAssetMetric extends Command
{
protected $signature = 'google-ads:get-asset-metrics';
protected $description = 'Get asset metrics from Google Ads';
public function handle()
{
try {
DB::beginTransaction();
$this->info("Fetching campaign details from Google Ads...");
$adsService = new GoogleAdsService();
$clients = Client::where('status', 'ENABLED')->where('customer_id', '4744166776')->get();
// $clients = Client::where('status', 'ENABLED')->get();
foreach ($clients as $client) {
$campaigns = GoogleCampaign::where('client_id', $client->id)->get();
if ($campaigns->isEmpty()) {
$this->info("No campaigns found for Client ID: {$client->customer_id}");
continue;
} else {
foreach ($campaigns as $campaign) {
$assets = GoogleAsset::where('google_campaign_id', $campaign->id)->get();
if (! empty($assets)) {
foreach ($assets as $asset) {
$metrics = $adsService->getAssetMetricsById($client->customer_id, $asset->asset_id, '1970-01-01');
if (! empty($metrics)) {
foreach ($metrics as $metric) {
GoogleAssetMetric::updateOrCreate(
[
'google_asset_id' => $asset->id,
'date' => $metric['date'],
],
[
'impressions' => $metric['impressions'],
'clicks' => $metric['clicks'],
'actual_spend' => $metric['cost'],
'conversions' => $metric['conversions'],
]
);
$this->info("Fetched Metric for Asset: {$asset->name} on Date: {$metric['date']} for Campaign: {$campaign->name}");
}
} else {
$this->info("No metrics found for Asset ID: {$asset->asset_id} in Campaign: {$campaign->name}");
}
}
} else {
$this->info("No assets found for Client ID: {$client->customer_id}, Campaign Name: {$campaign->name}");
}
}
}
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
Log::error('Error getting campaign details: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
return 1;
}
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Console\Commands;
use App\Models\Client;
use App\Models\GoogleCampaign;
use App\Models\GoogleClient;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Services\GoogleAdsService;
use Illuminate\Support\Facades\Log;
class GetGoogleCampaignDetails extends Command
{
protected $signature = 'google-ads:get-campaign-details';
protected $description = 'Get campaign details from Google Ads';
public function handle()
{
try {
DB::beginTransaction();
$this->info("Fetching campaign details from Google Ads...");
$adsService = new GoogleAdsService();
// $clients = Client::where('status','ENABLED')->get();
$clients = Client::where('status', 'ENABLED')->where('customer_id', '4744166776')->get();
foreach ($clients as $client) {
$campaigns = $adsService->listCampaigns($client->customer_id);
foreach ($campaigns as $campaign) {
$campaign = GoogleCampaign::updateOrCreate(
['client_id' => $client->id, 'campaign_id' => $campaign['id']],
[
'campaign_id' => $campaign['id'],
'name' => $campaign['name'],
'status' => $campaign['status'],
'channel' => $campaign['channel'],
'start_date' => $campaign['start_date'],
'end_date' => $campaign['end_date'],
'sub_channel' => $campaign['sub_channel'],
]
);
$this->info("Client ID: {$client->customer_id}, Campaign Name: {$campaign['name']}, Status: {$campaign['status']}");
}
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
Log::error('Error getting campaign details: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
return 1;
}
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Console\Commands;
use App\Models\Client;
use App\Models\GoogleCampaign;
use App\Models\GoogleCampaignMetric;
use App\Models\GoogleClient;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Services\GoogleAdsService;
use Illuminate\Support\Facades\Log;
class GetGoogleCampaignMetric extends Command
{
protected $signature = 'google-ads:get-campaign-metric';
protected $description = 'Get campaign metric from Google Ads';
public function handle()
{
try {
DB::beginTransaction();
$this->info("Fetching campaign details from Google Ads...");
$adsService = new GoogleAdsService();
// $clients = Client::where('status', 'ENABLED')->get();
$clients = Client::where('status', 'ENABLED')->where('customer_id', '4744166776')->get();
foreach ($clients as $client) {
$campaigns = GoogleCampaign::where('client_id', $client->id)->get();
if ($campaigns->isEmpty()) {
$this->info("No campaigns found for Client ID: {$client->customer_id}");
continue;
} else {
foreach ($campaigns as $campaign) {
//Beginning of campaign
$metrics = $adsService->listCampaignsMetricsById($client->customer_id, $campaign->campaign_id, '1970-01-01');
// $metrics = $adsService->listCampaignsMetricsById($client->customer_id, $campaign->campaign_id);
if (! empty($metrics)) {
$this->info("Found metrics for Client ID: {$client->customer_id}, Campaign Name: {$campaign->name}");
foreach ($metrics as $metric) {
GoogleCampaignMetric::updateOrCreate(
[
'google_campaign_id' => $campaign->id,
'date' => $metric['date'],
'google_campaign_metric_id' => $metric['id']
],
[
// 'google_campaign_metric_id' => $metric['id'],
'impressions' => $metric['impressions'],
'date' => $metric['date'],
'clicks' => $metric['clicks'],
'daily_budget' => $metric['daily_budget'],
'actual_spend' => $metric['actual_spend'],
'conversions' => $metric['conversions'],
'interactions' => $metric['interactions'],
]
);
$this->info("Client ID: {$client->customer_id}, Campaign Name: {$campaign->name},Date: {$metric['date']} , Impressions: {$metric['impressions']}, Clicks: {$metric['clicks']}, Cost: {$metric['actual_spend']}");
}
} else {
$this->info("No metrics found for Client ID: {$client->customer_id}, Campaign Name: {$campaign->name}");
}
}
}
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
Log::error('Error getting campaign details: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
return 1;
}
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Console\Commands;
use App\Models\Client;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Services\GoogleAdsService;
use Illuminate\Support\Facades\Log;
class GetGoogleCompanyDetails extends Command
{
protected $signature = 'google-ads:get-company-details';
protected $description = 'Get company details from Google Ads';
public function handle()
{
try {
DB::beginTransaction();
$this->info("Fetching company details from Google Ads...");
$mccCustomerId = env('GOOGLE_ADS_LOGIN_CUSTOMER_ID'); // Manager ID without dashes
$adsService = new GoogleAdsService();
$accounts = $adsService->listAccounts();
foreach ($accounts as $account) {
$company = Client::updateOrCreate(
['customer_id' => $account['id']],
[
'name' => $account['name'],
'status' => $account['status'],
'time_zone' => $account['time_zone'],
]
);
$this->info("Customer ID: {$account['customer_id']}, Name: {$account['name']}, Status: {$account['status']}");
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
Log::error('Error creating project iteration: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
return 1;
}
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Console\Commands;
use App\Models\Client;
use App\Models\GoogleAd;
use App\Models\GoogleAdGroup;
use App\Models\GoogleCampaign;
use App\Models\GoogleClient;
use App\Models\GoogleKeywords;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Services\GoogleAdsService;
use Illuminate\Support\Facades\Log;
class GetGoogleKeywordsDetails extends Command
{
protected $signature = 'google-ads:get-keywords-details';
protected $description = 'Get keywords details from Google Ads';
public function handle()
{
try {
DB::beginTransaction();
$this->info("Fetching campaign details from Google Ads...");
$adsService = new GoogleAdsService();
$clients = Client::where('status', 'ENABLED')->where('customer_id', '2048068576')->get();
foreach ($clients as $client) {
$campaigns = GoogleCampaign::where('client_id', $client->id)->get();
if ($campaigns->isEmpty()) {
$this->info("No campaigns found for Client ID: {$client->customer_id}");
continue;
} else {
foreach ($campaigns as $campaign) {
$adGroups = GoogleAdGroup::where('google_campaign_id', $campaign->id)->get();
if (! empty($adGroups)) {
foreach ($adGroups as $adGroup) {
$keywords = $adsService->listKeywordsByAdGroupId($client->customer_id, $adGroup->ad_group_id);
if (! empty($keywords)) {
foreach ($keywords as $keyword) {
GoogleKeywords::updateOrCreate(
[
'google_ad_group_id' => $adGroup->id,
'keyword_id' => $keyword['keyword_id'],
],
[
'text' => $keyword['text'],
'match_type' => $keyword['match_type'],
'status' => $keyword['status'],
]
);
$this->info("Fetched Keyword: {$keyword['text']} (Match Type: {$keyword['match_type']}) for Ad Group: {$adGroup->name} in Campaign: {$campaign->name}");
}
} else {
$this->info("No keywords found for Client ID: {$client->customer_id}, Ad Group Name: {$adGroup->name}");
}
}
} else {
$this->info("No ad group found for Client ID: {$client->customer_id}, Campaign Name: {$campaign->name}");
}
}
}
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
Log::error('Error getting campaign details: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
return 1;
}
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace App\Console\Commands;
use App\Models\Client;
use App\Models\GoogleAdGroup;
use App\Models\GoogleCampaign;
use App\Models\GoogleCampaignMetric;
use App\Models\GoogleClient;
use App\Models\GoogleKeywordMetric;
use App\Models\GoogleKeywords;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Services\GoogleAdsService;
use Illuminate\Support\Facades\Log;
class GetGoogleKeywordsMetric extends Command
{
protected $signature = 'google-ads:get-keywords-metric';
protected $description = 'Get keywords metric from Google Ads';
public function handle()
{
try {
DB::beginTransaction();
$this->info("Fetching campaign details from Google Ads...");
$adsService = new GoogleAdsService();
$clients = Client::where('status', 'ENABLED')->where('customer_id', '2048068576')->get();
foreach ($clients as $client) {
$campaigns = GoogleCampaign::where('client_id', $client->id)->get();
if ($campaigns->isEmpty()) {
$this->info("No campaigns found for Client ID: {$client->customer_id}");
continue;
} else {
foreach ($campaigns as $campaign) {
$adGroups = GoogleAdGroup::where('google_campaign_id', $campaign->id)->get();
if ($adGroups->isEmpty()) {
$this->info("No ad groups found for Client ID: {$client->customer_id}");
continue;
} else {
foreach ($adGroups as $adGroup) {
$keywords = GoogleKeywords::where('google_ad_group_id', $adGroup->id)->get();
if ($keywords->isEmpty()) {
$this->info("No keywords found for Client ID: {$client->customer_id}");
continue;
} else {
foreach ($keywords as $keyword) {
$metrics = $adsService->getKeywordMetricsById($client->customer_id, $keyword->keyword_id, '1970-01-01');
// $metrics = $adsService->getKeywordMetricsById($client->customer_id, $keyword->keyword_id);
if (! empty($metrics)) {
foreach ($metrics as $metric) {
GoogleKeywordMetric::updateOrCreate(
[
'google_keyword_id' => $keyword->id,
'date' => $metric['date'],
],
[
'impressions' => $metric['impressions'],
'clicks' => $metric['clicks'],
'actual_spend' => $metric['actual_spend'],
'conversions' => $metric['conversions'],
]
);
$this->info("Client ID: {$client->customer_id}, Keyword: {$keyword->text}, Date: {$metric['date']} , Impressions: {$metric['impressions']}, Clicks: {$metric['clicks']}, Cost: {$metric['actual_spend']}");
}
} else {
$this->info("No metrics found for Client ID: {$client->customer_id}, Keyword: {$keyword->text}");
}
}
}
}
}
}
}
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
Log::error('Error getting campaign details: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
return 1;
}
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use App\Models\Client;
use App\Models\Customers;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Services\GoogleAdsService;
use Illuminate\Support\Facades\Log;
class MigrateClientCustomer extends Command
{
protected $signature = 'customer:migrate-client-customers';
protected $description = 'Migrate client customer data';
public function handle()
{
try {
DB::beginTransaction();
$clients = Client::where('sql_acc_code', '!=', null)->get();
foreach ($clients as $client) {
$customers = explode(',',$client->sql_acc_code);
foreach($customers as $customer){
$client->customers()->create([
// 'company_name' => $customer,
'sql_acc_code' => $customer,
]);
}
}
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
Log::error('Error project linkage : '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
return 1;
}
}
}

View File

@ -0,0 +1,156 @@
<?php
namespace App\Console\Commands;
use App\Models\ClientProjectActivities;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class PendingProjectActivitiesNotify extends Command
{
protected $signature = 'project-activities:pending-notify {--test-email= : Send a sample reminder email to this address only}';
protected $description = 'Notify users about pending project activities based on estimated completion date';
public function handle(): int
{
try {
if ($this->option('test-email')) {
return $this->sendTestEmail($this->option('test-email'));
}
$today = Carbon::today();
$tomorrow = Carbon::tomorrow();
$activities = ClientProjectActivities::query()
->with(['client:id,name,customer_id', 'user:id,name,email'])
->whereNull('completed_at')
->whereNotNull('estimated_completed_at')
->whereDate('estimated_completed_at', '<=', $tomorrow)
->orderBy('estimated_completed_at')
->get();
$activities
->groupBy('user_id')
->each(function ($userActivities, $userId) use ($today, $tomorrow) {
$user = $userActivities->first()->user;
if (! $user || empty($user->email)) {
$this->warn("Skipping project activity reminder for user_id={$userId}; no valid email found.");
return;
}
$overdue = $userActivities->filter(function (ClientProjectActivities $activity) use ($today) {
return $activity->estimated_completed_at->lt($today);
});
$dueToday = $userActivities->filter(function (ClientProjectActivities $activity) use ($today) {
return $activity->estimated_completed_at->isSameDay($today);
});
$dueTomorrow = $userActivities->filter(function (ClientProjectActivities $activity) use ($tomorrow) {
return $activity->estimated_completed_at->isSameDay($tomorrow);
});
if ($overdue->isEmpty() && $dueToday->isEmpty()) {
return;
}
$mailData = [
'user_name' => $user->name,
'role_label' => 'Activity Owner',
'overdue' => $overdue->map(fn (ClientProjectActivities $activity) => $this->formatActivity($activity, $today))->values()->all(),
'due_today' => $dueToday->map(fn (ClientProjectActivities $activity) => $this->formatActivity($activity, $today))->values()->all(),
'due_tomorrow' => $dueTomorrow->map(fn (ClientProjectActivities $activity) => $this->formatActivity($activity, $today))->values()->all(),
];
Mail::send('mail.project_activity_reminder', ['data' => $mailData], function ($message) use ($user) {
$message->to($user->email, $user->name)
->subject('Pending Project Activity Reminder');
});
$this->info("Sent pending project activity reminder to {$user->email} ({$user->name}).");
});
return self::SUCCESS;
} catch (\Throwable $e) {
Log::error('Error sending pending project activity reminders: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
$this->error('Failed to send pending project activity reminders.');
return self::FAILURE;
}
}
private function formatActivity(ClientProjectActivities $activity, Carbon $today): array
{
$estimatedDate = $activity->estimated_completed_at->copy()->startOfDay();
return [
'activity_no' => $activity->activity_no,
'customer_id' => $activity->client?->customer_id ?? '-',
'customer' => $activity->client?->name ?? '-',
'activity_type' => $activity->activity_type ?? '-',
'task_description' => $activity->task_description ?? '-',
'estimated_completed_at' => $estimatedDate->format('Y-m-d'),
'number_of_days' => (int) $today->diffInDays($estimatedDate, false),
];
}
private function sendTestEmail(string $email): int
{
$today = Carbon::today();
$mailData = [
'user_name' => 'Brian',
'role_label' => 'Activity Owner',
'overdue' => [
[
'activity_no' => 'ACT0001',
'customer_id' => '1234567890',
'customer' => 'Example Client A',
'activity_type' => 'scheduled',
'task_description' => 'Review SEM performance report and update client notes.',
'estimated_completed_at' => $today->copy()->subDays(2)->format('Y-m-d'),
'number_of_days' => -2,
],
],
'due_today' => [
[
'activity_no' => 'ACT0002',
'customer_id' => '1234567891',
'customer' => 'Example Client B',
'activity_type' => 'scheduled',
'task_description' => 'Prepare optimisation checklist for campaign review.',
'estimated_completed_at' => $today->format('Y-m-d'),
'number_of_days' => 0,
],
],
'due_tomorrow' => [
[
'activity_no' => 'ACT0003',
'customer_id' => '1234567892',
'customer' => 'Example Client C',
'activity_type' => 'scheduled',
'task_description' => 'Follow up on budget pacing and next-month planning.',
'estimated_completed_at' => $today->copy()->addDay()->format('Y-m-d'),
'number_of_days' => 1,
],
],
];
Mail::send('mail.project_activity_reminder', ['data' => $mailData], function ($message) use ($email) {
$message->to($email)
->subject('Test: Pending Project Activity Reminder');
});
$this->info("Sent test pending project activity reminder to {$email}.");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,119 @@
<?php
namespace App\Http\Controllers;
use App\Models\Client;
use App\Models\ClientProjectActivities;
use App\Models\ClientUserAssignation;
use App\Models\User;
use App\Services\UserHierarchyService;
use Inertia\Inertia;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log as FacadesLog;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
class ActivityController extends Controller
{
public function __construct(private UserHierarchyService $hierarchyService)
{
}
public function completeActivity(Request $request, int $activity_id)
{
$activity = ClientProjectActivities::findOrFail($activity_id);
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $activity->client), 403);
$activity->completed_at = Carbon::now();
$activity->save();
return redirect()->back()
->with('message-info', 'Activity has been completed.');
}
public function storeActivity(Request $request, int $project_id): RedirectResponse
{
$request->validate([
'task_description' => ['required', 'string'],
'category' => ['required_if:activity_type,report,scheduled'],
'activity_type' => ['required', 'in:report,scheduled,customer_notes'],
'estimated_completed_at' => [
'nullable',
'required_if:activity_type,scheduled',
],
]);
$project = Client::findOrFail($project_id);
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $project), 403);
$activity = $project->activitiesList()->create([
'task_description' => $request->task_description,
'activity_type' => $request->category,
'estimated_completed_at' => $request->activity_type == 'report' ? null : $request->estimated_completed_at,
'user_id' => auth()->id(),
'completed_at' => $request->activity_type == 'report' ? now() : null,
]);
return redirect()
->route('google-ads.accounts.show', ['id' => $project->customer_id, 'tab' => 'activities'])
->with('message-info', 'Activity has been created successfully.');
}
public function updateActivity($id, Request $request): RedirectResponse
{
try {
$activity = ClientProjectActivities::findOrFail($id);
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $activity->client), 403);
$request->validate([
'task_description' => ['required', 'string', 'min:0'],
'category' => ['required_if:activity_type,report,scheduled'],
'activity_type' => ['required', 'in:report,scheduled,customer_notes'],
'estimated_completed_at' => [
'nullable',
'required_if:activity_type,scheduled',
],
]);
DB::beginTransaction();
$activity->update([
'task_description' => $request->task_description,
'activity_type' => $request->category,
'estimated_completed_at' => $request->activity_type == 'report' ? null : $request->estimated_completed_at,
'completed_at' => $request->activity_type == 'report' ? (! empty($activity->completed_at) ? $activity->completed_at : now()) : null,
]);
DB::commit();
return redirect()
->back()
->with('message-info', 'Activity has been updated successfully.');
} catch (\Throwable $e) {
DB::rollBack();
FacadesLog::error('Error updated project activity: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
return redirect()
->back()
->withInput()
->with('message-error', 'Something went wrong while updating the project activity.');
}
}
public function deleteActivity($id): RedirectResponse
{
$activity = ClientProjectActivities::findOrFail($id);
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $activity->client), 403);
$clientId = $activity->client_id;
$activity->delete();
return redirect()
->route('google-ads.accounts.show', ['id' => $clientId, 'tab' => 'activities'])
->with('message-info', 'Activity has been deleted successfully.');
}
}

View File

@ -0,0 +1,304 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ClientInvoice;
use App\Services\ClientLookupService;
use App\Services\ClientInvoiceApprovalService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class ClientInvoiceController extends Controller
{
public function __construct(
private ClientInvoiceApprovalService $approvalService,
private ClientLookupService $clientLookupService,
) {
}
public function pending(): JsonResponse
{
$invoices = ClientInvoice::query()
->with('client:id,name,customer_id')
->whereNull('approved_at')
->latest('id')
->get([
'id',
'client_id',
'pending_sql_acc_code',
'pending_client_name',
'invoice_no',
'start_date',
'end_date',
'payment_no',
'management_fee',
'media_fee',
'nett_amount',
'total_spending',
'created_at',
]);
return response()->json([
'count' => $invoices->count(),
'invoices' => $invoices->map(function (ClientInvoice $invoice) {
$previousPayments = $this->previousPaymentsForInvoice($invoice);
$invoiceBillingTotals = $this->invoiceBillingTotalsFromPayments($previousPayments);
return [
...$invoice->toArray(),
'requires_client' => $invoice->client_id === null,
'previous_payments' => $previousPayments,
'invoice_billing_totals' => $invoiceBillingTotals,
];
}),
]);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'client_id' => ['nullable', 'exists:clients,id'],
'sql_acc_code' => ['required_without:client_id', 'nullable', 'string'],
'client_name' => ['nullable', 'string'],
'invoice_no' => ['required', 'string'],
'linked_invoice_id' => ['nullable', 'integer'],
'is_credit_card' => ['nullable', 'boolean'],
'is_paid' => ['nullable', 'boolean'],
'payment_no' => ['nullable', 'string'],
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'management_fee' => ['required', 'numeric', 'min:0'],
'media_fee' => ['required', 'numeric', 'min:0'],
'tax_percent' => ['nullable', 'numeric', 'min:0', 'max:100'],
'total_spending' => ['nullable', 'numeric', 'min:0'],
]);
$mediaFee = $validated['media_fee'];
$taxPercent = $validated['tax_percent'] ?? 0;
$nettAmount = $mediaFee - ($mediaFee * ($taxPercent / 100));
$sqlAccCode = $this->clientLookupService->normalizeSqlAccCode($validated['sql_acc_code'] ?? null);
$client = ! empty($validated['client_id'])
? \App\Models\Client::find($validated['client_id'])
: $this->clientLookupService->findBySqlAccCode($sqlAccCode);
if (! empty($validated['linked_invoice_id'])) {
$linkedInvoiceExists = $client !== null && ClientInvoice::query()
->where('id', $validated['linked_invoice_id'])
->where('client_id', $client->id)
->exists();
if (! $linkedInvoiceExists) {
throw ValidationException::withMessages([
'linked_invoice_id' => 'The linked invoice must belong to the resolved client.',
]);
}
}
$invoice = ClientInvoice::create([
'client_id' => $client?->id,
'pending_sql_acc_code' => $client === null ? $sqlAccCode : null,
'pending_client_name' => $client === null ? ($validated['client_name'] ?? null) : null,
'invoice_no' => $validated['invoice_no'],
'linked_invoice_id' => $validated['linked_invoice_id'] ?? null,
'is_credit_card' => (bool) ($validated['is_credit_card'] ?? false),
'is_paid' => (bool) ($validated['is_paid'] ?? false),
'approved_at' => null,
'payment_no' => $validated['payment_no'] ?? null,
'start_date' => $validated['start_date'] ?? null,
'end_date' => $validated['end_date'] ?? null,
'management_fee' => $validated['management_fee'],
'media_fee' => $validated['media_fee'],
'tax_percent' => $taxPercent,
'nett_amount' => $nettAmount,
'total_spending' => $validated['total_spending'] ?? null,
]);
$this->approvalService->requireApproval($invoice);
return response()->json([
'message' => 'Invoice created and marked for approval.',
'invoice' => $invoice->fresh(),
], 201);
}
public function approve(ClientInvoice $invoice): JsonResponse
{
if ($invoice->client_id === null) {
return response()->json([
'message' => 'Create and link the client before approving this invoice.',
], 409);
}
$invoice = $this->approvalService->approve($invoice);
return response()->json([
'message' => 'Invoice approved successfully.',
'invoice' => $invoice,
]);
}
private function previousPaymentsForInvoice(ClientInvoice $invoice): array
{
if (empty($invoice->invoice_no)) {
return [];
}
try {
$response = Http::acceptJson()
->withHeaders([
'X-Secret' => config('app.billing_key'),
'Accept' => 'application/json',
])
->get(config('app.billing_url').'/customer/invoices/getInvoicePaymentDetailsByInvoiceGoogle', [
'invoice_number' => $invoice->invoice_no,
]);
if (! $response->successful()) {
Log::warning('Unable to fetch invoice payment details.', [
'invoice_no' => $invoice->invoice_no,
'status' => $response->status(),
]);
return [];
}
$records = $this->normalizePaymentRecords($response->json('data'));
return collect($records)
->filter(fn (array $payment) => ($payment['payment_number'] ?? null) !== $invoice->payment_no)
->map(fn (array $payment) => $this->formatPreviousPayment($payment))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('Unable to fetch invoice payment details.', [
'invoice_no' => $invoice->invoice_no,
'message' => $e->getMessage(),
]);
return [];
}
}
private function normalizePaymentRecords(mixed $data): array
{
if (! is_array($data)) {
return [];
}
if (array_is_list($data)) {
return $data;
}
return [$data];
}
private function formatPreviousPayment(array $payment): array
{
$items = collect($payment['items'] ?? [])
->filter(fn (mixed $paymentItem) => is_array($paymentItem))
->values();
$paymentAmount = (float) ($payment['amount'] ?? 0);
$estimatedItemsTotal = $items->sum(
fn (array $paymentItem) => $this->paymentItemEstimatedTotal($paymentItem)
);
$itemAmounts = $items
->map(fn (array $paymentItem) => (float) ($paymentItem['amount'] ?? 0))
->filter(fn (float $amount) => $amount > 0)
->unique()
->values();
$usesRepeatedPaymentAmount = $items->count() > 1
&& $paymentAmount > 0
&& $estimatedItemsTotal > 0
&& $itemAmounts->count() === 1
&& abs($itemAmounts->first() - $paymentAmount) < 0.01;
$totals = $items->reduce(function (array $totals, array $paymentItem) use ($usesRepeatedPaymentAmount, $paymentAmount, $estimatedItemsTotal) {
$sqlAccCode = $this->paymentItemSqlAccCode($paymentItem);
$estimatedTotal = $this->paymentItemEstimatedTotal($paymentItem);
$exact_tax = $this->paymentItemTax($paymentItem);
$taxPercent = $this->paymentTaxPercent($paymentItem)/100 + 1;
$amount = $usesRepeatedPaymentAmount
? $paymentAmount * ($estimatedTotal / $estimatedItemsTotal)
: (float) ($paymentItem['amount'] ?? 0);
if ($sqlAccCode === 'G03') {
$totals['media_fee'] += $amount;
$totals['invoice_media_fee'] += $estimatedTotal;
}
if ($sqlAccCode === 'GOOGLE') {
$totals['management_fee'] += $amount;
$totals['invoice_management_fee'] += $estimatedTotal;
}
return $totals;
}, [
'media_fee' => 0.0,
'management_fee' => 0.0,
'invoice_media_fee' => 0.0,
'invoice_management_fee' => 0.0,
]);
return [
'payment_number' => $payment['payment_number'] ?? null,
'pending_client_name' => $payment['company_name'] ?? null,
'status' => $payment['status'] ?? null,
'sql_created_at' => $payment['sql_created_at'] ?? null,
'amount' => $payment['amount'] ?? null,
'media_fee' => $totals['media_fee']/1.08,
'management_fee' => $totals['management_fee']/1.08,
'invoice_media_fee' => $totals['invoice_media_fee']/1.08,
'invoice_management_fee' => $totals['invoice_management_fee']/1.08,
'invoice_number' => data_get($payment, 'invoice.invoice_number'),
];
}
private function paymentItemSqlAccCode(array $paymentItem): ?string
{
$sqlAccCode = data_get($paymentItem, 'item.item.sql_acc_code')
?? data_get($paymentItem, 'item.sql_acc_code')
?? data_get($paymentItem, 'sql_acc_code');
return is_string($sqlAccCode) ? strtoupper(trim($sqlAccCode)) : null;
}
private function paymentItemEstimatedTotal(array $paymentItem): float
{
return (float) (
data_get($paymentItem, 'item.estimated_total')
?? data_get($paymentItem, 'estimated_total')
?? 0
);
}
private function paymentItemTax(array $paymentItem): float
{
return (float) (
data_get($paymentItem, 'exact_tax')
?? 0
);
}
private function paymentTaxPercent(array $paymentItem): float
{
return (float) (
data_get($paymentItem, 'item.sql_acc_tax_percent')
?? 0
);
}
private function invoiceBillingTotalsFromPayments(array $payments): array
{
$payment = $payments[0] ?? null;
return [
'media_fee' => $payment['invoice_media_fee'] ?? 0,
'management_fee' => $payment['invoice_management_fee'] ?? 0,
];
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ClientInvoice;
use Illuminate\Http\JsonResponse;
class NotificationController extends Controller
{
public function index(): JsonResponse
{
$pendingInvoiceCount = ClientInvoice::query()
->whereNull('approved_at')
->count();
$notifications = collect([
[
'type' => 'pending_invoice',
'title' => 'Pending invoice approvals',
'description' => 'Client invoices waiting for approval',
'count' => $pendingInvoiceCount,
],
])->filter(fn (array $notification) => $notification['count'] > 0)->values();
return response()->json([
'count' => $notifications->sum('count'),
'notifications' => $notifications,
]);
}
}

View File

@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers;
use App\Models\Campaign;
use App\Models\Client;
use App\Models\Industry;
use App\Models\CampaignManager;
use App\Models\User;
use Illuminate\Http\Request;
use Inertia\Inertia;
class CampaignController extends Controller
{
public function index()
{
$campaigns = Campaign::with(['client','consultant','campaignManager'])
->latest()->get();
return Inertia::render('campaigns/index', [
'campaigns' => $campaigns,
]);
}
public function create()
{
return Inertia::render('campaigns/create', [
'clients' => Client::all(),
'consultants' => User::all(),
'campaignManagers' => User::all()
]);
}
public function store(Request $request)
{
// $request->validate([
// 'client_id' => 'required|exists:clients,id',
// 'consultant_id' => 'required|exists:users,id',
// 'campaign_manager_id' => 'required|exists:campaign_managers,id',
// 'industry_id' => 'required|exists:industries,id',
// 'campaign_name' => 'required|string|max:255',
// 'landing_page' => 'nullable|url|max:255',
// 'status' => 'required|in:active,paused,ended,draft',
// ]);
Campaign::create($request->all());
return redirect()->route('campaigns.index')->with('success', 'Campaign created successfully.');
}
public function edit(Campaign $campaign)
{
return Inertia::render('campaigns/edit', [
'campaign' => $campaign->load(['client', 'consultant', 'campaignManager', 'industry']),
'clients' => Client::all(),
'consultants' => User::all(),
'campaignManagers' => User::all(),
]);
}
public function update(Request $request, Campaign $campaign)
{
// $request->validate([
// 'client_id' => 'required|exists:clients,id',
// 'consultant_id' => 'required|exists:users,id',
// 'campaign_manager_id' => 'required|exists:campaign_managers,id',
// 'industry_id' => 'required|exists:industries,id',
// 'campaign_name' => 'required|string|max:255',
// 'landing_page' => 'nullable|url|max:255',
// 'status' => 'required|in:active,paused,ended,draft',
// ]);
$campaign->update($request->all());
return redirect()->route('campaigns.index')->with('success', 'Campaign updated successfully.');
}
public function destroy(Campaign $campaign)
{
$campaign->delete();
return redirect()->route('campaigns.index')->with('success', 'Campaign deleted successfully.');
}
public function show(Campaign $campaign)
{
return Inertia::render('campaigns/show', [
'campaign' => $campaign->load(['client', 'consultant', 'campaignManager', 'industry', 'finances', 'reports', 'remarks']),
]);
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers;
use App\Models\ClientInvoiceAdjustment;
use App\Models\Client;
use App\Services\UserHierarchyService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
class ClientInvoiceAdjustmentController extends Controller
{
public function __construct(private UserHierarchyService $hierarchyService)
{
}
public function store(Request $request, Client $client)
{
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403);
$validated = $request->validate([
'entry_type' => ['required', 'string', Rule::in([
ClientInvoiceAdjustment::TYPE_DEBIT,
ClientInvoiceAdjustment::TYPE_CREDIT,
])],
'amount' => ['required', 'numeric', 'min:0'],
'remark' => ['nullable', 'string'],
]);
ClientInvoiceAdjustment::create([
'client_id' => $client->id,
'entry_type' => $validated['entry_type'],
'amount' => $validated['amount'],
'remark' => $validated['remark'] ?? null,
]);
return redirect()
->back()
->with('message-info', 'Adjustment added successfully.');
}
public function destroy(ClientInvoiceAdjustment $adjustment)
{
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $adjustment->client), 403);
$adjustment->delete();
return redirect()
->back()
->with('message-info', 'Adjustment deleted successfully.');
}
}

View File

@ -0,0 +1,369 @@
<?php
namespace App\Http\Controllers;
use App\Models\Client;
use App\Models\ClientCustomer;
use App\Models\ClientInvoice;
use App\Models\ClientUserAssignation;
use App\Services\ClientInvoiceApprovalService;
use App\Services\ClientLookupService;
use App\Services\UserHierarchyService;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
class ClientInvoiceController extends Controller
{
public function __construct(
private ClientInvoiceApprovalService $approvalService,
private UserHierarchyService $hierarchyService,
private ClientLookupService $clientLookupService,
) {
}
public function create(Request $request): Response
{
$clientId = $request->query('client');
$customerId = $request->query('customer_id');
if (! $clientId || ! $customerId) {
abort(404);
}
$client = Client::findOrFail($clientId);
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403);
$availableInvoices = ClientInvoice::query()
->where('client_id', $clientId)
->orderBy('invoice_no')
->get(['id', 'invoice_no', 'linked_invoice_id']);
return Inertia::render('client-invoices/create', [
'clientId' => $clientId,
'customerId' => $customerId,
'availableInvoices' => $availableInvoices,
]);
}
public function edit(ClientInvoice $invoice): Response
{
abort_if($invoice->client === null, 409, 'Create the client before editing this invoice.');
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403);
$availableInvoices = ClientInvoice::query()
->where('client_id', $invoice->client_id)
->where('id', '!=', $invoice->id)
->orderBy('invoice_no')
->get(['id', 'invoice_no', 'linked_invoice_id']);
return Inertia::render('client-invoices/edit', [
'invoice' => $invoice->load('client'),
'availableInvoices' => $availableInvoices,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'client_id' => ['required', 'exists:clients,id'],
'customer_id' => ['required', 'string'],
'invoice_no' => ['required', 'string'],
'linked_invoice_id' => [
'nullable',
'integer',
Rule::exists('client_invoices', 'id')->where(function ($query) use ($request) {
return $query->where('client_id', $request->integer('client_id'));
}),
],
'is_credit_card' => ['nullable', 'boolean'],
'is_paid' => ['nullable', 'boolean'],
'payment_no' => ['nullable', 'string'],
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'management_fee' => ['required', 'numeric', 'min:0'],
'media_fee' => ['required', 'numeric', 'min:0'],
'tax_percent' => ['nullable', 'numeric', 'min:0', 'max:100'],
'total_spending' => ['nullable', 'numeric', 'min:0'],
]);
$client = Client::findOrFail($validated['client_id']);
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403);
$mediaFee = $validated['media_fee'];
$taxPercent = $validated['tax_percent'] ?? 0;
$nettAmount = $mediaFee - ($mediaFee * ($taxPercent / 100));
$invoice = ClientInvoice::create([
'client_id' => $validated['client_id'],
'invoice_no' => $validated['invoice_no'],
'linked_invoice_id' => $validated['linked_invoice_id'] ?? null,
'is_credit_card' => (bool) ($validated['is_credit_card'] ?? false),
'is_paid' => (bool) ($validated['is_paid'] ?? false),
'approved_at' => null,
'payment_no' => $validated['payment_no'] ?? null,
'start_date' => $validated['start_date'] ?? null,
'end_date' => $validated['end_date'] ?? null,
'management_fee' => $validated['management_fee'],
'media_fee' => $validated['media_fee'],
'tax_percent' => $taxPercent,
'nett_amount' => $nettAmount,
'total_spending' => $validated['total_spending'] ?? null,
]);
$this->approvalService->approve($invoice);
return redirect()
->route('google-ads.accounts.show', ['id' => $validated['customer_id']])
->with('message-info', 'Invoice created successfully.');
}
public function update(Request $request, ClientInvoice $invoice)
{
abort_if($invoice->client === null, 409, 'Create the client before updating this invoice.');
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403);
$validated = $request->validate([
'invoice_no' => ['required', 'string'],
'linked_invoice_id' => [
'nullable',
'integer',
Rule::exists('client_invoices', 'id')->where(function ($query) use ($invoice) {
return $query
->where('client_id', $invoice->client_id)
->where('id', '!=', $invoice->id);
}),
],
'is_credit_card' => ['nullable', 'boolean'],
'is_paid' => ['nullable', 'boolean'],
'payment_no' => ['nullable', 'string'],
'start_date' => ['nullable', 'date'],
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
'amount' => ['required', 'numeric', 'min:0'],
'management_fee' => ['required', 'numeric', 'min:0'],
'media_fee' => ['required', 'numeric', 'min:0'],
'tax_percent' => ['nullable', 'numeric', 'min:0', 'max:100'],
'total_spending' => ['nullable', 'numeric', 'min:0'],
]);
$managementFee = $validated['management_fee'];
$mediaFee = $validated['media_fee'];
$taxPercent = $validated['tax_percent'] ?? 0;
$nettAmount = $mediaFee - ($mediaFee * ($taxPercent / 100));
$invoice->update([
'invoice_no' => $validated['invoice_no'],
'linked_invoice_id' => $validated['linked_invoice_id'] ?? null,
'is_credit_card' => (bool) ($validated['is_credit_card'] ?? false),
'is_paid' => (bool) ($validated['is_paid'] ?? false),
'payment_no' => $validated['payment_no'] ?? null,
'start_date' => $validated['start_date'] ?? null,
'end_date' => $validated['end_date'] ?? null,
'amount' => $validated['amount'],
'management_fee' => $managementFee,
'media_fee' => $validated['media_fee'],
'tax_percent' => $taxPercent,
'nett_amount' => $nettAmount,
'total_spending' => $validated['total_spending'] ?? null,
]);
return redirect()
->route('google-ads.accounts.show', ['id' => $invoice->client->customer_id])
->with('message-info', 'Invoice updated successfully.');
}
public function approve(ClientInvoice $invoice)
{
abort_if($invoice->client === null, 409, 'Create the client before approving this invoice.');
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403);
$this->approvalService->approve($invoice);
return redirect()
->back()
->with('message-info', 'Invoice approved successfully.');
}
public function destroy(ClientInvoice $invoice)
{
if ($invoice->client !== null) {
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403);
}
$invoice->delete();
return redirect()
->back()
->with('message-info', 'Invoice deleted successfully.');
}
public function getPdfInvoice($id)
{
$invoice = ClientInvoice::where('id', $id)->first();
if (! empty($invoice) && ! empty($invoice->invoice_no)) {
if ($invoice->client !== null) {
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $invoice->client), 403);
}
$payload = [
'audience' => 'SEM',
'invoice_numbers' => [$invoice->invoice_no],
];
$invoiceResponse = Http::acceptJson()
->withHeaders([
'X-Secret' => config('app.billing_key'),
'Accept' => 'application/json',
])
->get(config('app.billing_url').'/customer/invoices/', $payload);
if ($invoiceResponse->successful()) {
$invoiceDetails = json_decode($invoiceResponse->body());
$response = Http::withHeaders([
'X-Secret' => config('app.billing_key'),
'Accept' => 'application/json',
])->get($invoiceDetails->data[0]->pdf_url);
if ($response->successful()) {
return response()->stream(
function () use ($response) {
echo $response->body();
},
200,
[
'Content-Type' => 'application/pdf',
]
);
} else {
$response->throw();
}
} else {
$invoiceResponse->throw();
}
}
}
public function createClient(ClientInvoice $invoice): Response|\Illuminate\Http\RedirectResponse
{
$existingClient = $invoice->client
?? $this->clientLookupService->findBySqlAccCode($invoice->pending_sql_acc_code);
if ($existingClient !== null) {
$invoice->update([
'client_id' => $existingClient->id,
'pending_sql_acc_code' => null,
'pending_client_name' => null,
]);
return redirect()
->route('client-invoices.edit', $invoice)
->with('message-info', 'Invoice '.$invoice->invoice_no.' has been linked to '.$existingClient->name.'.');
}
$unlinkedClients = Client::query()
->where(function ($query) {
$query->whereNull('sql_acc_code')
->orWhere('sql_acc_code', '');
})
->whereDoesntHave('customers', function ($query) {
$query->whereNotNull('sql_acc_code')
->where('sql_acc_code', '!=', '');
})
->orderBy('name')
->get(['id', 'name', 'customer_id', 'status', 'time_zone'])
->map(fn (Client $client) => [
'value' => (string) $client->id,
'label' => trim($client->name.' ('.$client->customer_id.')'),
])
->values()
->all();
return Inertia::render('client-invoices/create-client', [
'invoice' => $invoice->load('client'),
'existingClient' => null,
'unlinkedClients' => $unlinkedClients,
]);
}
public function storeClient(Request $request, ClientInvoice $invoice)
{
$validated = $request->validate([
'client_id' => [
'required',
Rule::exists('clients', 'id')->where(function ($query) {
$query
->where(function ($query) {
$query->whereNull('sql_acc_code')
->orWhere('sql_acc_code', '');
});
}),
],
'sql_acc_code' => ['required', 'string'],
]);
$sqlAccCode = $this->clientLookupService->normalizeSqlAccCode($validated['sql_acc_code']);
$existingClient = $this->clientLookupService->findBySqlAccCode($sqlAccCode);
if ($existingClient !== null && $existingClient->id !== (int) $validated['client_id']) {
return redirect()
->back()
->withInput()
->withErrors(['sql_acc_code' => 'This SQL account code is already linked to another client.']);
}
$selectedClientIsLinked = Client::query()
->where('id', $validated['client_id'])
->whereHas('customers', function ($query) {
$query->whereNotNull('sql_acc_code')
->where('sql_acc_code', '!=', '');
})
->exists();
if ($selectedClientIsLinked) {
return redirect()
->back()
->withInput()
->withErrors(['client_id' => 'Select a client that does not already have an SQL account code.']);
}
$client = DB::transaction(function () use ($validated, $sqlAccCode, $invoice) {
$client = Client::findOrFail($validated['client_id']);
$client->update([
'sql_acc_code' => $sqlAccCode,
]);
ClientCustomer::updateOrCreate(
[
'client_id' => $client->id,
'sql_acc_code' => $sqlAccCode,
],
[]
);
ClientUserAssignation::updateOrCreate(
[
'client_id' => $client->id,
'role' => ClientUserAssignation::ROLE_ASSIGNED_PERSON,
],
[
'user_id' => Auth::id(),
]
);
$invoice->update([
'client_id' => $client->id,
'pending_sql_acc_code' => null,
'pending_client_name' => null,
]);
return $client;
});
return redirect()
->route('client-invoices.edit', $invoice)
->with('message-info', 'Client '.$client->name.' has been linked to invoice '.$invoice->invoice_no.'.');
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers;
use App\Models\Client;
use App\Models\ClientInvoice;
use App\Models\ClientProjectActivities;
use App\Services\UserHierarchyService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __construct(private UserHierarchyService $hierarchyService)
{
}
public function __invoke(Request $request): Response
{
$user = $request->user();
$visibleClients = $this->hierarchyService
->scopeClientsVisibleTo(
Client::query()->with(['invoices', 'invoiceAdjustments', 'customers']),
$user
)
->get();
$visibleClientIds = $visibleClients->pluck('id');
$visibleClientQuery = fn (): Builder => Client::query()->whereIn('id', $visibleClientIds);
$visibleInvoiceQuery = fn (): Builder => ClientInvoice::query()->whereIn('client_id', $visibleClientIds);
$unlinkedPendingInvoiceCount = $user->hasRole('Admin')
? ClientInvoice::query()->whereNull('approved_at')->whereNull('client_id')->count()
: 0;
$pendingInvoiceCount = $visibleInvoiceQuery()
->whereNull('approved_at')
->count() + $unlinkedPendingInvoiceCount;
$pendingActivities = ClientProjectActivities::query()
->whereIn('client_id', $visibleClientIds)
->whereNull('completed_at')
->whereNotNull('estimated_completed_at')
->count();
return Inertia::render('dashboard', [
'stats' => [
'totalClients' => $visibleClients->count(),
'pendingInvoices' => $pendingInvoiceCount,
'unlinkedPendingInvoices' => $unlinkedPendingInvoiceCount,
'pendingActivities' => $pendingActivities,
'clientsMissingSqlCode' => $visibleClientQuery()
->where(function (Builder $query) {
$query->whereNull('sql_acc_code')
->orWhere('sql_acc_code', '');
})
->whereDoesntHave('customers', function (Builder $query) {
$query->whereNotNull('sql_acc_code')
->where('sql_acc_code', '!=', '');
})
->count(),
],
'clientStatusCounts' => $visibleClients
->groupBy('status')
->map(fn ($clients) => $clients->count())
->sortKeys()
->toArray(),
'recentInvoices' => $visibleInvoiceQuery()
->with('client:id,name,customer_id')
->latest('id')
->limit(6)
->get([
'id',
'client_id',
'invoice_no',
'approved_at',
'management_fee',
'media_fee',
'nett_amount',
'created_at',
])
->map(fn (ClientInvoice $invoice) => [
'id' => $invoice->id,
'invoice_no' => $invoice->invoice_no,
'approved_at' => $invoice->approved_at?->toDateTimeString(),
'management_fee' => $invoice->management_fee,
'media_fee' => $invoice->media_fee,
'nett_amount' => $invoice->nett_amount,
'created_at' => $invoice->created_at?->toDateString(),
'client' => $invoice->client,
])
->values()
->all(),
'pendingActivities' => ClientProjectActivities::query()
->with(['client:id,name,customer_id', 'user:id,name'])
->whereIn('client_id', $visibleClientIds)
->whereNull('completed_at')
->whereNotNull('estimated_completed_at')
->orderBy('estimated_completed_at')
->limit(6)
->get()
->map(fn (ClientProjectActivities $activity) => [
'id' => $activity->id,
'activity_no' => $activity->activity_no,
'activity_type' => $activity->activity_type,
'task_description' => $activity->task_description,
'estimated_completed_at' => $activity->estimated_completed_at?->toDateString(),
'client' => $activity->client,
'assigned_person' => $activity->user?->name,
])
->values()
->all(),
]);
}
}

View File

@ -0,0 +1,490 @@
<?php
namespace App\Http\Controllers;
use App\Models\Client;
use App\Models\ClientProjectActivities;
use App\Models\ClientUserAssignation;
use App\Models\ClientInvoiceAdjustment;
use App\Models\ClientCustomer;
use App\Models\GoogleCampaignMetric;
use App\Models\User;
use App\Models\ClientInvoice;
use App\Services\GoogleAdsService;
use App\Services\UserHierarchyService;
use Carbon\Carbon;
use Inertia\Inertia;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log as FacadesLog;
use Rap2hpoutre\FastExcel\FastExcel;
class GoogleAdsController extends Controller
{
protected $adsService;
private const GOOGLE_COMPANY_SYNC_LOCK = 'google-ads:get-company-details:running';
public function __construct(
GoogleAdsService $adsService,
private UserHierarchyService $hierarchyService,
)
{
$this->adsService = $adsService;
}
// List all accounts under MCC
public function accounts()
{
// $accounts = collect($this->adsService->listAccounts())
// ->map(function (array $account) {
// $account['customer_id'] = (string) ($account['customer_id'] ?? $account['id']);
// return $account;
// });
// $customerMap = $accounts->keyBy('customer_id');
$localClients = $this->hierarchyService
->scopeClientsVisibleTo(Client::query(), Auth::user())
->with('assignations.user', 'customers', 'invoices', 'invoiceAdjustments')
->get();
$customerMap = $localClients->map(function ($data) {
$assignedPerson = $data->assignations->firstWhere('role', ClientUserAssignation::ROLE_ASSIGNED_PERSON);
$salesPerson = $data->assignations->firstWhere('role', ClientUserAssignation::ROLE_SALES_PERSON);
$data['industry'] = $data->industry;
$data['sql_acc_code'] = implode(',', $data->customers->pluck('sql_acc_code')->toArray());
$data['assigned_person'] = $assignedPerson?->user?->name;
$data['sales_person'] = $salesPerson?->user?->name;
$data['latest_remaining_amount'] = $data->latestRemainingAmount(function (ClientInvoice $invoice) use ($data) {
if (empty($invoice->start_date) || empty($invoice->end_date)) {
return 0;
}
return GoogleCampaignMetric::query()
->join('google_campaigns', 'google_campaign_metrics.google_campaign_id', '=', 'google_campaigns.id')
->where('google_campaigns.client_id', $data->id)
->whereNull('google_campaigns.deleted_at')
->whereNull('google_campaign_metrics.deleted_at')
->whereBetween('google_campaign_metrics.date', [
$invoice->start_date?->toDateString(),
$invoice->end_date?->toDateString(),
])
->sum('google_campaign_metrics.actual_spend');
});
return $data;
});
return Inertia::render('campaigns/index', [
'clients' => $customerMap->values()->all(),
'googleCompanySyncRunning' => Cache::has(self::GOOGLE_COMPANY_SYNC_LOCK),
]);
}
public function syncGoogleCompanyDetails(): RedirectResponse
{
$started = Cache::add(self::GOOGLE_COMPANY_SYNC_LOCK, [
'user_id' => Auth::id(),
'started_at' => now()->toDateTimeString(),
], now()->addHour());
if (! $started) {
return redirect()
->back()
->with('message-warning', 'Google account sync is already running. Please wait for it to finish.');
}
try {
$exitCode = Artisan::call('google-ads:get-company-details');
if ($exitCode !== 0) {
return redirect()
->back()
->with('message-error', 'Google account sync failed. Please check the logs.');
}
return redirect()
->back()
->with('message-info', 'Google account records have been synced.');
} finally {
Cache::forget(self::GOOGLE_COMPANY_SYNC_LOCK);
}
}
public function show($id)
{
// $account = $this->adsService->getAccountDetails($id);
$client = Client::where('customer_id', $id)->firstOrFail();
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403);
$account = [
'id' => $client->customer_id,
'name' => $client->name,
'status' => $client->status,
'time_zone' => $client->time_zone,
];
[$localClient, $assignments, $users, $invoices, $activityList, $pendingActivities, $completedActivities, $lifeTimeSpending, $clientAdjustments] =
$this->hydrateClient($account);
$account = array_merge($account, [
'industry' => $localClient->industry,
'sql_acc_code' => $localClient->sql_acc_code,
'activities_list' => $activityList,
]);
return Inertia::render('campaigns/show', [
'id' => $id,
'client' => $account,
'invoices' => $invoices,
'localClientId' => $localClient->id,
'clientAssignmentRoles' => ClientUserAssignation::roles(),
'clientAssignments' => $assignments,
'assignmentUsers' => $users,
'clientInvoices' => $invoices,
'clientAdjustments' => $clientAdjustments,
'pendingActivities' => $pendingActivities,
'completedActivities' => $completedActivities,
'lifeTimeSpending' => number_format($lifeTimeSpending, 2, '.', ','),
]);
}
public function edit($id)
{
$account = $this->adsService->getAccountDetails($id);
[$localClient, $assignments, $users, $invoices] = $this->hydrateClient($account);
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $localClient), 403);
$account = array_merge($account, [
'name' => $localClient->name,
'customer_id' => $localClient->customer_id,
'status' => $localClient->status,
'time_zone' => $localClient->time_zone,
'industry' => $localClient->industry,
'sql_acc_code' => $localClient->sql_acc_code,
]);
return Inertia::render('campaigns/account/edit', [
'id' => $id,
'client' => $account,
'clientAssignmentRoles' => ClientUserAssignation::roles(),
'clientAssignments' => $assignments,
'assignmentUsers' => $users,
'clientInvoices' => $invoices,
]);
}
public function updateAccount(Request $request, $id)
{
[$localClient] = $this->hydrateClient($this->adsService->getAccountDetails($id));
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $localClient), 403);
$validated = $request->validate([
'name' => ['required', 'string'],
'customer_id' => ['required', 'string', 'unique:clients,customer_id,'.$localClient->id],
'industry' => ['nullable', 'string'],
'sql_acc_code' => ['nullable', 'string'],
'assigned_person' => ['nullable', 'integer', 'exists:users,id'],
'sales_person' => ['nullable', 'integer', 'exists:users,id'],
]);
$localClient->update([
'name' => $validated['name'],
'customer_id' => $validated['customer_id'],
'industry' => $validated['industry'] ?? null,
'sql_acc_code' => $validated['sql_acc_code'] ?? null,
]);
$localClient->customers()->delete();
if (! empty($validated['sql_acc_code'])) {
collect(explode(',', $validated['sql_acc_code']))
->map(fn (string $sqlAccCode) => trim($sqlAccCode))
->filter()
->unique()
->each(function (string $sqlAccCode) use ($localClient) {
ClientCustomer::create([
'client_id' => $localClient->id,
'sql_acc_code' => $sqlAccCode,
]);
});
}
$assignmentValues = [
ClientUserAssignation::ROLE_ASSIGNED_PERSON => $validated['assigned_person'] ?? null,
ClientUserAssignation::ROLE_SALES_PERSON => $validated['sales_person'] ?? null,
];
foreach ($assignmentValues as $role => $userId) {
if ($userId === null) {
$localClient->assignations()->where('role', $role)->delete();
continue;
}
$localClient->assignations()->updateOrCreate(
['role' => $role],
['user_id' => $userId]
);
}
return redirect()
->route('google-ads.accounts.edit', ['id' => $localClient->customer_id])
->with('message-info', 'Account details updated.');
}
public function campaigns($id)
{
$campaigns = $this->adsService->listCampaigns($id);
return response()->json($campaigns);
}
public function listCampaignsMetrics($id, $startDate, $endDate)
{
$campaigns = $this->adsService->listCampaignsMetrics($id, $startDate, $endDate);
return response()->json($campaigns);
}
private function hydrateClient(array $account): array
{
$localClient = Client::updateOrCreate(
['customer_id' => $account['id']],
[
'name' => $account['name'],
'status' => $account['status'],
'time_zone' => $account['time_zone'],
]
);
$localClient->load(['assignations.user', 'invoices']);
$assignments = $localClient->assignations
->mapWithKeys(function (ClientUserAssignation $assignation) {
return [$assignation->role => $assignation->user_id];
})
->toArray();
$users = User::orderBy('name')
->get(['id', 'name', 'email'])
->map(function (User $user) {
return [
'value' => (string) $user->id,
'label' => trim($user->name.' ('.$user->email.')'),
];
})
->toArray();
$clientAdjustments = ClientInvoiceAdjustment::query()
->where('client_id', $localClient->id)
->orderByDesc('created_at')
->get()
->map(function (ClientInvoiceAdjustment $adjustment) {
return [
'id' => $adjustment->id,
'client_id' => $adjustment->client_id,
'entry_type' => $adjustment->entry_type,
'amount' => $adjustment->amount,
'remark' => $adjustment->remark,
'created_at' => $adjustment->created_at?->toDateTimeString(),
];
})
->values()
->all();
$invoices = $localClient->invoices
->map(function ($invoice) use ($account) {
$campaigns = $this->adsService->listCampaigns($account['id']);
$totalInvoiceSpend = 0;
FacadesLog::info('Hydrated client data', [
'campaigns' => $campaigns,
]);
foreach ($campaigns as $campaign) {
FacadesLog::info('Hydrated client data', [
'campaigns' => $campaign['id'],
]);
if (empty($invoice->start_date) || empty($invoice->end_date)) {
continue;
} else {
$metrics = $this->adsService->listCampaignsMetricsById(
$account['id'],
$campaign['id'],
$invoice->start_date?->toDateString() ?? null,
$invoice->end_date?->toDateString() ?? null
);
FacadesLog::info('Hydrated client data', [
'metrics' => $metrics,
]);
$totalSpend = array_sum(array_column($metrics, 'actual_spend'));
$totalInvoiceSpend += $totalSpend;
}
}
return [
'id' => $invoice->id,
'client_id' => $invoice->client_id,
'invoice_no' => $invoice->invoice_no,
'linked_invoice_id' => $invoice->linked_invoice_id,
'is_credit_card' => $invoice->is_credit_card,
'is_paid' => $invoice->is_paid,
'start_date' => $invoice->start_date?->toDateString(),
'end_date' => $invoice->end_date?->toDateString(),
'payment_no' => $invoice->payment_no,
'amount' => $invoice->amount,
'total_spend' => number_format($totalInvoiceSpend, 2, '.', ''),
'management_fee' => $invoice->management_fee,
'media_fee' => $invoice->media_fee,
'tax_percent' => $invoice->tax_percent,
'nett_amount' => $invoice->nett_amount,
'total_spending' => $invoice->total_spending,
];
})
->toArray();
// dd($invoices);
$campaigns = $this->adsService->listCampaigns($localClient->customer_id);
$lifeTimeSpend = 0;
if (! empty($campaigns)) {
foreach ($campaigns as $campaign) {
$metrics = $this->adsService->listCampaignsMetricsById(
$localClient->customer_id,
$campaign['id'],
'1970-01-01', // Start date far in the past to capture all historical data
now()->toDateString() // End date as today`
);
$totalSpend = array_sum(array_column($metrics, 'actual_spend'));
$lifeTimeSpend += number_format($totalSpend, 2, '.', '');
}
}
$activities = $localClient->activitiesList()
->orderByDesc('created_at')
->get()
->map(function (ClientProjectActivities $activity) {
return [
'id' => $activity->id,
'activity_no' => $activity->activity_no,
'activity_type' => $activity->activity_type,
'task_description' => $activity->task_description,
'estimated_completed_at' => data_get($activity, 'estimated_completed_at'),
'completed_at' => data_get($activity, 'completed_at'),
'notification_sent_at' => data_get($activity, 'notification_sent_at'),
'notification_status' => data_get($activity, 'notification_status'),
'note_to_customer' => data_get($activity, 'note_to_customer'),
'assignation' => null,
];
});
$activitiesList = $activities->values()->all();
$pendingActivities = $activities
->filter(function (array $activity) {
return empty($activity['completed_at']) && ! empty($activity['estimated_completed_at']);
})
->values()
->all();
$completedActivities = $activities
->filter(function (array $activity) {
return ! empty($activity['completed_at']);
})
->values()
->all();
FacadesLog::info('Hydrated client data', [
'localClient' => $localClient->toArray(),
'assignments' => $assignments,
'users' => $users,
'invoices' => $invoices,
]);
return [
$localClient,
$assignments,
$users,
$invoices,
$activitiesList,
$pendingActivities,
$completedActivities,
$lifeTimeSpend,
$clientAdjustments,
];
}
public function insertCSVDataToDB()
{
$collection = (new FastExcel)->import(storage_path('app/public/csv/Fixed_HJ.csv'));
$array = $collection->toArray();
foreach ($array as $row) {
$startDate = Carbon::parse($row['start_date'])->format('Y-m-d');
$endDate = Carbon::parse($row['end_date'])->format('Y-m-d');
$client = Client::where('customer_id', str_replace('-', '', $row['customer_id']))->first();
// dd($row);
if ($client) {
$salesUser = User::where('name', $row['sales'])->first();
$pic = User::where('name', $row['pic'])->first();
if ($pic) {
ClientUserAssignation::updateOrCreate(
[
'client_id' => $client->id,
'role' => ClientUserAssignation::ROLE_ASSIGNED_PERSON,
],
[
'user_id' => $pic->id,
]
);
}
if ($salesUser) {
ClientUserAssignation::updateOrCreate(
[
'client_id' => $client->id,
'role' => ClientUserAssignation::ROLE_SALES_PERSON,
],
[
'user_id' => $salesUser->id,
]
);
}
$row['client_id'] = $client->id;
$spend = 0;
if ($client->status != 'CANCELED') {
$campaigns = $this->adsService->listCampaigns($row['customer_id']);
FacadesLog::info('Hydrated client data', [
'campaigns' => $campaigns,
]);
foreach ($campaigns as $campaign) {
FacadesLog::info('Hydrated client data', [
'campaigns' => $campaign['id'],
]);
if (empty($startDate) || empty($endDate)) {
continue;
} else {
$metrics = $this->adsService->listCampaignsMetricsById(
$row['customer_id'],
$campaign['id'],
$startDate,
$endDate
);
FacadesLog::info('Hydrated client data', [
'metrics' => $metrics,
]);
$totalSpend = array_sum(array_column($metrics, 'actual_spend'));
$spend += $totalSpend;
}
}
}
ClientInvoice::updateOrCreate(
['invoice_no' => $row['invoice_no']],
[
'client_id' => $row['client_id'],
'is_credit_card' => intval($row['media_fee']) == 0 ? 1 : 0,
'start_date' => $startDate,
'end_date' => $endDate,
'management_fee' => intval($row['management_fee']),
'media_fee' => intval($row['media_fee']),
'tax_percent' => 8,
'nett_amount' => intval($row['media_fee']) > 0 ? intval($row['media_fee']) / 1.08 : 0,
'total_spending' => $spend,
]
);
} else {
FacadesLog::warning('Client not found for customer_id: '.str_replace('-', '', $row['customer_id']));
continue; // Skip this row if client not found
}
}
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers;
use App\Models\Client;
use App\Services\GoogleAdsService;
use App\Services\UserHierarchyService;
use Inertia\Inertia;
use Inertia\Response;
// use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class GoogleController extends Controller
{
protected $adsService;
public function __construct(
GoogleAdsService $adsService,
private UserHierarchyService $hierarchyService,
)
{
$this->adsService = $adsService;
}
public function campaigns($id)
{
$campaigns = $this->adsService->listCampaigns($id);
return response()->json($campaigns);
}
public function listCampaignsMetrics(Request $request)
{
$clientCustomerId = $request->clientCustomerId;
$startDate = $request->startDate;
$endDate = $request->endDate;
$client = Client::where('customer_id', $clientCustomerId)->firstOrFail();
abort_unless($this->hierarchyService->canViewClient(Auth::user(), $client), 403);
$campaigns = $this->adsService->listCampaigns($clientCustomerId);
$campaignsWithMetrics = [];
foreach ($campaigns as $campaign) {
$metrics = $this->adsService->listCampaignsMetrics(
$clientCustomerId,
$campaign['id'],
$startDate,
$endDate
);
$campaign['total_actual_spend'] = 0;
$campaign['total_impressions'] = 0;
$campaign['total_clicks'] = 0;
$campaign['metrics'] = [];
foreach ($metrics as $metric) {
$campaign['total_actual_spend'] += $metric['actual_spend'] ?? 0;
$campaign['total_impressions'] += $metric['impressions'] ?? 0;
$campaign['total_clicks'] += $metric['clicks'] ?? 0;
}
// CPC calculation
$campaign['cpc'] = $campaign['total_clicks'] > 0
? $campaign['total_actual_spend'] / $campaign['total_clicks']
: 0;
$campaign['total_actual_spend'] = number_format($campaign['total_actual_spend'], 2, '.', '');
$campaign['metrics'] = $metrics;
$campaignsWithMetrics[] = $campaign;
}
$totalSpend = array_sum(array_column($campaignsWithMetrics, 'total_actual_spend'));
$totalClicks = array_sum(array_column($campaignsWithMetrics, 'total_clicks'));
return response()->json([
'campaigns' => $campaignsWithMetrics,
'summary' => [
'total_actual_spend' => number_format(array_sum(array_column($campaignsWithMetrics, 'total_actual_spend')), 2, '.', ''),
'total_impressions' => array_sum(array_column($campaignsWithMetrics, 'total_impressions')),
'total_clicks' => array_sum(array_column($campaignsWithMetrics, 'total_clicks')),
]
]);
}
}

View File

@ -0,0 +1,140 @@
<?php
namespace App\Http\Controllers\Management;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Inertia\Inertia;
use Inertia\Response;
use Spatie\Permission\Models\Permission;
use App\Services\RoleService;
use Spatie\Permission\Models\Role;
class RoleController extends Controller
{
protected $roleService;
public function __construct(RoleService $roleService)
{
$this->roleService = $roleService;
// $this->middleware('auth:web');
}
public function index(): Response
{
$roles = $this->roleService->getAllRoles();
return Inertia::render('management/roles/index', [
'roles' => $roles['data'],
]);
}
// public function create(): Response
// {
// $permissions = Permission::all()->mapWithKeys(fn (Permission $permission) => [
// $permission->name => false,
// ]);
// return Inertia::render('Admin/UserManagement/Role/Create', [
// 'permissions' => Arr::undot($permissions),
// ]);
// }
// public function store(Request $request): RedirectResponse
// {
// $request->validate([
// 'name' => ['required', 'string'],
// 'permissions' => ['required', 'array'],
// ]);
// $permissions = Arr::dot($request->permissions);
// $permissions = array_keys(array_filter($permissions, fn ($permission) => $permission === true));
// $role = Role::create([
// 'name' => $request->name,
// ]);
// $role->syncPermissions($permissions);
// return redirect()
// ->route('admin.user_management.role.index')
// ->with('message-info', 'Role ' . $role->name . ' has created successfully.');
// }
public function edit(int $id): Response
{
$role = Role::findOrFail($id);
// 1. Get all permissions with their "checked" state
$permissions = Permission::all()->map(function ($permission) use ($role) {
return [
'id' => $permission->id,
'name' => $permission->name, // e.g. "user.create"
'description' => $permission->description,
'checked' => $role->hasPermissionTo($permission->name),
];
});
// 2. Group them by the prefix (the part before the dot)
$grouped = $permissions->groupBy(function ($item) {
return explode('.', $item['name'])[0];
})->map(function ($group) {
// 3. Force it to be a sequential array so JS sees it as []
return $group->values()->toArray();
});
return Inertia::render('management/roles/edit', [
'role' => $role,
'permissions' => $grouped,
]);
}
public function update(int $id, Request $request): RedirectResponse
{
$role = Role::findOrFail($id);
$request->validate([
'name' => ['required', 'string'],
'permissions' => ['required', 'array'],
]);
// 1. Extract only the IDs of permissions that are checked
$permissionIds = [];
foreach ($request->permissions as $group => $items) {
foreach ($items as $permission) {
if (! empty($permission['checked'])) {
$permissionIds[] = $permission['id'];
}
}
}
// 2. Update Role name
$role->update([
'name' => $request->name
]);
// 3. Sync permissions using the collected IDs
$role->syncPermissions($permissionIds);
return redirect()
->route('management.roles.index')
->with('message-info', 'Role '.$role->name.' has updated successfully.');
}
// public function destroy(int $id): RedirectResponse
// {
// $role = Role::findOrFail($id);
// if ($role->name == 'Admin') {
// return redirect()
// ->route('user_management.user.index')
// ->with('message-error', 'User ' . $role->name . ' cannot be delete.');
// }
// $role->delete();
// return redirect()
// ->route('admin.user_management.role.index')
// ->with('message-info', 'Role has deleted successfully.');
// }
}

View File

@ -0,0 +1,152 @@
<?php
namespace App\Http\Controllers\Management;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\UserHierarchyService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
use Spatie\Permission\Models\Role;
class UserController extends Controller
{
public function __construct(private UserHierarchyService $hierarchyService)
{
}
// public function __construct()
// {
// $this->middleware('auth:web');
// }
public function index(): Response
{
$users = User::with(['roles', 'manager'])->get();
$roles = Role::all();
return Inertia::render('management/users/index', [
'users' => $users,
'roles' => $roles,
]);
}
public function create(): Response
{
$roles = Role::all();
$managers = $this->managerOptions();
return Inertia::render('management/users/create', [
'roles' => $roles,
'managers' => $managers,
]);
}
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string'],
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', 'string', 'min:8'],
'manager_id' => ['nullable', 'integer', 'exists:users,id'],
'roles' => ['required', 'array'],
'roles.*' => ['required', 'string'],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'manager_id' => $request->manager_id,
]);
$user->syncRoles($request->roles);
return redirect()
->route('management.users.index')
->with('message-info', 'User ' . $user->name . ' has created successfully.');
}
public function edit(int $id): Response
{
$user = User::with(['roles', 'manager'])->findOrFail($id);
$roles = Role::all();
$managers = $this->managerOptions($user->id);
return Inertia::render('management/users/edit', [
'user' => $user,
'roles' => $roles,
'managers' => $managers,
]);
}
public function update(int $id, Request $request): RedirectResponse
{
$user = User::findOrFail($id);
$request->validate([
'name' => ['required', 'string'],
'email' => ['required', 'email', 'unique:users,email,' . $user->id],
'password' => ['nullable', 'string', 'min:8'],
'manager_id' => [
'nullable',
'integer',
Rule::exists('users', 'id')->where(fn ($query) => $query->where('id', '!=', $user->id)),
],
'roles' => ['required', 'array'],
'roles.*' => ['required', 'string'],
]);
if ($this->hierarchyService->wouldCreateCycle($user, $request->integer('manager_id') ?: null)) {
return redirect()
->back()
->withInput()
->withErrors(['manager_id' => 'A user cannot report to themselves or one of their reports.']);
}
$user->name = $request->name;
$user->email = $request->email;
$user->manager_id = $request->manager_id;
if ($request->password !== null) {
$user->password = Hash::make($request->password);
}
$user->save();
$user->syncRoles($request->roles);
return redirect()
->route('management.users.index')
->with('message-info', 'User ' . $user->name . ' has updated successfully.');
}
public function destroy(int $id): RedirectResponse
{
$user = User::findOrFail($id);
if ($user->id === 1) {
return redirect()
->route('management.users.index')
->with('message-error', 'User ' . $user->name . ' cannot be delete.');
}
$user->delete();
return redirect()
->route('management.users.index')
->with('message-info', 'User ' . $user->name . ' has deleted successfully.');
}
private function managerOptions(?int $excludedUserId = null): array
{
return User::query()
->when($excludedUserId !== null, fn ($query) => $query->where('id', '!=', $excludedUserId))
->orderBy('name')
->get(['id', 'name', 'email'])
->map(fn (User $user) => [
'value' => (string) $user->id,
'label' => trim($user->name . ' (' . $user->email . ')'),
])
->all();
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
class PasswordController extends Controller
{
/**
* Show the user's password settings page.
*/
public function edit(): Response
{
return Inertia::render('settings/password');
}
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => $validated['password'],
]);
return back();
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\ProfileUpdateRequest;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
/**
* Show the user's profile settings page.
*/
public function edit(Request $request): Response
{
return Inertia::render('settings/profile', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'status' => $request->session()->get('status'),
]);
}
/**
* Update the user's profile settings.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return to_route('profile.edit');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validate([
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\TwoFactorAuthenticationRequest;
use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Routing\Controllers\Middleware;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Fortify\Features;
class TwoFactorAuthenticationController extends Controller implements HasMiddleware
{
/**
* Get the middleware that should be assigned to the controller.
*/
public static function middleware(): array
{
return Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword')
? [new Middleware('password.confirm', only: ['show'])]
: [];
}
/**
* Show the user's two-factor authentication settings page.
*/
public function show(TwoFactorAuthenticationRequest $request): Response
{
$request->ensureStateIsValid();
return Inertia::render('settings/two-factor', [
'twoFactorEnabled' => $request->user()->hasEnabledTwoFactorAuthentication(),
'requiresConfirmation' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
]);
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class HandleAppearance
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
View::share('appearance', $request->cookie('appearance') ?? 'system');
return $next($request);
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Inspiring;
use Illuminate\Http\Request;
use Inertia\Middleware;
use Spatie\Permission\Models\Permission;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that's loaded on the first page visit.
*
* @see https://inertiajs.com/server-side-setup#root-template
*
* @var string
*/
protected $rootView = 'app';
/**
* Determines the current asset version.
*
* @see https://inertiajs.com/asset-versioning
*/
public function version(Request $request): ?string
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @see https://inertiajs.com/shared-data
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{
[$message, $author] = str(Inspiring::quotes()->random())->explode('-');
return [
...parent::share($request),
'name' => config('app.name'),
'quote' => ['message' => trim($message), 'author' => trim($author)],
'auth' => [
'user' => $request->user()?->load('roles'),
'permissions' => fn () => $request->user('web') !== null ?
Permission::all()->mapWithKeys(fn (Permission $permission) => [$permission->name => $request->user()->hasPermissionTo($permission->name)])
: null,
],
'flash' => [
'message-info' => fn () => $request->session()->get('message-info'),
'message-warning' => fn () => $request->session()->get('message-warning'),
'message-error' => fn () => $request->session()->get('message-error'),
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
];
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Settings;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Settings;
use Illuminate\Foundation\Http\FormRequest;
use Laravel\Fortify\Features;
use Laravel\Fortify\InteractsWithTwoFactorState;
class TwoFactorAuthenticationRequest extends FormRequest
{
use InteractsWithTwoFactorState;
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return Features::enabled(Features::twoFactorAuthentication());
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [];
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class CampaignFinance extends Model
{
use HasFactory;
protected $fillable = [
'campaign_id',
'total_media_fee',
'withholding_tax_rate',
'total_media_after_tax',
'accumulated_media_fee',
'monthly_media_fee',
'management_fee',
'duration_months',
'checking_date',
'budget_spent',
'balance_as_at_2024_03_01',
'additional_sst_2_percent',
'balance_reminder',
];
public function campaign()
{
return $this->belongsTo(Campaign::class);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class CampaignRemark extends Model
{
use HasFactory;
protected $fillable = [
'campaign_id',
'remark_date',
'remark',
];
public function campaign()
{
return $this->belongsTo(Campaign::class);
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class CampaignReport extends Model
{
use HasFactory;
protected $fillable = [
'campaign_id',
'report_status',
'report_date',
'report_file_path',
];
public function campaign()
{
return $this->belongsTo(Campaign::class);
}
}

88
app/Models/Client.php Normal file
View File

@ -0,0 +1,88 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Models\ClientProjectActivities;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Client extends Model
{
use HasFactory;
protected $fillable = ['name','customer_id','status','time_zone','industry','sql_acc_code'];
protected $appends = [
'latest_remaining_amount',
];
public function campaigns()
{
return $this->hasMany(GoogleCampaign::class);
}
public function assignations()
{
return $this->hasMany(ClientUserAssignation::class);
}
public function invoices(): HasMany
{
return $this->hasMany(ClientInvoice::class);
}
public function invoiceAdjustments(): HasMany
{
return $this->hasMany(ClientInvoiceAdjustment::class);
}
public function getLatestRemainingAmountAttribute(): string
{
return $this->latestRemainingAmount();
}
public function latestRemainingAmount(?callable $invoiceSpendingResolver = null): string
{
$invoices = $this->relationLoaded('invoices')
? $this->invoices
: $this->invoices()->get(['client_id', 'is_credit_card', 'nett_amount', 'total_spending']);
$adjustments = $this->relationLoaded('invoiceAdjustments')
? $this->invoiceAdjustments
: $this->invoiceAdjustments()->get(['client_id', 'entry_type', 'amount']);
$nettAmount = $invoices
->sum(fn (ClientInvoice $invoice) => (float) ($invoice->nett_amount ?? 0));
$billableSpending = $invoices
->reject(fn (ClientInvoice $invoice) => $invoice->is_credit_card)
->sum(function (ClientInvoice $invoice) use ($invoiceSpendingResolver) {
if ($invoiceSpendingResolver !== null) {
return (float) $invoiceSpendingResolver($invoice);
}
return (float) ($invoice->total_spending ?? 0);
});
$adjustmentNet = $adjustments->sum(function (ClientInvoiceAdjustment $adjustment) {
$amount = (float) ($adjustment->amount ?? 0);
return $adjustment->entry_type === ClientInvoiceAdjustment::TYPE_CREDIT
? $amount
: -$amount;
});
return number_format(max(0, $nettAmount + $adjustmentNet - $billableSpending), 2, '.', '');
}
public function activitiesList(): HasMany
{
return $this->hasMany(ClientProjectActivities::class);
}
public function customers()
{
return $this->hasMany(ClientCustomer::class);
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ClientCustomer extends Model
{
protected $fillable = ['client_id', 'sql_acc_code'];
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ClientInvoice extends Model
{
use HasFactory;
protected $fillable = [
'client_id',
'pending_sql_acc_code',
'pending_client_name',
'invoice_no',
'linked_invoice_id',
'is_credit_card',
'is_paid',
'approved_at',
'start_date',
'end_date',
'payment_no',
'amount',
'tax_percent',
'media_fee',
'management_fee',
'nett_amount',
'total_spending',
];
protected $casts = [
'start_date' => 'date',
'end_date' => 'date',
'is_credit_card' => 'boolean',
'is_paid' => 'boolean',
'approved_at' => 'datetime',
'amount' => 'decimal:2',
'tax_percent' => 'decimal:2',
'media_fee' => 'decimal:2',
'management_fee' => 'decimal:2',
'nett_amount' => 'decimal:2',
'total_spending' => 'decimal:2',
];
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function linkedInvoice(): BelongsTo
{
return $this->belongsTo(self::class, 'linked_invoice_id');
}
public function linkedInvoices(): HasMany
{
return $this->hasMany(self::class, 'linked_invoice_id');
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ClientInvoiceAdjustment extends Model
{
use HasFactory;
public const TYPE_DEBIT = 'debit';
public const TYPE_CREDIT = 'credit';
protected $fillable = [
'client_id',
'entry_type',
'amount',
'remark',
];
protected $casts = [
'amount' => 'decimal:2',
];
public function client(): BelongsTo
{
return $this->belongsTo(Client::class, 'client_id');
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use DateTimeInterface;
class ClientProjectActivities extends Model
{
protected $fillable = [
'client_id',
'activity_type',
'task_description',
'estimated_completed_at',
'completed_at',
'amount',
'notification_status',
'user_id',
];
protected $casts = [
'estimated_completed_at' => 'datetime',
'completed_at' => 'datetime',
];
protected $appends = ['activity_no'];
protected function serializeDate(DateTimeInterface $date): string
{
return $date->format('Y-m-d');
}
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function getIsCompletedAttribute(): bool
{
return ! is_null($this->completed_at);
}
public function getActivityNoAttribute()
{
return 'ACT'.str_pad($this->id, 4, '0', STR_PAD_LEFT);
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ClientUserAssignation extends Model
{
use HasFactory;
public const ROLE_ASSIGNED_PERSON = 1;
public const ROLE_SALES_PERSON = 2;
protected $fillable = [
'client_id',
'user_id',
'role',
];
public static function roles(): array
{
return [
[
'id' => self::ROLE_ASSIGNED_PERSON,
'label' => 'Assigned Person',
'field' => 'assigned_person',
],
[
'id' => self::ROLE_SALES_PERSON,
'label' => 'Sales Person',
'field' => 'sales_person',
],
];
}
public function user()
{
return $this->belongsTo(User::class);
}
}

13
app/Models/Customers.php Normal file
View File

@ -0,0 +1,13 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Models\ClientProjectActivities;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Customers extends Model
{
use HasFactory;
}

18
app/Models/GoogleAd.php Normal file
View File

@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class GoogleAd extends Model
{
protected $fillable = [
'google_ad_group_id',
'ad_id',
'name',
'status',
'type',
'final_urls',
'approval_status',
];
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class GoogleAdGroup extends Model
{
protected $fillable = [
'google_campaign_id',
'ad_group_id',
'name',
'status',
'type',
'cpc_bid_micros',
];
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class GoogleAdGroupMetric extends Model
{
protected $fillable = [
'google_ad_group_id',
'date',
'impressions',
'clicks',
'actual_spend',
'conversions',
'ctr',
'average_cpc',
];
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class GoogleAdMetric extends Model
{
protected $fillable = [
'google_ad_id',
'date',
'impressions',
'clicks',
'actual_spend',
'conversions',
'ctr',
'average_cpc',
];
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class GoogleAsset extends Model
{
protected $fillable = [
'google_campaign_id',
'asset_id',
'name',
'type',
'status',
'added_by',
'use_case',
'approval_status',
'review_status',
];
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class GoogleAssetMetric extends Model
{
protected $fillable = [
'google_asset_id',
'date',
'impressions',
'clicks',
'actual_spend',
'conversions',
'ctr',
'average_cpc',
];
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class GoogleCampaign extends Model
{
use HasFactory;
protected $fillable = [
'client_id',
'campaign_id',
'name',
'status',
'channel',
'sub_channel',
'start_date',
'end_date',
'resource_name',
];
// Relationships
public function client()
{
return $this->belongsTo(Client::class);
}
public function consultant()
{
return $this->belongsTo(\App\Models\User::class, 'consultant_id');
}
public function campaignManager()
{
return $this->belongsTo(\App\Models\User::class, 'campaign_manager_id');
}
public function finances()
{
return $this->hasOne(CampaignFinance::class);
}
public function reports()
{
return $this->hasMany(CampaignReport::class);
}
public function remarks()
{
return $this->hasMany(CampaignRemark::class);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class GoogleCampaignMetric extends Model
{
protected $fillable = [
'google_campaign_id',
'google_campaign_metric_id',
'date',
'impressions',
'clicks',
'daily_budget',
'actual_spend',
'conversions',
'conversions_value',
'interactions',
'interaction_rate',
];
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class GoogleKeywordMetric extends Model
{
protected $fillable = [
'google_keyword_id',
'date',
'google_keyword_metric_id',
'impressions',
'clicks',
'actual_spend',
'conversions',
'ctr',
'average_cpc',
];
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class GoogleKeywords extends Model
{
protected $fillable = [
'google_ad_group_id',
'keyword_id',
'text',
'status',
'match_type',
];
}

71
app/Models/User.php Normal file
View File

@ -0,0 +1,71 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, TwoFactorAuthenticatable, HasRoles;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
'manager_id',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'two_factor_secret',
'two_factor_recovery_codes',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_confirmed_at' => 'datetime',
];
}
public function manager(): BelongsTo
{
return $this->belongsTo(self::class, 'manager_id');
}
public function directReports(): HasMany
{
return $this->hasMany(self::class, 'manager_id');
}
public function clientAssignations(): HasMany
{
return $this->hasMany(ClientUserAssignation::class);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

View File

@ -0,0 +1,91 @@
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Laravel\Fortify\Features;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configureActions();
$this->configureViews();
$this->configureRateLimiting();
}
/**
* Configure Fortify actions.
*/
private function configureActions(): void
{
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::createUsersUsing(CreateNewUser::class);
}
/**
* Configure Fortify views.
*/
private function configureViews(): void
{
Fortify::loginView(fn (Request $request) => Inertia::render('auth/login', [
'canResetPassword' => Features::enabled(Features::resetPasswords()),
'canRegister' => Features::enabled(Features::registration()),
'status' => $request->session()->get('status'),
]));
Fortify::resetPasswordView(fn (Request $request) => Inertia::render('auth/reset-password', [
'email' => $request->email,
'token' => $request->route('token'),
]));
Fortify::requestPasswordResetLinkView(fn (Request $request) => Inertia::render('auth/forgot-password', [
'status' => $request->session()->get('status'),
]));
Fortify::verifyEmailView(fn (Request $request) => Inertia::render('auth/verify-email', [
'status' => $request->session()->get('status'),
]));
Fortify::registerView(fn () => Inertia::render('auth/register'));
Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/two-factor-challenge'));
Fortify::confirmPasswordView(fn () => Inertia::render('auth/confirm-password'));
}
/**
* Configure rate limiting.
*/
private function configureRateLimiting(): void
{
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Services;
use ClickHouseDB\Client;
class ClickHouseService
{
protected $client;
public function __construct()
{
$this->client = new Client([
'host' => env('CLICKHOUSE_HOST'),
'port' => env('CLICKHOUSE_PORT', 8123),
'username' => env('CLICKHOUSE_USER', 'default'),
'password' => env('CLICKHOUSE_PASSWORD', ''),
]);
$this->client->database(env('CLICKHOUSE_DATABASE', 'default'));
}
public function getClient()
{
return $this->client;
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Services;
use App\Models\ClientInvoice;
class ClientInvoiceApprovalService
{
public function approve(ClientInvoice $invoice): ClientInvoice
{
if ($invoice->approved_at === null) {
$invoice->forceFill([
'approved_at' => now(),
])->save();
}
return $invoice->refresh();
}
public function requireApproval(ClientInvoice $invoice): ClientInvoice
{
if ($invoice->approved_at !== null) {
$invoice->forceFill([
'approved_at' => null,
])->save();
}
return $invoice->refresh();
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Services;
use App\Models\Client;
class ClientLookupService
{
public function findBySqlAccCode(?string $sqlAccCode): ?Client
{
$sqlAccCode = $this->normalizeSqlAccCode($sqlAccCode);
if ($sqlAccCode === null) {
return null;
}
return Client::query()
->where('sql_acc_code', $sqlAccCode)
->orWhereHas('customers', function ($query) use ($sqlAccCode) {
$query->where('sql_acc_code', $sqlAccCode);
})
->first();
}
public function normalizeSqlAccCode(?string $sqlAccCode): ?string
{
if ($sqlAccCode === null) {
return null;
}
$sqlAccCode = trim($sqlAccCode);
return $sqlAccCode === '' ? null : $sqlAccCode;
}
}

View File

@ -0,0 +1,819 @@
<?php
namespace App\Services;
use Google\Ads\GoogleAds\Lib\V22\GoogleAdsClientBuilder;
use Google\Ads\GoogleAds\Lib\OAuth2TokenBuilder;
use Google\Ads\GoogleAds\V22\Services\SearchGoogleAdsRequest;
use Google\Ads\GoogleAds\V22\Enums\CustomerStatusEnum\CustomerStatus;
use Google\Ads\GoogleAds\V22\Enums\CampaignStatusEnum\CampaignStatus;
use Google\Ads\GoogleAds\V22\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType;
use Google\Ads\GoogleAds\V22\Enums\AdvertisingChannelSubTypeEnum\AdvertisingChannelSubType;
use Google\Ads\GoogleAds\V22\Enums\AdGroupStatusEnum\AdGroupStatus;
use Google\Ads\GoogleAds\V22\Enums\AdGroupTypeEnum\AdGroupType;
use Google\Ads\GoogleAds\V22\Enums\PolicyApprovalStatusEnum\PolicyApprovalStatus;
use Google\Ads\GoogleAds\V22\Enums\AdGroupAdStatusEnum\AdGroupAdStatus;
use Google\Ads\GoogleAds\V22\Enums\AdTypeEnum\AdType;
use Google\Ads\GoogleAds\V22\Enums\KeywordMatchTypeEnum\KeywordMatchType;
use Google\Ads\GoogleAds\V22\Enums\AdGroupCriterionStatusEnum\AdGroupCriterionStatus;
use Google\Ads\GoogleAds\V22\Enums\AssetSourceEnum\AssetSource;
use Google\Ads\GoogleAds\V22\Enums\AssetTypeEnum\AssetType;
use Google\Ads\GoogleAds\V22\Enums\AssetFieldTypeEnum\AssetFieldType;
use Google\Ads\GoogleAds\V22\Enums\PolicyReviewStatusEnum\PolicyReviewStatus;
use Google\Ads\GoogleAds\V22\Enums\AssetLinkStatusEnum\AssetLinkStatus;
use GPBMetadata\Google\Api\Log;
use Illuminate\Support\Facades\Log as FacadesLog;
class GoogleAdsService
{
protected $oAuth2Credential;
protected $developerToken;
protected $loginCustomerId;
public function __construct()
{
$this->oAuth2Credential = (new OAuth2TokenBuilder())
->withClientId(env('GOOGLE_ADS_CLIENT_ID'))
->withClientSecret(env('GOOGLE_ADS_CLIENT_SECRET'))
->withRefreshToken(env('GOOGLE_ADS_REFRESH_TOKEN'))
->build();
$this->developerToken = env('GOOGLE_ADS_DEVELOPER_TOKEN');
$this->loginCustomerId = env('GOOGLE_ADS_LOGIN_CUSTOMER_ID'); // MCC ID
}
/**
* Build a GoogleAdsClient scoped to a specific customer ID
*/
protected function buildClient(string $customerId)
{
return (new GoogleAdsClientBuilder())
->withDeveloperToken($this->developerToken)
->withOAuth2Credential($this->oAuth2Credential)
->withLoginCustomerId($customerId)
->withTransport('rest')
->build();
}
/**
* List all accounts under MCC
*/
public function listAccounts(): array
{
$client = $this->buildClient($this->loginCustomerId);
$service = $client->getGoogleAdsServiceClient();
$query = <<<QUERY
SELECT
customer_client.id,
customer_client.client_customer,
customer_client.level,
customer_client.manager,
customer_client.descriptive_name,
customer_client.status,
customer_client.applied_labels,
customer_client.resource_name,
customer_client.time_zone
FROM
customer_client
QUERY;
$request = new SearchGoogleAdsRequest([
'customer_id' => $this->loginCustomerId,
'query' => $query,
]);
$response = $service->search($request);
$accounts = [];
foreach ($response->iterateAllElements() as $row) {
$clientData = $row->getCustomerClient();
$accounts[] = [
'customer_id' => (string) $clientData->getId(),
'id' => $clientData->getId(),
'name' => $clientData->getDescriptiveName(),
'level' => $clientData->getLevel(),
'status' => CustomerStatus::name($clientData->getStatus()),
'resource_name' => $clientData->getResourceName(),
'time_zone' => $clientData->getTimeZone(),
'applied_labels' => $clientData->getAppliedLabels(),
'manager' => $clientData->getManager(),
];
}
return $accounts;
}
public function listCampaigns(string $clientCustomerId): array
{
$client = $this->buildClient($this->loginCustomerId);
$service = $client->getGoogleAdsServiceClient();
$customerId = str_replace('-', '', $clientCustomerId);
$query = <<<QUERY
SELECT
campaign.id,
campaign.name,
campaign.status,
campaign.advertising_channel_type,
campaign.advertising_channel_sub_type,
campaign.start_date,
campaign.end_date,
campaign.resource_name,
campaign.serving_status,
campaign.bidding_strategy_type,
campaign.campaign_budget,
campaign.primary_status,
campaign.primary_status_reasons,
campaign.payment_mode
FROM campaign
QUERY;
$request = new SearchGoogleAdsRequest([
'customer_id' => $customerId,
'query' => $query,
]);
$response = $service->search($request);
$campaigns = [];
foreach ($response->iterateAllElements() as $row) {
$c = $row->getCampaign();
$campaigns[] = [
'id' => $c->getId(),
'name' => $c->getName(),
'status' => CampaignStatus::name($c->getStatus()),
'channel' => AdvertisingChannelType::name($c->getAdvertisingChannelType()),
'sub_channel' => AdvertisingChannelSubType::name($c->getAdvertisingChannelSubType()),
'start_date' => $c->getStartDate(),
'end_date' => $c->getEndDate(),
'resource_name' => $c->getResourceName(),
];
}
return $campaigns;
}
public function listCampaignsMetrics(string $clientCustomerId, string $campaignId, string $startDate, string $endDate): array
{
$client = $this->buildClient($this->loginCustomerId);
$service = $client->getGoogleAdsServiceClient();
$customerId = str_replace('-', '', $clientCustomerId);
$query = <<<QUERY
SELECT
campaign.id,
campaign.name,
segments.date,
campaign_budget.amount_micros,
metrics.impressions,
metrics.clicks,
metrics.ctr,
metrics.average_cpc,
metrics.conversions,
metrics.conversions_value,
metrics.cost_per_conversion,
metrics.all_conversions_from_interactions_rate,
metrics.interactions,
metrics.interaction_rate,
metrics.all_conversions,
metrics.view_through_conversions,
metrics.cost_micros
FROM campaign
WHERE segments.date BETWEEN '$startDate' AND '$endDate'
AND campaign.id = '$campaignId'
ORDER BY segments.date ASC
QUERY;
$request = new SearchGoogleAdsRequest([
'customer_id' => $customerId,
'query' => $query,
]);
$response = $service->search($request);
FacadesLog::info('Google Ads Query', [
'query' => $query,
'customer_id' => $customerId,
]);
$metrics = [];
foreach ($response->iterateAllElements() as $row) {
$c = $row->getCampaign();
$metrics[] = [
'id' => $c->getId(),
'name' => $c->getName(),
'date' => $row->getSegments()->getDate(),
'impressions' => $row->getMetrics()->getImpressions(),
'clicks' => $row->getMetrics()->getClicks(),
// 'ctr' => round($row->getMetrics()->getCtr() * 100, 2),
'daily_budget' => $row->getCampaignBudget()->getAmountMicros() / 1000000,
'actual_spend' => number_format($row->getMetrics()->getCostMicros() / 1000000, 2, '.', ''),
// 'average_cpc' => round($row->getMetrics()->getAverageCpc() / 1000000, 2),
'conversions' => $row->getMetrics()->getConversions(),
'conversions_value' => $row->getMetrics()->getConversionsValue(),
'cost_per_conversion' =>number_format(( $row->getMetrics()->getCostPerConversion() / 1000000), 2, '.', ''),
// 'conversions_from_interactions_rate' => round($row->getMetrics()->getConversionsFromInteractionsRate() * 100, 2),
'interactions' => $row->getMetrics()->getInteractions(),
'interaction_rate' => number_format($row->getMetrics()->getInteractionRate() * 100, 2, '.', ''),
// 'all_conversions' => $row->getMetrics()->getAllConversions(),
// 'view_through_conversions' => $row->getMetrics()->getViewThroughConversions()
];
}
return $metrics;
}
public function listCampaignsMetricsById(string $clientCustomerId, string $campaignId, string $startDate = '', string $endDate = ''): array
{
$client = $this->buildClient($this->loginCustomerId);
$service = $client->getGoogleAdsServiceClient();
$customerId = str_replace('-', '', $clientCustomerId);
if (empty($startDate)) {
$startDate = date('Y-m-d');
}
if (empty($endDate)) {
$endDate = date('Y-m-d');
}
$query = <<<QUERY
SELECT
campaign.id,
campaign.name,
campaign.start_date,
segments.date,
campaign_budget.amount_micros,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value,
metrics.interactions,
metrics.interaction_rate
FROM campaign
WHERE campaign.id = $campaignId
AND segments.date BETWEEN '$startDate' AND '$endDate'
ORDER BY segments.date ASC
QUERY;
$request = new SearchGoogleAdsRequest([
'customer_id' => $customerId,
'query' => $query,
]);
$response = $service->search($request);
$metrics = [];
foreach ($response->iterateAllElements() as $row) {
$c = $row->getCampaign();
$metrics[] = [
'id' => $c->getId(),
'name' => $c->getName(),
'start_date' => $c->getStartDate(), // Actual launch date
'date' => $row->getSegments()->getDate(),
'impressions' => $row->getMetrics()->getImpressions(),
'clicks' => $row->getMetrics()->getClicks(),
'daily_budget' => round($row->getCampaignBudget()->getAmountMicros() / 1000000, 2),
'actual_spend' => round($row->getMetrics()->getCostMicros() / 1000000, 2),
'conversions' => $row->getMetrics()->getConversions(),
'conversions_value' => $row->getMetrics()->getConversionsValue(),
'interactions' => $row->getMetrics()->getInteractions(),
'interaction_rate' => round($row->getMetrics()->getInteractionRate() * 100, 2),
];
}
return $metrics;
}
public function listAdGroupsByCampaignId(string $clientCustomerId, string $campaignId): array
{
$customerId = str_replace('-', '', $clientCustomerId);
$client = $this->buildClient($this->loginCustomerId);
$service = $client->getGoogleAdsServiceClient();
$query = <<<QUERY
SELECT
ad_group.id,
ad_group.name,
ad_group.status,
ad_group.type,
ad_group.cpc_bid_micros,
campaign.id,
campaign.name
FROM ad_group
WHERE campaign.id = $campaignId
AND ad_group.status IN ('ENABLED', 'PAUSED', 'REMOVED')
QUERY;
$request = new SearchGoogleAdsRequest([
'customer_id' => $customerId,
'query' => $query,
]);
$response = $service->search($request);
$adGroups = [];
foreach ($response->iterateAllElements() as $row) {
$ag = $row->getAdGroup();
$campaign = $row->getCampaign();
$adGroups[] = [
'ad_group_id' => $ag->getId(),
'ad_group_name' => $ag->getName(),
'status' => AdGroupStatus::name($ag->getStatus()),
'type' => AdGroupType::name($ag->getType()),
'cpc_bid_micros' => round($ag->getCpcBidMicros() / 1000000, 2),
'parent_campaign_id' => $campaign->getId(),
'parent_campaign_name' => $campaign->getName(),
];
}
return $adGroups;
}
public function listAdsByAdGroupId(string $clientCustomerId, string $adGroupId): array
{
$customerId = str_replace('-', '', $clientCustomerId);
$client = $this->buildClient($this->loginCustomerId);
$service = $client->getGoogleAdsServiceClient();
// Query the ad_group_ad resource
$query = <<<QUERY
SELECT
ad_group_ad.ad.id,
ad_group_ad.status,
ad_group_ad.ad.type,
ad_group_ad.ad.final_urls,
ad_group_ad.policy_summary.approval_status,
ad_group.id,
ad_group.name
FROM ad_group_ad
WHERE ad_group.id = $adGroupId
AND ad_group_ad.status IN ('ENABLED', 'PAUSED', 'REMOVED')
QUERY;
$request = new SearchGoogleAdsRequest([
'customer_id' => $customerId,
'query' => $query,
]);
$response = $service->search($request);
$ads = [];
foreach ($response->iterateAllElements() as $row) {
$adGroupAd = $row->getAdGroupAd();
$ad = $adGroupAd->getAd();
$ads[] = [
'ad_id' => $ad->getId(),
'type' => AdType::name($ad->getType()),
'status' => AdGroupAdStatus::name($adGroupAd->getStatus()),
'approval_status' => PolicyApprovalStatus::name($adGroupAd->getPolicySummary()->getApprovalStatus()),
'final_urls' => iterator_to_array($ad->getFinalUrls()),
'ad_group_id' => $row->getAdGroup()->getId()
];
}
return $ads;
}
public function listAssetsByCampaignId(string $clientCustomerId, string $campaignId): array
{
$customerId = str_replace('-', '', $clientCustomerId);
$client = $this->buildClient($this->loginCustomerId);
$service = $client->getGoogleAdsServiceClient();
$query = <<<QUERY
SELECT
asset.id,
asset.name,
asset.source,
asset.type,
asset.policy_summary.approval_status,
asset.policy_summary.review_status,
campaign_asset.field_type,
campaign_asset.status,
campaign.id
FROM campaign_asset
WHERE campaign.id = $campaignId
QUERY;
$request = new SearchGoogleAdsRequest([
'customer_id' => $customerId,
'query' => $query,
]);
$response = $service->search($request);
$assets = [];
foreach ($response->iterateAllElements() as $row) {
$asset = $row->getAsset();
$ca = $row->getCampaignAsset();
$policy = $asset->getPolicySummary();
// 1. Safe access for policy_summary
$policy = $asset->getPolicySummary();
// Check if policy exists before calling methods
$approvalStatus = 'NOT_APPLICABLE';
$reviewStatus = 'NOT_APPLICABLE';
if (! is_null($policy)) {
$approvalStatus = PolicyApprovalStatus::name($policy->getApprovalStatus());
$reviewStatus = PolicyReviewStatus::name($policy->getReviewStatus());
}
$assets[] = [
'id' => $asset->getId(),
'name' => $asset->getName(),
'added_by' => AssetSource::name($asset->getSource()),
'type' => AssetType::name($asset->getType()),
'use_case' => AssetFieldType::name($ca->getFieldType()),
'approval_status' => $approvalStatus,
'review_status' => $reviewStatus,
'status' => AssetLinkStatus::name($ca->getStatus()),
];
}
return $assets;
}
public function listKeywordsByAdGroupId(string $clientCustomerId, string $adGroupId): array
{
$customerId = str_replace('-', '', $clientCustomerId);
$client = $this->buildClient($this->loginCustomerId);
$service = $client->getGoogleAdsServiceClient();
$query = <<<QUERY
SELECT
ad_group_criterion.criterion_id,
ad_group_criterion.keyword.text,
ad_group_criterion.keyword.match_type,
ad_group_criterion.status,
ad_group_criterion.cpc_bid_micros,
ad_group.id
FROM ad_group_criterion
WHERE ad_group.id = $adGroupId
AND ad_group_criterion.type = 'KEYWORD'
AND ad_group_criterion.status IN ('ENABLED', 'PAUSED', 'REMOVED')
QUERY;
$request = new SearchGoogleAdsRequest([
'customer_id' => $customerId,
'query' => $query,
]);
$response = $service->search($request);
$keywords = [];
foreach ($response->iterateAllElements() as $row) {
$criterion = $row->getAdGroupCriterion();
$keywordInfo = $criterion->getKeyword();
$keywords[] = [
'keyword_id' => $criterion->getCriterionId(),
'text' => $keywordInfo->getText(),
'match_type' => KeywordMatchType::name($keywordInfo->getMatchType()),
'status' => AdGroupCriterionStatus::name($criterion->getStatus()),
'cpc_bid' => round($criterion->getCpcBidMicros() / 1000000, 2),
'ad_group_id' => $row->getAdGroup()->getId()
];
}
return $keywords;
}
public function listAdGroupMetrics(string $dateFrom, string $dateTo): array
{
$client = $this->buildClient($this->loginCustomerId);
$service = $client->getGoogleAdsServiceClient();
$query = <<<QUERY
SELECT
ad_group.resource_name,
segments.date,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.ctr,
metrics.average_cpc
FROM ad_group
WHERE segments.date BETWEEN '$dateFrom' AND '$dateTo'
QUERY;
$request = new SearchGoogleAdsRequest([
'customer_id' => $this->loginCustomerId,
'query' => $query,
]);
$response = $service->search($request);
$metrics = [];
foreach ($response->iterateAllElements() as $row) {
$ag = $row->getAdGroup();
$m = $row->getMetrics();
$segments = $row->getSegments();
$metrics[] = [
'ad_group_resource_name' => $ag->getResourceName(),
'date' => $segments->getDate(),
'impressions' => $m->getImpressions(),
'clicks' => $m->getClicks(),
'cost_micros' => $m->getCostMicros(),
'conversions' => $m->getConversions(),
'ctr' => $m->getCtr(),
'average_cpc' => $m->getAverageCpc(),
];
}
return $metrics;
}
public function getKeywordMetricsById(string $clientCustomerId, string $keywordId, string $startDate = '', string $endDate = ''): array
{
$customerId = str_replace('-', '', $clientCustomerId);
$client = $this->buildClient($this->loginCustomerId);
$service = $client->getGoogleAdsServiceClient();
if (empty($startDate)) {
$startDate = date('Y-m-d');
}
if (empty($endDate)) {
$endDate = date('Y-m-d');
}
$query = <<<QUERY
SELECT
ad_group_criterion.criterion_id,
ad_group_criterion.keyword.text,
ad_group_criterion.keyword.match_type,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
segments.date
FROM keyword_view
WHERE ad_group_criterion.criterion_id = $keywordId
AND segments.date BETWEEN '$startDate' AND '$endDate'
ORDER BY segments.date ASC
QUERY;
$request = new SearchGoogleAdsRequest([
'customer_id' => $customerId,
'query' => $query,
]);
$response = $service->search($request);
$metrics = [];
foreach ($response->iterateAllElements() as $row) {
$criterion = $row->getAdGroupCriterion();
$m = $row->getMetrics();
$metrics[] = [
'keyword_id' => $criterion->getCriterionId(),
'text' => $criterion->getKeyword()->getText(),
'match_type' => KeywordMatchType::name($criterion->getKeyword()->getMatchType()),
'date' => $row->getSegments()->getDate(),
'impressions' => $m->getImpressions(),
'clicks' => $m->getClicks(),
'actual_spend' => round($m->getCostMicros() / 1000000, 2),
'conversions' => $m->getConversions(),
];
}
return $metrics;
}
public function getAdGroupMetricsById(string $clientCustomerId, string $adGroupId, string $startDate = '', string $endDate = ''): array
{
$customerId = str_replace('-', '', $clientCustomerId);
$client = $this->buildClient($this->loginCustomerId);
$service = $client->getGoogleAdsServiceClient();
if (empty($startDate)) {
$startDate = date('Y-m-d');
}
if (empty($endDate)) {
$endDate = date('Y-m-d');
}
$query = <<<QUERY
SELECT
ad_group.id,
ad_group.name,
ad_group.status,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.conversions_value,
segments.date
FROM ad_group
WHERE ad_group.id = $adGroupId
AND segments.date BETWEEN '$startDate' AND '$endDate'
ORDER BY segments.date ASC
QUERY;
$request = new SearchGoogleAdsRequest([
'customer_id' => $customerId,
'query' => $query,
]);
$response = $service->search($request);
$results = [];
foreach ($response->iterateAllElements() as $row) {
$ag = $row->getAdGroup();
$m = $row->getMetrics();
$results[] = [
'ad_group_id' => $ag->getId(),
'ad_group_name' => $ag->getName(),
'date' => $row->getSegments()->getDate(),
'status' => AdGroupStatus::name($ag->getStatus()),
'impressions' => $m->getImpressions(),
'clicks' => $m->getClicks(),
'actual_spend' => round($m->getCostMicros() / 1000000, 2),
'conversions' => $m->getConversions(),
'conversion_value' => $m->getConversionsValue(),
'ctr' => $m->getImpressions() > 0
? round(($m->getClicks() / $m->getImpressions()) * 100, 2)
: 0
];
}
return $results;
}
public function getAdMetricsById(string $clientCustomerId, string $adId, string $startDate = '', string $endDate = ''): array
{
$customerId = str_replace('-', '', $clientCustomerId);
$client = $this->buildClient($this->loginCustomerId);
$service = $client->getGoogleAdsServiceClient();
if (empty($startDate)) {
$startDate = date('Y-m-d');
}
if (empty($endDate)) {
$endDate = date('Y-m-d');
}
$query = <<<QUERY
SELECT
ad_group_ad.ad.id,
ad_group_ad.ad.type,
ad_group_ad.status,
ad_group.id,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
segments.date
FROM ad_group_ad
WHERE ad_group_ad.ad.id = $adId
AND segments.date BETWEEN '$startDate' AND '$endDate'
ORDER BY segments.date ASC
QUERY;
$request = new SearchGoogleAdsRequest([
'customer_id' => $customerId,
'query' => $query,
]);
$response = $service->search($request);
$results = [];
foreach ($response->iterateAllElements() as $row) {
$adGroupAd = $row->getAdGroupAd();
$m = $row->getMetrics();
$results[] = [
'ad_id' => $adGroupAd->getAd()->getId(),
'ad_type' => AdType::name($adGroupAd->getAd()->getType()),
'date' => $row->getSegments()->getDate(),
'status' => AdGroupAdStatus::name($adGroupAd->getStatus()),
'impressions' => $m->getImpressions(),
'clicks' => $m->getClicks(),
'spend' => round($m->getCostMicros() / 1000000, 2),
'conversions' => $m->getConversions(),
'ad_group_id' => $row->getAdGroup()->getId()
];
}
return $results;
}
public function getAssetMetricsById(string $clientCustomerId, string $assetId, string $startDate = '', string $endDate = ''): array
{
$customerId = str_replace('-', '', $clientCustomerId);
$client = $this->buildClient($this->loginCustomerId);
$service = $client->getGoogleAdsServiceClient();
if (empty($startDate)) {
$startDate = date('Y-m-d');
}
if (empty($endDate)) {
$endDate = date('Y-m-d');
}
$query = <<<QUERY
SELECT
asset.id,
asset.name,
campaign_asset.field_type,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
segments.date,
campaign.id
FROM campaign_asset
WHERE asset.id = $assetId
AND segments.date BETWEEN '$startDate' AND '$endDate'
ORDER BY segments.date ASC
QUERY;
$request = new SearchGoogleAdsRequest([
'customer_id' => $customerId,
'query' => $query,
]);
$response = $service->search($request);
$results = [];
foreach ($response->iterateAllElements() as $row) {
$asset = $row->getAsset();
$ca = $row->getCampaignAsset();
$m = $row->getMetrics();
$results[] = [
'asset_id' => $asset->getId(),
'asset_name' => $asset->getName(),
'date' => $row->getSegments()->getDate(),
'field_type' => AssetFieldType::name($ca->getFieldType()), // e.g., SITELINK, CALLOUT
'impressions' => $m->getImpressions(),
'clicks' => $m->getClicks(),
'cost' => round($m->getCostMicros() / 1000000, 2),
'conversions' => $m->getConversions(),
'campaign_id' => $row->getCampaign()->getId()
];
}
return $results;
}
public function getAccountDetails(string $customerId): array
{
$client = $this->buildClient($this->loginCustomerId);
$service = $client->getGoogleAdsServiceClient();
// Querying 'customer' gives you specific settings for that ID
$query = "SELECT
customer.id,
customer.descriptive_name,
customer.currency_code,
customer.time_zone,
customer.tracking_url_template,
customer.auto_tagging_enabled,
customer.manager,
customer.test_account,
customer.status
FROM customer";
// When querying the 'customer' resource, the customer_id in the request
// must match the ID you are querying.
$request = new SearchGoogleAdsRequest([
'customer_id' => $customerId,
'query' => $query,
]);
$response = $service->search($request);
// Since we are querying a specific ID, we only need the first result
$row = $response->iterateAllElements()->current();
if (! $row) {
return [];
}
$customer = $row->getCustomer();
return [
'id' => $customer->getId(),
'name' => $customer->getDescriptiveName(),
'status' => CustomerStatus::name($customer->getStatus()),
'currency' => $customer->getCurrencyCode(),
'time_zone' => $customer->getTimeZone(),
'is_manager' => $customer->getManager(),
'is_test_account' => $customer->getTestAccount(),
'auto_tagging' => $customer->getAutoTaggingEnabled(),
'tracking_template' => $customer->getTrackingUrlTemplate(),
];
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace App\Services;
use App\Enums\UserStatus;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use App\Models\Task;
use Carbon\Carbon;
// use App\Helpers\GeneralHelper;
use App\Models\User;
use Spatie\Permission\Models\Role;
use Illuminate\Support\Facades\Hash;
class RoleService
{
protected $helper;
public function __construct()
{
// $this->helper = new GeneralHelper();
}
public function getAllRoles()
{
try {
$roles = Role::all();
return ['success' => true, 'message' => 'Roles fetched successfully', 'data' => $roles];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage(), 'error' => $e->getMessage()];
}
}
public function createRole($data)
{
DB::beginTransaction();
try {
$role = Role::create([
'name' => $data->name,
'guard_name' => 'web'
]);
foreach ($data->permissions as $permissions) {
foreach ($permissions as $permission) {
if ($permission['checked']) {
$role->givePermissionTo($permission['name']);
}
}
}
DB::commit();
return ['success' => true, 'message' => 'Role created successfully', 'data' => $role];
} catch (\Exception $e) {
DB::rollback();
return ['success' => false, 'message' => $e->getMessage(), 'error' => $e->getMessage()];
}
}
public function getRoleById($id)
{
try {
return ['success' => true, 'message' => 'User created successfully', 'data' => []];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage(), 'error' => $e->getMessage()];
}
}
public function updateRoleById($id, $data)
{
DB::beginTransaction();
try {
$role = Role::findOrFail($id);
$role->update([
'name' => $data->name,
]);
foreach ($data->permissions as $permissions) {
foreach ($permissions as $permission) {
if ($permission['checked']) {
$role->givePermissionTo($permission['name']);
} else {
$role->revokePermissionTo($permission['name']);
}
}
}
DB::commit();
return ['success' => true, 'message' => 'Role updated successfully', 'data' => $role];
} catch (\Exception $e) {
DB::rollback();
return ['success' => false, 'message' => $e->getMessage(), 'error' => $e->getMessage()];
}
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Services;
use App\Models\Client;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
class UserHierarchyService
{
public function visibleUserIds(User $user): array
{
if ($user->hasRole('Admin')) {
return User::query()->pluck('id')->all();
}
$visibleIds = [$user->id];
$frontier = [$user->id];
while ($frontier !== []) {
$directReportIds = User::query()
->whereIn('manager_id', $frontier)
->pluck('id')
->all();
$directReportIds = array_values(array_diff($directReportIds, $visibleIds));
if ($directReportIds === []) {
break;
}
$visibleIds = array_merge($visibleIds, $directReportIds);
$frontier = $directReportIds;
}
return $visibleIds;
}
public function scopeClientsVisibleTo(Builder $query, User $user): Builder
{
if ($user->hasRole('Admin')) {
return $query;
}
$visibleUserIds = $this->visibleUserIds($user);
return $query->whereHas('assignations', function (Builder $query) use ($visibleUserIds) {
$query->whereIn('user_id', $visibleUserIds);
});
}
public function canViewClient(User $user, Client $client): bool
{
if ($user->hasRole('Admin')) {
return true;
}
return $client->assignations()
->whereIn('user_id', $this->visibleUserIds($user))
->exists();
}
public function wouldCreateCycle(User $user, ?int $managerId): bool
{
if ($managerId === null) {
return false;
}
if ($user->id === $managerId) {
return true;
}
$frontier = [$user->id];
$checkedIds = [$user->id];
while ($frontier !== []) {
$directReportIds = User::query()
->whereIn('manager_id', $frontier)
->pluck('id')
->all();
$directReportIds = array_values(array_diff($directReportIds, $checkedIds));
if (in_array($managerId, $directReportIds, true)) {
return true;
}
$checkedIds = array_merge($checkedIds, $directReportIds);
$frontier = $directReportIds;
}
return false;
}
}

18
artisan Executable file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

28
bootstrap/app.php Normal file
View File

@ -0,0 +1,28 @@
<?php
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->web(append: [
HandleAppearance::class,
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();

2
bootstrap/cache/.gitignore vendored Normal file
View File

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

6
bootstrap/providers.php Normal file
View File

@ -0,0 +1,6 @@
<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
];

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "resources/css/app.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

105
composer.json Normal file
View File

@ -0,0 +1,105 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/react-starter-kit",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": [
"laravel",
"framework"
],
"license": "MIT",
"require": {
"php": "^8.2",
"googleads/google-ads-php": "^31.1",
"inertiajs/inertia-laravel": "^2.0",
"laravel/fortify": "^1.30",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1",
"laravel/wayfinder": "^0.1.9",
"rap2hpoutre/fast-excel": "^5.7",
"smi2/phpclickhouse": "^1.26",
"spatie/laravel-permission": "^7.2",
"tightenco/ziggy": "^2.6"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/boost": "^1.8",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^4.1",
"pestphp/pest-plugin-laravel": "^4.0"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"dev:ssr": [
"npm run build:ssr",
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force",
"@php artisan boost:update --ansi"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

10940
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

130
config/app.php Normal file
View File

@ -0,0 +1,130 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'Asia/Kuala_Lumpur',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
'billing_key' => env('BILLINGBEARERTOKEN', '1|D4sTWrBYsr8aNbNxY4H6cQCRKGoN87zDuUspTtc5fb7776aa'),
'billing_url' => env('BILLINGAPIURL', 'http://127.0.0.1:8001/api/v1'),
'email_notification' => env('EMAILNOTIFICATION', false),
];

115
config/auth.php Normal file
View File

@ -0,0 +1,115 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

117
config/cache.php Normal file
View File

@ -0,0 +1,117 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane",
| "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
];

183
config/database.php Normal file
View File

@ -0,0 +1,183 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];

80
config/filesystems.php Normal file
View File

@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

157
config/fortify.php Normal file
View File

@ -0,0 +1,157 @@
<?php
use Laravel\Fortify\Features;
return [
/*
|--------------------------------------------------------------------------
| Fortify Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Fortify will use while
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'web',
/*
|--------------------------------------------------------------------------
| Fortify Password Broker
|--------------------------------------------------------------------------
|
| Here you may specify which password broker Fortify can use when a user
| is resetting their password. This configured value should match one
| of your password brokers setup in your "auth" configuration file.
|
*/
'passwords' => 'users',
/*
|--------------------------------------------------------------------------
| Username / Email
|--------------------------------------------------------------------------
|
| This value defines which model attribute should be considered as your
| application's "username" field. Typically, this might be the email
| address of the users but you are free to change this value here.
|
| Out of the box, Fortify expects forgot password and reset password
| requests to have a field named 'email'. If the application uses
| another name for the field you may define it below as needed.
|
*/
'username' => 'email',
'email' => 'email',
/*
|--------------------------------------------------------------------------
| Lowercase Usernames
|--------------------------------------------------------------------------
|
| This value defines whether usernames should be lowercased before saving
| them in the database, as some database system string fields are case
| sensitive. You may disable this for your application if necessary.
|
*/
'lowercase_usernames' => true,
/*
|--------------------------------------------------------------------------
| Home Path
|--------------------------------------------------------------------------
|
| Here you may configure the path where users will get redirected during
| authentication or password reset when the operations are successful
| and the user is authenticated. You are free to change this value.
|
*/
'home' => '/dashboard',
/*
|--------------------------------------------------------------------------
| Fortify Routes Prefix / Subdomain
|--------------------------------------------------------------------------
|
| Here you may specify which prefix Fortify will assign to all the routes
| that it registers with the application. If necessary, you may change
| subdomain under which all of the Fortify routes will be available.
|
*/
'prefix' => '',
'domain' => null,
/*
|--------------------------------------------------------------------------
| Fortify Routes Middleware
|--------------------------------------------------------------------------
|
| Here you may specify which middleware Fortify will assign to the routes
| that it registers with the application. If necessary, you may change
| these middleware but typically this provided default is preferred.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
|
| By default, Fortify will throttle logins to five requests per minute for
| every email and IP address combination. However, if you would like to
| specify a custom rate limiter to call then you may specify it here.
|
*/
'limiters' => [
'login' => 'login',
'two-factor' => 'two-factor',
],
/*
|--------------------------------------------------------------------------
| Register View Routes
|--------------------------------------------------------------------------
|
| Here you may specify if the routes returning views should be disabled as
| you may not need them when building your own application. This may be
| especially true if you're writing a custom single-page application.
|
*/
'views' => true,
/*
|--------------------------------------------------------------------------
| Features
|--------------------------------------------------------------------------
|
| Some of the Fortify features are optional. You may disable the features
| by removing them from this array. You're free to only remove some of
| these features, or you can even remove all of these if you need to.
|
*/
'features' => [
Features::registration(),
Features::resetPasswords(),
Features::emailVerification(),
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
// 'window' => 0
]),
],
];

View File

@ -0,0 +1,6 @@
[GOOGLE_ADS]
developerToken = "PC1utvNtBsbs5rclB3EWRQ"
clientId = "281925192292-jkt1jnv09evm9fh88hbi7637qhj0fhv0.apps.googleusercontent.com"
clientSecret = "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"
refreshToken = "1//0gZs_jqqrN-rlCgYIARAAGBASNwF-L9Irk8smW7z25CdkofFvf1AiZeSfudbPjh-PkEceQ6w1yxuja7qb2aHwvASyU-FyO5iemI4"
loginCustomerId = "3623740547" ; your Manager (MCC) ID

55
config/inertia.php Normal file
View File

@ -0,0 +1,55 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Server Side Rendering
|--------------------------------------------------------------------------
|
| These options configures if and how Inertia uses Server Side Rendering
| to pre-render each initial request made to your application's pages
| so that server rendered HTML is delivered for the user's browser.
|
| See: https://inertiajs.com/server-side-rendering
|
*/
'ssr' => [
'enabled' => true,
'url' => 'http://127.0.0.1:13714',
// 'bundle' => base_path('bootstrap/ssr/ssr.mjs'),
],
/*
|--------------------------------------------------------------------------
| Testing
|--------------------------------------------------------------------------
|
| The values described here are used to locate Inertia components on the
| filesystem. For instance, when using `assertInertia`, the assertion
| attempts to locate the component as a file relative to the paths.
|
*/
'testing' => [
'ensure_pages_exist' => true,
'page_paths' => [
resource_path('js/pages'),
],
'page_extensions' => [
'js',
'jsx',
'svelte',
'ts',
'tsx',
'vue',
],
],
];

132
config/logging.php Normal file
View File

@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

118
config/mail.php Normal file
View File

@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', 'Example'),
],
];

202
config/permission.php Normal file
View File

@ -0,0 +1,202 @@
<?php
return [
'models' => [
/*
* When using the "HasPermissions" trait from this package, we need to know which
* Eloquent model should be used to retrieve your permissions. Of course, it
* is often just the "Permission" model but you may use whatever you like.
*
* The model you want to use as a Permission model needs to implement the
* `Spatie\Permission\Contracts\Permission` contract.
*/
'permission' => Spatie\Permission\Models\Permission::class,
/*
* When using the "HasRoles" trait from this package, we need to know which
* Eloquent model should be used to retrieve your roles. Of course, it
* is often just the "Role" model but you may use whatever you like.
*
* The model you want to use as a Role model needs to implement the
* `Spatie\Permission\Contracts\Role` contract.
*/
'role' => Spatie\Permission\Models\Role::class,
],
'table_names' => [
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your roles. We have chosen a basic
* default value but you may easily change it to any table you like.
*/
'roles' => 'roles',
/*
* When using the "HasPermissions" trait from this package, we need to know which
* table should be used to retrieve your permissions. We have chosen a basic
* default value but you may easily change it to any table you like.
*/
'permissions' => 'permissions',
/*
* When using the "HasPermissions" trait from this package, we need to know which
* table should be used to retrieve your models permissions. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'model_has_permissions' => 'model_has_permissions',
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your models roles. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'model_has_roles' => 'model_has_roles',
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your roles permissions. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'role_has_permissions' => 'role_has_permissions',
],
'column_names' => [
/*
* Change this if you want to name the related pivots other than defaults
*/
'role_pivot_key' => null, // default 'role_id',
'permission_pivot_key' => null, // default 'permission_id',
/*
* Change this if you want to name the related model primary key other than
* `model_id`.
*
* For example, this would be nice if your primary keys are all UUIDs. In
* that case, name this `model_uuid`.
*/
'model_morph_key' => 'model_id',
/*
* Change this if you want to use the teams feature and your related model's
* foreign key is other than `team_id`.
*/
'team_foreign_key' => 'team_id',
],
/*
* When set to true, the method for checking permissions will be registered on the gate.
* Set this to false if you want to implement custom logic for checking permissions.
*/
'register_permission_check_method' => true,
/*
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
* this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated
* NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it.
*/
'register_octane_reset_listener' => false,
/*
* Events will fire when a role or permission is assigned/unassigned:
* \Spatie\Permission\Events\RoleAttached
* \Spatie\Permission\Events\RoleDetached
* \Spatie\Permission\Events\PermissionAttached
* \Spatie\Permission\Events\PermissionDetached
*
* To enable, set to true, and then create listeners to watch these events.
*/
'events_enabled' => false,
/*
* Teams Feature.
* When set to true the package implements teams using the 'team_foreign_key'.
* If you want the migrations to register the 'team_foreign_key', you must
* set this to true before doing the migration.
* If you already did the migration then you must make a new migration to also
* add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions'
* (view the latest version of this package's migration file)
*/
'teams' => false,
/*
* The class to use to resolve the permissions team id
*/
'team_resolver' => \Spatie\Permission\DefaultTeamResolver::class,
/*
* Passport Client Credentials Grant
* When set to true the package will use Passports Client to check permissions
*/
'use_passport_client_credentials' => false,
/*
* When set to true, the required permission names are added to exception messages.
* This could be considered an information leak in some contexts, so the default
* setting is false here for optimum safety.
*/
'display_permission_in_exception' => false,
/*
* When set to true, the required role names are added to exception messages.
* This could be considered an information leak in some contexts, so the default
* setting is false here for optimum safety.
*/
'display_role_in_exception' => false,
/*
* By default wildcard permission lookups are disabled.
* See documentation to understand supported syntax.
*/
'enable_wildcard_permission' => false,
/*
* The class to use for interpreting wildcard permissions.
* If you need to modify delimiters, override the class and specify its name here.
*/
// 'wildcard_permission' => Spatie\Permission\WildcardPermission::class,
/* Cache-specific settings */
'cache' => [
/*
* By default all permissions are cached for 24 hours to speed up performance.
* When permissions or roles are updated the cache is flushed automatically.
*/
'expiration_time' => \DateInterval::createFromDateString('24 hours'),
/*
* The cache key used to store all permissions.
*/
'key' => 'spatie.permission.cache',
/*
* You may optionally indicate a specific cache driver to use for permission and
* role caching using any of the `store` drivers listed in the cache.php config
* file. Using 'default' here means to use the `default` set in cache.php.
*/
'store' => 'default',
],
];

129
config/queue.php Normal file
View File

@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

38
config/services.php Normal file
View File

@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];

217
config/session.php Normal file
View File

@ -0,0 +1,217 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain and all subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
];

1
database/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.sqlite*

View File

@ -0,0 +1,37 @@
<?php
namespace Database\Factories;
use App\Models\Campaign;
use App\Models\Client;
use App\Models\Industry;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class CampaignFactory extends Factory
{
protected $model = Campaign::class;
public function definition()
{
// Get random consultant (role = consultant) and campaign manager
$consultant = User::find(2);
$manager = User::find(2);
return [
'client_id' => Client::inRandomOrder()->first()->id,
'consultant_id' => 2,
'campaign_manager_id' => 2,
'campaign_name' => $this->faker->sentence(3),
'landing_page' => $this->faker->url,
'status' => $this->faker->randomElement(['draft', 'active', 'paused', 'ended']),
'conversion' => $this->faker->numberBetween(0, 100),
'countdown_enabled' => $this->faker->boolean,
'campaign_paused_flag' => $this->faker->boolean,
'campaign_paused_reason' => $this->faker->sentence(),
'activated_date' => $this->faker->dateTimeBetween('-1 year', 'now'),
'end_date' => $this->faker->dateTimeBetween('now', '+6 months'),
'countdown_days' => $this->faker->numberBetween(1, 30),
];
}
}

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