first commit

This commit is contained in:
Noor E Ilahi
2026-01-09 12:54:53 +05:30
commit 7ccf44f7da
1070 changed files with 113036 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use App\Models\TimeInterval;
use App\Models\User;
use Carbon\Carbon;
use DB;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class CalculateEfficiency extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cattr:calculate-efficiency';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Calculate efficiency for users';
/**
* Execute the console command.
*/
public function handle()
{
$startDate = Carbon::now()->subMonth(2);
$users = User::all();
foreach ($users as $user) {
$tasks = $user->tasks()
->select(['id', 'estimate'])
->whereHas('status', static function (Builder $query) {
$query->where('active', false);
})
->whereHas('timeIntervals', static function (Builder $query) use ($startDate) {
$query
->whereNotNull('estimate')
->where('start_at', '>', $startDate);
})
->get()
->keyBy('id')
->toArray();
if (empty($tasks)) {
$user->efficiency = null;
$user->save();
continue;
}
$taskIds = array_keys($tasks);
$timeIntervals = TimeInterval::query()
->select(['user_id', 'task_id', DB::raw('SUM(TIMESTAMPDIFF(SECOND, start_at, end_at)) as duration')])
->where('start_at', '>', $startDate)
->where('user_id', $user->id)
->whereIn('task_id', $taskIds)
->groupBy(['user_id', 'task_id'])
->get();
$totalEfficiency = 0;
foreach ($timeIntervals as $timeInterval) {
$tasks[$timeInterval->task_id]['duration'] = (int)$timeInterval->duration;
$totalEfficiency += $tasks[$timeInterval->task_id]['duration'] / (float)$tasks[$timeInterval->task_id]['estimate'];
}
$user->efficiency = $totalEfficiency / count($tasks);
$user->save();
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Console\Commands;
use App\Jobs\ClearExpiredApps;
use App\Models\TrackedApplication;
use Illuminate\Console\Command;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'cattr:intervals:apps:clear-expired')]
class ClearExpiredTrackedApps extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cattr:intervals:apps:clear-expired';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command description';
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
$this->info(
sprintf(
'Found %d items for deletion',
TrackedApplication::where(
'created_at',
'<=',
now()->subDay()->toIso8601String()
)->withoutGlobalScopes()->count()
)
);
ClearExpiredApps::dispatch();
$this->info('Clearance job dispatched');
return 0;
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Console\Commands;
use App\Contracts\AttachmentService;
use App\Models\Attachment;
use App\Models\SusFiles;
use Arr;
use Illuminate\Console\Command;
use Storage;
use Str;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'cattr:attachments:find-sus-files')]
class FindSusFiles extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cattr:attachments:find-sus-files';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Find files that exist inside attachments projects folder but not in DB';
/**
* Execute the console command.
*/
public function handle(AttachmentService $service): void
{
SusFiles::query()->delete();
$this->withProgressBar(
Storage::disk('attachments')->allFiles('projects'),
function ($filePath) use ($service) {
$fileName = Str::of($filePath)->basename()->toString();
if (Attachment::whereId($fileName)->exists() === false) {
SusFiles::create([
'path' => $filePath,
'mime_type' => $service->getMimeType($filePath),
'hash' => $service->getHashSum($filePath),
]);
}
}
);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Mail;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'cattr:mail:test')]
class MailTestCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cattr:mail:test {destination}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Test mail sending';
public function handle(): void
{
$destination = $this->argument('destination');
Mail::raw('Text to e-mail', static function ($message) use ($destination) {
$message->to($destination);
});
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Validator;
use Illuminate\Validation\Rule;
use Illuminate\Contracts\Console\Isolatable;
#[AsCommand(name: 'cattr:make:admin')]
class MakeAdmin extends Command implements Isolatable
{
protected $signature = 'cattr:make:admin
{--o : Skip instance registration}
{--email= : User email}
{--name= : User name}
{--password= : User password}';
protected $description = 'Creates admin user with data from ENV or input';
public function handle(): int
{
if ($this->option('email')) {
$email = $this->option('email');
if (Validator::make([
'email' => $email
], [
'email' => ['email', Rule::unique(User::class)]
])->fails()) {
$this->error('Email is incorrect or it was already registered');
return 1;
}
}
$email = env('APP_ADMIN_EMAIL', 'admin@cattr.app');
if (Validator::make([
'email' => $email
], [
'email' => ['email', Rule::unique(User::class)]
])->fails()) {
if (env('IMAGE_VERSION', false)) {
$this->info('Admin already exists, skipping creation');
return 0;
}
$this->error('Email is incorrect or it was already registered');
return 1;
}
if ($email !== 'admin@cattr.app' && !$this->option('o') && !User::admin()->count()) {
$self = $this;
rescue(
static fn () => $self->call(
RegisterInstance::class,
[
'adminEmail' => $email
],
),
);
}
if ($this->option('password')) {
$password = $this->option('password');
if (Validator::make([
'password' => $password
], [
'password' => 'min:6'
])->fails()) {
$this->warn('Minimum length is 6 characters');
return 1;
}
}
$password = env('APP_ADMIN_PASSWORD', 'password');
if (Validator::make([
'password' => $password
], [
'password' => 'min:6'
])->fails()) {
$this->warn('Minimum length is 6 characters');
return 1;
}
User::factory()->admin()->create([
'full_name' => $this->option('name') ?: env('APP_ADMIN_NAME', 'Admin'),
'email' => $email,
'password' => $password,
'last_activity' => now(),
]);
$this->info("Administrator with email $email was created successfully");
return 0;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Console\Commands;
use App\Helpers\Version;
use Illuminate\Console\Command;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'cattr:module:version')]
class ModuleVersion extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cattr:module:version {module}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Get version of provided module';
/**
* Execute the console command.
*/
public function handle(): void
{
$this->info((string)new Version($this->argument('module')));
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Console\Commands;
use App\Models\Task;
use App\Models\CronTaskWorkers;
use App\Models\TimeInterval;
use Carbon\Carbon;
use DB;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Settings;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'cattr:task:recreate-workers')]
class RecreateCronTaskWorkers extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cattr:task:recreate-workers';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Recreates materialized table for CronTaskWorkers model';
/**
* Execute the console command.
*
* @return void
*/
public function handle(): void
{
$timezone = Settings::scope('core')->get('timezone');
$reportCreatedAt = Carbon::now($timezone)->format('Y-m-d H:i:s e');
$this->withProgressBar(
Task::whereNull('deleted_at')->lazyById(),
static fn(Task $task) => rescue(static function () use ($task) {
CronTaskWorkers::whereTaskId($task->id)->delete();
CronTaskWorkers::insertUsing(
['user_id', 'task_id', 'duration', 'created_by_cron'],
TimeInterval::selectRaw('user_id, task_id, SUM(TIMESTAMPDIFF(SECOND, start_at, end_at)) as duration, "1" as created_by_cron')
->where('task_id', '=', $task->id)
->groupBy(['user_id', 'task_id'])
);
})
);
Settings::scope('core.reports')->set('planned_time_report_date', $reportCreatedAt);
CronTaskWorkers::whereDoesntHave('task')
->orWhereDoesntHave('user')
->orWhereHas('task',
static fn(EloquentBuilder $query) => $query
->whereNotNull('deleted_at')
)->delete();
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Console\Commands;
use App\Helpers\ModuleHelper;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Console\Command;
use JsonException;
use Exception;
use Settings;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'cattr:register')]
class RegisterInstance extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cattr:register {adminEmail} {--i : Interactive mode}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Send instance data on the statistics server';
/**
* Execute the console command.
*
* @param Client $client
*
* @return int
* @throws JsonException|GuzzleException
*/
public function handle(Client $client): int
{
if (Settings::scope('core')->get('instance')) {
echo 'Application already registered';
return 1;
}
try {
$appVersion = config('app.version');
$response = $client->post(config('app.stats_collector_url') . '/instance', [
'json' => [
'ownerEmail' => $this->argument('adminEmail'),
'version' => $appVersion,
'modules' => ModuleHelper::getModulesInfo(),
'image' => getenv('IMAGE_VERSION')
]
]);
$responseBody = json_decode(
$response->getBody()->getContents(),
true,
512,
JSON_THROW_ON_ERROR | JSON_THROW_ON_ERROR
);
if (isset($responseBody['instanceId'])) {
Settings::scope('core')->set('instance', $responseBody['instanceId']);
}
if (isset($responseBody['release']['flashMessage'])) {
$this->info($responseBody['release']['flashMessage']);
}
if (isset($responseBody['release']['lastVersion'])
&& $responseBody['release']['lastVersion'] > $appVersion
) {
$this->alert("New version is available: {$responseBody['release']['lastVersion']}");
}
if ($responseBody['release']['vulnerable']) {
if ($this->option('i')) {
// Interactive mode
return $this->confirm('You have a vulnerable version, are you sure you want to continue?');
}
$this->alert('You have a vulnerable version. Please update to the latest version.');
}
return 0;
} catch (Exception $e) {
if ($e->getResponse()) {
$error = json_decode(
$e->getResponse()->getBody(),
true,
512,
JSON_THROW_ON_ERROR | JSON_THROW_ON_ERROR
);
$this->warn($error['message']);
} else {
$this->warn('Сould not get a response from the server to check the relevance of your version.');
}
return 0;
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Console\Commands;
use App\Models\TimeInterval;
use DB;
use Illuminate\Console\Command;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'cattr:intervals:dedupe')]
class RemoveDuplicateIntervals extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cattr:intervals:dedupe';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Removes duplicates in the Time intervals table';
/**
* Execute the console command.
*/
public function handle(): void
{
$interval_ids = DB::table('time_intervals as ti1')
->join('time_intervals as ti2', static function ($join) {
$join->on('ti2.start_at', '=', 'ti1.start_at');
$join->on('ti2.end_at', '=', 'ti1.end_at');
$join->on('ti2.user_id', '=', 'ti1.user_id');
$join->on('ti2.id', '>', 'ti1.id');
})
->whereNull('ti2.deleted_at')
->pluck('ti2.id')
->unique();
TimeInterval::destroy($interval_ids->toArray());
$count = $interval_ids->count();
$this->info("Removed $count intervals.");
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use Exception;
use Illuminate\Console\Command;
use DB;
use Illuminate\Database\Console\Seeds\SeedCommand;
use Nwidart\Modules\Commands\SeedCommand as ModuleSeedCommand;
use Settings;
use Storage;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'cattr:reset')]
class ResetCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cattr:reset {--s|seed} {--f|force} {--i|images}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Cattr flush database';
protected array $protectedTables = ['migrations', 'jobs', 'failed_jobs'];
/**
* Execute the console command.
*
* @throws Exception
*/
public function handle(): int
{
if (!$this->option('force') && !$this->confirm('Are you sure want to drop data for your Cattr instance?')) {
return 0;
}
DB::statement('SET FOREIGN_KEY_CHECKS=0;');
$tables = DB::connection()->getDoctrineSchemaManager()->listTableNames();
foreach ($tables as $table) {
if (!in_array($table, $this->protectedTables, true)) {
DB::table($table)->truncate();
}
}
DB::statement('SET FOREIGN_KEY_CHECKS=1;');
if ($this->option('images')) {
Storage::deleteDirectory('uploads/screenshots');
}
$this->call(SeedCommand::class, [
'--class' => 'InitialSeeder',
'--force' => true
]);
if ($this->option('seed')) {
$this->call(SeedCommand::class, [
'--force' => true
]);
$this->call(ModuleSeedCommand::class, [
'--force' => true
]);
}
return 0;
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Helpers\StorageCleaner;
use Illuminate\Contracts\Container\BindingResolutionException;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'cattr:screenshots:rotate')]
class RotateScreenshots extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cattr:screenshots:rotate';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Rotate screenshots';
/**
* Execute the console command.
*
* @throws BindingResolutionException
*/
public function handle(): void
{
if (!StorageCleaner::needThinning()) {
$this->info('Time for thinning hasn\'t come');
return;
}
$this->info('For thinning available ' . StorageCleaner::countAvailableScreenshots() . ' screenshots');
$spaceBefore = StorageCleaner::getUsedSpace();
$this->info('Started thinning...');
StorageCleaner::thin();
$this->info('Totally freed ' . round(
($spaceBefore - StorageCleaner::getUsedSpace()) / 1024 / 1024,
3
) . 'MB');
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Console\Commands;
use App\Models\Property;
use Illuminate\Console\Command;
use Settings;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'cattr:set:language')]
class SetLanguage extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cattr:set:language {language}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sets company language';
public function handle(): void
{
$language = $this->argument('language');
if (!in_array($language, config('app.languages'), true)) {
$this->error('Invalid or not supported language');
return;
}
Settings::scope('core')->set('language', $language);
$this->info(strtoupper($language) . ' language successfully set');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use App\Models\Property;
use Illuminate\Console\Command;
use Settings;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'cattr:set:timezone')]
class SetTimeZone extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cattr:set:timezone {timezone}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sets company timezone';
public function handle(): void
{
$timezone = $this->argument('timezone');
if (!in_array($timezone, timezone_identifiers_list(), true)) {
$this->error('Invalid time zone format');
return;
}
Settings::scope('core')->set('timezone', $timezone);
$this->info("$timezone time zone successfully set");
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Console\Commands;
use App\Contracts\AttachmentService;
use App\Models\Attachment;
use App\Models\SusFiles;
use Arr;
use Illuminate\Console\Command;
use Storage;
use Str;
use Symfony\Component\Console\Attribute\AsCommand;
use App\Jobs\VerifyAttachmentHash;
#[AsCommand(name: 'cattr:attachments:verify')]
class VerifyAttachments extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cattr:attachments:verify';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Verify hash of all files';
/**
* Execute the console command.
*/
public function handle(AttachmentService $service): void
{
$this->withProgressBar(Attachment::lazyById(), fn($attachment) => VerifyAttachmentHash::dispatch($attachment));
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Console\Commands;
use App\Helpers\Version;
use Illuminate\Console\Command;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'cattr:version')]
class VersionCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'cattr:version';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Get Cattr version';
/**
* Execute the console command.
*/
public function handle(): void
{
$this->info((string)new Version());
}
}

63
app/Console/Kernel.php Normal file
View File

@@ -0,0 +1,63 @@
<?php
namespace App\Console;
use App\Console\Commands\CalculateEfficiency;
use App\Console\Commands\RecreateCronTaskWorkers;
use App\Console\Commands\FindSusFiles;
use App\Console\Commands\RotateScreenshots;
use App\Jobs\ClearExpiredApps;
use App\Console\Commands\VerifyAttachments;
use App\Models\Attachment;
use Exception;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use Sentry\State\Scope;
use Settings;
use function Sentry\configureScope;
class Kernel extends ConsoleKernel
{
/**
* The Artisan commands provided by your application.
*
* @var array
*/
protected $commands = [
//
];
public final function bootstrap(): void
{
parent::bootstrap();
}
/**
* Define the application's command schedule.
*
* @param Schedule $schedule
* @return void
* @throws Exception
*/
protected function schedule(Schedule $schedule): void
{
$schedule->command(RotateScreenshots::class)->weekly()->when(Settings::scope('core')->get('auto_thinning'));
$schedule->job(new ClearExpiredApps)->daily();
$schedule->command(RecreateCronTaskWorkers::class)->daily()->runInBackground()->withoutOverlapping();
$schedule->command(VerifyAttachments::class)->daily()->runInBackground()->withoutOverlapping();
$schedule->command(CalculateEfficiency::class)->daily()->runInBackground()->withoutOverlapping();
$schedule->command(FindSusFiles::class)->weekly()->runInBackground()->withoutOverlapping();
}
/**
* Register the Closure based commands for the application.
*/
protected function commands(): void
{
$this->load(__DIR__ . '/Commands');
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Contracts;
abstract class AppReport
{
final public static function init(...$arguments): self
{
return new static(...$arguments);
}
abstract public function getReportId(): string;
abstract public function getLocalizedReportName(): string;
abstract public function store(string $filePath = null, string $disk = null, string $writerType = null, $diskOptions = []);
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Contracts;
use App\Models\Attachment;
use Illuminate\Database\Eloquent\Relations\MorphMany;
interface AttachmentAble
{
/**
* @return Attachment[]
*/
public function attachments();
public function attachmentsRelation(): MorphMany;
public function getProjectId(): int;
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Contracts;
use App\Models\Attachment;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Http\UploadedFile;
interface AttachmentService
{
public function storeFile(UploadedFile $file, Attachment $attachment): string|false;
public function handleProjectChange(Task $parent): void;
public function moveAttachment(Attachment $attachment, $newProjectId): void;
public function deleteAttachment(Attachment $attachment): void;
public function deleteAttachments(array $attachmentsIds): void;
public function handleParentDeletion(AttachmentAble $parent): void;
public function handleProjectDeletion(Project $project): void;
public function fileExists(Attachment $attachment): bool;
public function attach(AttachmentAble $parent, array $idsToAttach);
public function getProjectPath(Project $project): string;
public function getPath(Attachment $attachment): string;
public function getFullPath(Attachment $attachment): string;
public function getHashAlgo(): string;
public function getHashSum(Attachment|string $attachment): false|string;
public function getMimeType(Attachment|string $attachment): false|string;
public function verifyHash(Attachment $attachment);
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Contracts;
use App\Jobs\GenerateScreenshotThumbnail;
use App\Models\TimeInterval;
use Image;
use Intervention\Image\Constraint;
use Storage;
abstract class ScreenshotService
{
protected const FILE_FORMAT = 'jpg';
public const PARENT_FOLDER = 'screenshots/';
public const THUMBS_FOLDER = 'thumbs/';
private const THUMB_WIDTH = 280;
private const QUALITY = 50;
/** Get screenshot path by interval */
abstract public function getScreenshotPath(TimeInterval|int $interval): string;
/** Get screenshot thumbnail path by interval */
abstract public function getThumbPath(TimeInterval|int $interval): string;
public function saveScreenshot($file, $timeInterval): void
{
if (!Storage::exists(self::PARENT_FOLDER)) {
Storage::makeDirectory(self::PARENT_FOLDER);
}
$path = is_string($file) ? $file : $file->path();
$image = Image::make($path);
Storage::put($this->getScreenshotPath($timeInterval), (string)$image->encode(self::FILE_FORMAT, self::QUALITY));
GenerateScreenshotThumbnail::dispatch($timeInterval);
}
public function createThumbnail(TimeInterval|int $timeInterval): void
{
if (!Storage::exists(self::PARENT_FOLDER . self::THUMBS_FOLDER)) {
Storage::makeDirectory(self::PARENT_FOLDER . self::THUMBS_FOLDER);
}
$image = Image::make(Storage::path($this->getScreenshotPath($timeInterval)));
$thumb = $image->resize(self::THUMB_WIDTH, null, fn(Constraint $constraint) => $constraint->aspectRatio());
Storage::put($this->getThumbPath($timeInterval), (string)$thumb->encode(self::FILE_FORMAT, self::QUALITY));
}
public function destroyScreenshot(TimeInterval|int $interval): void
{
Storage::delete($this->getScreenshotPath($interval));
Storage::delete($this->getThumbPath($interval));
}
public static function getFullPath(): string
{
$fileSystemPath = config('filesystems.default');
return storage_path(config("filesystems.disks.$fileSystemPath.root"));
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Contracts;
interface SettingsProvider
{
/**
* Get all module settings
*
* @return array
*/
public function all(): array;
/**
* Get the settings value by key
*
* @param string $key
* @param mixed|null $default
*
* @return mixed
*/
public function get(string $key, mixed $default = null): mixed;
/**
* Set the setting value
* A key value array can be passed as key
*
* @param string $key
* @param mixed|null $value
*
* @return void
*/
public function set(string $key, mixed $value = null): void;
/**
* Flushes module settings storage
*
* @return void
*/
public function flush(): void;
}

206
app/Docs/auth.php Normal file
View File

@@ -0,0 +1,206 @@
<?php
/**
* @apiDeprecated since 1.0.0 use now (#Password_Reset:Process)
* @api {post} /api/auth/reset Reset
* @apiDescription Get user JWT
*
*
* @apiVersion 1.0.0
* @apiName Reset
* @apiGroup Auth
*/
/**
* @apiDeprecated since 1.0.0 use now (#Password_Reset:Request)
* @api {post} /api/auth/send-reset Send reset e-mail
* @apiDescription Get user JWT
*
*
* @apiVersion 1.0.0
* @apiName Send reset
* @apiGroup Auth
*/
/**
* @apiDeprecated since 4.0.0
* @api {post} /auth/refresh Refresh
* @apiDescription Refreshes JWT
*
* @apiVersion 1.0.0
* @apiName Refresh
* @apiGroup Auth
*
* @apiUse AuthHeader
*
* @apiSuccess {String} access_token Token
* @apiSuccess {String} token_type Token Type
* @apiSuccess {String} expires_in Token TTL 8601String Date
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
/**
* @apiDeprecated since 4.0.0
* @apiDefine ParamsValidationError
* @apiErrorExample {json} Params validation
* HTTP/1.1 400 Bad Request
* {
* "message": "Invalid params",
* "error_type": "authorization.wrong_params"
* }
*
* @apiVersion 1.0.0
*/
/**
* @api {post} /auth/logout Logout
* @apiDescription Invalidate JWT
*
* @apiVersion 1.0.0
* @apiName Logout
* @apiGroup Auth
*
* @apiUse AuthHeader
*
* @apiSuccess {String} message Message from server
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "message": "Successfully logged out"
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
/**
* @api {post} /auth/logout-from-all Logout from all
* @apiDescription Invalidate all user JWT
*
* @apiVersion 1.0.0
* @apiName Logout all
* @apiGroup Auth
*
* @apiUse AuthHeader
*
* @apiSuccess {String} message Message from server
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "message": "Successfully logged out from all sessions"
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
/**
* @api {get} /auth/me Me
* @apiDescription Get authenticated User Entity
*
* @apiVersion 1.0.0
* @apiName Me
* @apiGroup Auth
*
* @apiUse AuthHeader
*
* @apiSuccess {Object} user User Entity
*
* @apiUse UserObject
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "user": {
* "id": 1,
* "full_name": "Admin",
* "email": "admin@example.com",
* "url": "",
* "company_id": 1,
* "avatar": "",
* "screenshots_state": 1,
* "manual_time": 0,
* "computer_time_popup": 300,
* "blur_screenshots": 0,
* "web_and_app_monitoring": 1,
* "screenshots_interval": 9,
* "active": "active",
* "deleted_at": null,
* "created_at": "2018-09-25 06:15:08",
* "updated_at": "2018-09-25 06:15:08",
* "timezone": null
* }
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
/**
* @api {get} /auth/desktop-key Issue key
* @apiDescription Issues key for desktop auth
*
* @apiVersion 1.0.0
* @apiName Issue key
* @apiGroup Auth
*
* @apiUse AuthHeader
*
* @apiSuccess {String} access_token Token
* @apiSuccess {String} token_type Token Type
* @apiSuccess {String} expires_in Token TTL 8601String Date
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "access_token": "r6nPiGocAWdD5ZF60dTkUboVAWSXsUScpp7m9x9R",
* "token_type": "desktop",
* "expires_in": "2020-12-26T14:18:32+00:00"
* }
*
* @apiUse UnauthorizedError
*/
/**
* @api {post} /auth/login Login
* @apiDescription Get user JWT
*
* @apiVersion 1.0.0
* @apiName Login
* @apiGroup Auth
*
* @apiParam {String} email User email
* @apiParam {String} password User password
* @apiParam {String} [recaptcha] Recaptcha token
*
* @apiParamExample {json} Request Example
* {
* "email": "johndoe@example.com",
* "password": "amazingpassword",
* "recaptcha": "03AOLTBLR5UtIoenazYWjaZ4AFZiv1OWegWV..."
* }
*
* @apiSuccess {String} access_token Token
* @apiSuccess {String} token_type Token Type
* @apiSuccess {ISO8601} expires_in Token TTL
* @apiSuccess {Object} user User Entity
*
* @apiUse UserObject
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "access_token": "16184cf3b2510464a53c0e573c75740540fe...",
* "token_type": "bearer",
* "expires_in": "2020-12-26T14:18:32+00:00",
* "user": {}
* }
*
* @apiUse 400Error
* @apiUse ParamsValidationError
* @apiUse UnauthorizedError
* @apiUse UserDeactivatedError
* @apiUse CaptchaError
* @apiUse LimiterError
*/

272
app/Docs/screenshots.php Normal file
View File

@@ -0,0 +1,272 @@
<?php
/**
* @apiDeprecated since 4.0.0
* @api {get} /screenshot/:id Screenshot
* @apiDescription Get Screenshot
*
* @apiVersion 3.5.0
* @apiName Show
* @apiGroup Screenshot
*
* @apiUse AuthHeader
*
* @apiPermission time_intervals_show
* @apiPermission time_intervals_full_access
*
* @apiParam {Integer} id ID of the target Time interval
*
* @apiSuccess {Raw} data Screenshot data
*
* @apiUse 400Error
* @apiUse ItemNotFoundError
* @apiUse ForbiddenError
* @apiUse UnauthorizedError
*/
/**
* @apiDeprecated since 4.0.0
* @api {get} /screenshot/:id Thumb
* @apiDescription Get Screenshot Thumbnail
*
* @apiVersion 3.5.0
* @apiName ShowThumb
* @apiGroup Screenshot
*
* @apiUse AuthHeader
*
* @apiPermission time_intervals_show
* @apiPermission time_intervals_full_access
*
* @apiParam {Integer} id ID of the target Time interval
*
* @apiSuccess {Raw} data Screenshot data
*
* @apiUse 400Error
* @apiUse ItemNotFoundError
* @apiUse ForbiddenError
* @apiUse UnauthorizedError
*/
/**
* @apiDeprecated since 4.0.0
* @api {get,post} /screenshots/list List
* @apiDescription Get list of Screenshots
*
* @apiVersion 1.0.0
* @apiName List
* @apiGroup Screenshot
*
* @apiUse AuthHeader
*
* @apiPermission screenshots_list
* @apiPermission screenshots_full_access
*
* @apiUse UserParams
*
* @apiParamExample {json} Request Example
* {
* "id": [">", 1],
* "time_interval_id": ["=", [1,2,3]],
* "user_id": ["=", [1,2,3]],
* "project_id": ["=", [1,2,3]],
* "path": ["like", "%lorem%"],
* "created_at": [">", "2019-01-01 00:00:00"],
* "updated_at": ["<", "2019-01-01 00:00:00"]
* }
*
* @apiUse UserObject
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* [
* {
* "id": 1,
* "time_interval_id": 1,
* "path": "uploads\/screenshots\/1_1_1.png",
* "created_at": "2020-01-23T09:42:26+00:00",
* "updated_at": "2020-01-23T09:42:26+00:00",
* "deleted_at": null,
* "thumbnail_path": null,
* "important": false,
* "is_removed": false
* },
* {
* "id": 2,
* "time_interval_id": 2,
* "path": "uploads\/screenshots\/1_1_2.png",
* "created_at": "2020-01-23T09:42:26+00:00",
* "updated_at": "2020-01-23T09:42:26+00:00",
* "deleted_at": null,
* "thumbnail_path": null,
* "important": false,
* "is_removed": false
* }
* ]
*
* @apiUse 400Error
* @apiUse UnauthorizedError
* @apiUse ForbiddenError
*/
/**
* @apiDeprecated since 4.0.0
* @api {post} /screenshots/create Create
* @apiDescription Create Screenshot
*
* @apiVersion 1.0.0
* @apiName Create
* @apiGroup Screenshot
*
* @apiParam {Integer} time_interval_id Time Interval ID
* @apiParam {Binary} screenshot File
*
* @apiParamExample {json} Simple-Request Example
* {
* "time_interval_id": 1,
* "screenshot": <binary data>
* }
*
* @apiSuccess {Object} res User
*
* @apiUse ScreenshotObject
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "res": {
* "id": 1,
* "time_interval_id": 1,
* "path": "uploads\/screenshots\/1_1_1.png",
* "created_at": "2020-01-23T09:42:26+00:00",
* "updated_at": "2020-01-23T09:42:26+00:00",
* "deleted_at": null,
* "thumbnail_path": null,
* "important": false,
* "is_removed": false
* }
* }
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse UnauthorizedError
* @apiUse ForbiddenError
*/
/**
* @apiDeprecated since 4.0.0
* @api {post} /screenshots/remove Destroy
* @apiDescription Destroy Screenshot
*
* @apiVersion 1.0.0
* @apiName Destroy
* @apiGroup Screenshot
*
* @apiUse AuthHeader
*
* @apiPermission screenshots_remove
* @apiPermission screenshots_full_access
*
* @apiParam {Integer} id ID of the target screenshot
*
* @apiParamExample {json} Request Example
* {
* "id": 1
* }
*
* @apiSuccess {String} message Destroy status
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "message": "Item has been removed"
* }
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse ForbiddenError
* @apiUse UnauthorizedError
*/
/**
* @apiDeprecated since 1.0.0
* @api {post} /screenshots/bulk-create Bulk Create
* @apiDescription Create Screenshot
*
* @apiVersion 1.0.0
* @apiName Bulk Create
* @apiGroup Screenshot
*
* @apiPermission screenshots_bulk_create
* @apiPermission screenshots_full_access
*/
/**
* @apiDeprecated since 4.0.0
* @api {get,post} /screenshot/count Count
* @apiDescription Count Screenshots
*
* @apiVersion 1.0.0
* @apiName Count
* @apiGroup Screenshot
*
* @apiUse AuthHeader
*
* @apiSuccess {String} total Amount of projects that we have
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "total": 2
* }
*
* @apiUse 400Error
* @apiUse ForbiddenError
* @apiUse UnauthorizedError
*/
/**
* @apiDeprecated since 4.0.0
* @api {post} /screenshots/show Show
* @apiDescription Show Screenshot
*
* @apiVersion 1.0.0
* @apiName Show
* @apiGroup Screenshot
*
* @apiUse AuthHeader
*
* @apiPermission screenshots_show
* @apiPermission screenshots_full_access
*
* @apiParam {Integer} id ID
*
* @apiUse ScreenshotParams
*
* @apiParamExample {json} Request Example
* {
* "id": 1,
* "time_interval_id": ["=", [1,2,3]],
* "path": ["like", "%lorem%"],
* "created_at": [">", "2019-01-01 00:00:00"],
* "updated_at": ["<", "2019-01-01 00:00:00"]
* }
*
* @apiUse ScreenshotObject
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "id": 1,
* "time_interval_id": 1,
* "path": "uploads\/screenshots\/1_1_1.png",
* "created_at": "2020-01-23T09:42:26+00:00",
* "updated_at": "2020-01-23T09:42:26+00:00",
* "deleted_at": null,
* "thumbnail_path": null,
* "important": false,
* "is_removed": false
* }
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse UnauthorizedError
* @apiUse ItemNotFoundError
*/

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum ActivityType: string
{
case ALL = 'all';
case COMMENTS = 'comments';
case HISTORY = 'history';
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Enums;
enum AttachmentStatus: int
{
/**
* file just uploaded, attachmentable not set yet, not calculating hash
*/
case NOT_ATTACHED = 0;
/**
* moving file to correct project folder and then calculating hash
*/
case PROCESSING = 1;
/**
* file hash matched (on cron check and initial upload)
*/
case GOOD = 2;
/**
* file hash NOT matched (on cron check)
*/
case BAD = 3;
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum DashboardSortBy: string
{
case USER_NAME = 'user';
case WORKED = 'worked';
}

13
app/Enums/Role.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
namespace App\Enums;
enum Role: int
{
case ANY = -1;
case ADMIN = 0;
case MANAGER = 1;
case USER = 2;
case AUDITOR = 3;
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Enums;
use Settings;
enum ScreenshotsState: int
{
case ANY = -1;
case FORBIDDEN = 0;
case REQUIRED = 1;
case OPTIONAL = 2;
public function title(): string
{
return strtolower($this->name);
}
public function mustBeInherited(): bool
{
return $this === self::FORBIDDEN || $this === self::REQUIRED;
}
public static function tryFromString(string $value): ?ScreenshotsState
{
try {
return constant(__CLASS__ . "::" . strtoupper($value));
} catch (\Throwable $e) {
return null;
}
}
public function toArray(): array
{
return [
'value' => $this->value,
'name' => $this->title(),
];
}
public static function states(): array
{
return array_map(fn ($case) => $case->toArray(), self::cases());
}
public static function createFrom(null|int|string|ScreenshotsState $value): ?ScreenshotsState
{
return match (true) {
!isset($value) => null,
is_numeric($value) => static::tryFrom((int)$value),
is_string($value) => static::tryFromString($value),
$value instanceof ScreenshotsState => static::tryFrom($value->value),
default => static::tryFrom($value),
};
}
public static function withGlobalOverrides(null|int|string|ScreenshotsState $value): ?ScreenshotsState
{
foreach ([
ScreenshotsState::createFrom(config('app.screenshots_state')),
ScreenshotsState::createFrom(Settings::scope('core')->get('screenshots_state')),
] as $globalOverride) {
if (isset($globalOverride) && $globalOverride->mustBeInherited()) {
return $globalOverride;
}
}
return static::createFrom($value);
}
public static function getNormalizedValue(null|int|string|ScreenshotsState $value): ?int
{
return static::createFrom($value)?->value;
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum SortDirection: string
{
case ASC = 'asc';
case DESC = 'desc';
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum TaskRelationType: string
{
case FOLLOWS = 'follows';
case PRECEDES = 'precedes';
}

View File

@@ -0,0 +1,184 @@
<?php
namespace App\Enums;
use App\Models\Project;
use App\Models\Task;
use App\Models\User;
enum UniversalReportBase: string
{
case PROJECT = 'project';
case USER = 'user';
case TASK = 'task';
public function fields() {
return match($this) {
self::PROJECT => [
'base' => [
'name',
'created_at',
'description',
'important',
],
'tasks' => [
'task_name',
'priority',
'status',
'due_date',
'estimate',
'description',
],
'users' => [
'full_name',
'email',
],
// 'work_time',
// 'work_time_user',
'calculations' => [
'total_spent_time',
'total_spent_time_by_user',
'total_spent_time_by_day',
'total_spent_time_by_day_and_user',
],
],
self::USER => [
'base' => [
'full_name',
'email',
],
'projects' => [
'name',
'created_at',
'description',
'important',
],
'tasks' => [
'task_name',
'priority',
'status',
'due_date',
'estimate',
'description',
],
'calculations' => [
'total_spent_time',
'total_spent_time_by_day',
]
],
self::TASK => [
'base' => [
'task_name',
'priority',
'status',
'due_date',
'estimate',
'description',
// 'workers',
],
'users' => [
'full_name',
'email',
],
'projects' => [
'name',
'created_at',
'description',
'important',
],
'calculations' => [
'total_spent_time',
'total_spent_time_by_day',
'total_spent_time_by_user',
'total_spent_time_by_day_and_user'
],
// 'total_spent_time',
// 'user_name',
// 'user_time',
],
};
}
public function dataObjects()
{
return match($this) {
self::PROJECT => (function() {
if(request()->user()->isAdmin()) {
return Project::select('id', 'name')->get();
}
return request()->user()->projects()->select('id', 'name')->get();
})(),
self::USER => (function() {
if (request()->user()->isAdmin()) {
return User::select('id', 'full_name as name', 'email', 'full_name')->get();
}
return;
})(),
self::TASK => (function() {
if(request()->user()->isAdmin()) {
return Task::select('id', 'task_name as name')->get();
}
return request()->user()->tasks()->select('id', 'name')->get();
})()
};
}
public function charts() {
// [
// // Project
// "worked_all_users",// "Отработанное время всеми пользователями на проекте за указанный период",
// "worked_all_users_separately",// "Отработанное время каждым пользователем на проекте за указанный период",
// // Task
// 'worked_all_users',// "Отработанное время всеми пользователями на задаче за указанный период",
// 'worked_all_users_separately',// "Отработанное время каждым пользователем на задаче за указанный период",
// // User
// 'total_hours',// "Всего часов за указанный период",
// 'hours_tasks',// "Часов на каждой задаче",
// 'hours_projects',// "Часов на каждом проекте",
// ];
return match($this) {
self::PROJECT => [
'total_spent_time_day',
'total_spent_time_day_and_users_separately',
],
self::USER => [
'total_spent_time_day',
'total_spent_time_day_and_tasks',
'total_spent_time_day_and_projects',
],
self::TASK => [
'total_spent_time_day',
'total_spent_time_day_users_separately',
],
};
}
public static function bases()
{
return array_map(fn($case) => $case->value, self::cases());
}
public function checkAccess(array $data_objects)
{
$user = request()->user();
return match($this) {
self::PROJECT => $user->projects()->select('id')->whereIn('id', $data_objects)->withoutGlobalScopes()->get()->count() === count($data_objects),
self::USER => '',
self::TASK => $user->tasks()->select('id')->whereIn('id', $data_objects)->withoutGlobalScopes()->get()->count() === count($data_objects),
};
}
public function model()
{
return match($this) {
self::PROJECT => new Project(),
self::USER => new User(),
self::TASK => new Task(),
};
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum UniversalReportType: string
{
case COMPANY = 'company';
case PERSONAL = 'personal';
}

163
app/Events/ChangeEvent.php Normal file
View File

@@ -0,0 +1,163 @@
<?php
namespace App\Events;
use App\Enums\Role;
use App\Models\Project;
use App\Models\Task;
use App\Models\TaskComment;
use App\Models\TimeInterval;
use App\Models\User;
use App\Reports\DashboardExport;
use Carbon\Carbon;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Settings;
use Staudenmeir\LaravelAdjacencyList\Eloquent\Builder as AdjacencyListBuilder;
class ChangeEvent implements ShouldBroadcast, ShouldDispatchAfterCommit
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/** @param Task|Project|TimeInterval $model */
public function __construct(
protected string $entityType,
protected string $action,
protected $model,
protected int $userId
) {
}
public function broadcastAs(): string
{
return sprintf('%s.%s', $this->entityType, $this->action);
}
public function broadcastWith(): array
{
// TODO: [ ] optimize loaded changes - payload too big
$model = match (true) {
$this->model instanceof Project && $this->entityType === 'gantt' => $this
->model
->setPermissionsUser(User::query()->find($this->userId))
->load([
'tasks' => fn(HasMany $queue) => $queue
->orderBy('start_date')
->select([
'id',
'task_name',
'priority_id',
'status_id',
'estimate',
'start_date',
'due_date',
'project_phase_id',
'project_id'
])->with(['status', 'priority'])
->withSum(['workers as total_spent_time'], 'duration')
->withSum(['workers as total_offset'], 'offset')
->withCasts(['start_date' => 'string', 'due_date' => 'string'])
->whereNotNull('start_date')->whereNotNull('due_date'),
'phases' => fn(HasMany $queue) => $queue
->select(['id', 'name', 'project_id'])
->withMin([
'tasks as start_date' => fn(AdjacencyListBuilder $q) => $q
->whereNotNull('start_date')
->whereNotNull('due_date')
], 'start_date')
->withMax([
'tasks as due_date' => fn(AdjacencyListBuilder $q) => $q
->whereNotNull('start_date')
->whereNotNull('due_date')
], 'due_date'),
])
->append('tasks_relations'),
$this->model instanceof Task => $this->model->setPermissionsUser(User::query()->find($this->userId))
->load([
'priority',
'project',
'users',
'status',
'parents',
'children',
'phase:id,name',
'workers',
'workers.user:id,full_name',
'attachmentsRelation',
'attachmentsRelation.user:id,full_name',
])
->append(['can'])
->loadSum('workers as total_spent_time', 'duration')
->loadSum('workers as total_offset', 'offset')
->makeVisible('can'),
$this->model instanceof TaskComment => $this->model
->load(
'user',
'attachmentsRelation',
'attachmentsRelation.user:id,full_name'
),
$this->model instanceof Project => $this->model->setPermissionsUser(User::query()->find($this->userId))
->load([
'users',
'defaultPriority',
'statuses',
'workers',
'workers.user:id,full_name',
'workers.task:id,task_name',
'phases' => fn($q) => $q->withCount('tasks'),
'group',
])
->loadCount('tasks')
->append(['can'])
->loadSum('workers as total_spent_time', 'duration')
->loadSum('workers as total_offset', 'offset')
->makeVisible('can'),
// Format a time interval as in the dashboard report
$this->model instanceof TimeInterval => DashboardExport::init(
[$this->model->user_id],
[$this->model->task->project_id],
Carbon::parse($this->model->start_at)->startOfDay(),
Carbon::parse($this->model->end_at)->endOfDay(),
Settings::scope('core')->get('timezone', 'UTC'),
$this->model->user->timezone ?? Settings::scope('core')->get('timezone', 'UTC'),
)->collection(['time_intervals.id' => $this->model->id])->first()->first()->toArray(),
default => $this->model,
};
return ['model' => $model];
}
/** @return Channel[] */
public function broadcastOn(): array
{
return [new PrivateChannel(sprintf('%s.%s', $this->entityType, $this->userId))];
}
/**
* @param Task|Project|TimeInterval $model
* @return int[]
*/
public static function getRelatedUserIds($model): array
{
$userRelation = match (true) {
$model instanceof Task => 'tasks',
$model instanceof Project => 'projects',
$model instanceof TimeInterval => 'timeIntervals',
default => null,
};
$query = User::query()->where('role_id', Role::ADMIN->value)->orWhere('role_id', Role::MANAGER->value);
if (isset($userRelation)) {
$query = $query->orWhereHas($userRelation, fn(Builder $builder) => $builder->where('id', $model->id));
}
return $query->pluck('id')->toArray();
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Events;
use App\Models\Invitation;
class InvitationCreated
{
public function __construct(public Invitation $invitation)
{
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Exceptions\Entities;
use Flugg\Responder\Exceptions\Http\HttpException;
class AppAlreadyInstalledException extends HttpException
{
protected $status = 400;
protected $errorCode = 'app.installation';
protected $message = 'App has been already installed';
}

View File

@@ -0,0 +1,172 @@
<?php
namespace App\Exceptions\Entities;
use Flugg\Responder\Exceptions\Http\HttpException;
class AuthorizationException extends HttpException
{
/**
* @apiDefine 400Error
* @apiError (Error 4xx) {String} message Message from server
* @apiError (Error 4xx) {Boolean} success Indicates erroneous response when `FALSE`
* @apiError (Error 4xx) {String} error_type Error type
*
* @apiVersion 1.0.0
*/
/**
* @apiDefine UnauthorizedError
* @apiErrorExample {json} Unauthorized
* HTTP/1.1 401 Unauthorized
* {
* "message": "Not authorized",
* "error_type": "authorization.unauthorized"
* }
*
* @apiVersion 1.0.0
*/
public const ERROR_TYPE_UNAUTHORIZED = 'authorization.unauthorized';
/**
* @apiDefine CaptchaError
* @apiError (Error 429) {Object} info Additional info from server
* @apiError (Error 429) {String} info.site_key Public site key for rendering reCaptcha
*
* @apiErrorExample {json} Captcha
* HTTP/1.1 429 Too Many Requests
* {
* "message": "Invalid captcha",
* "error_type": "authorization.captcha"
* }
*
* @apiVersion 1.0.0
*/
public const ERROR_TYPE_CAPTCHA = 'authorization.captcha';
/**
* @apiDefine LimiterError
* @apiErrorExample {json} Limiter
* HTTP/1.1 423 Locked
* {
* "message": "Enhance Your Calm",
* "error_type": "authorization.banned_enhance_your_calm"
* }
*
* @apiVersion 1.0.0
*/
public const ERROR_TYPE_BANNED = 'authorization.banned';
/**
* @apiDefine TokenMismatchError
* @apiErrorExample {json} Token mismatch
* HTTP/1.1 401 Unauthorized
* {
* "message": "Token mismatch",
* "error_type": "authorization.token_mismatch"
* }
*
* @apiVersion 1.0.0
*/
public const ERROR_TYPE_TOKEN_MISMATCH = 'authorization.token_mismatch';
/**
* @apiDefine TokenExpiredError
* @apiErrorExample {json} Token expired
* HTTP/1.1 401 Unauthorized
* {
* "message": "Token expired",
* "error_type": "authorization.token_expired"
* }
*
* @apiVersion 1.0.0
*/
public const ERROR_TYPE_TOKEN_EXPIRED = 'authorization.token_expired';
/**
* @apiDefine UserDeactivatedError
* @apiErrorExample {json} User deactivated
* HTTP/1.1 403 Forbidden
* {
* "message": "User deactivated",
* "error_type": "authorization.user_disabled"
* }
*
* @apiVersion 1.0.0
*/
public const ERROR_TYPE_USER_DISABLED = 'authorization.user_disabled';
/**
* @apiDeprecated since 4.0.0
* @apiDefine ParamsValidationError
* @apiErrorExample {json} Params validation
* HTTP/1.1 400 Bad Request
* {
* "message": "Invalid params",
* "error_type": "authorization.wrong_params"
* }
*
* @apiVersion 1.0.0
*/
public const ERROR_TYPE_VALIDATION_FAILED = 'authorization.wrong_params';
/**
* @apiDefine NoSuchUserError
* @apiErrorExample {json} No such user
* HTTP/1.1 404 Not Found
* {
* "message": "User with such email isnt found",
* "error_type": "authorization.user_not_found"
* }
*
* @apiVersion 1.0.0
*/
public const ERROR_TYPE_USER_NOT_FOUND = 'authorization.user_not_found';
/**
* @apiDefine InvalidPasswordResetDataError
* @apiErrorExample {json} Invalid password reset data
* HTTP/1.1 401 Unauthorized
* {
* "message": "Invalid password reset data",
* "error_type": "authorization.invalid_password_data"
* }
*
* @apiVersion 1.0.0
*/
public const ERROR_TYPE_INVALID_PASSWORD_RESET_DATA = 'authorization.invalid_password_data';
/**
* @apiDefine ForbiddenError
* @apiErrorExample {json} Forbidden
* HTTP/1.1 403 Forbidden
* {
* "message": "Access denied to this item",
* "error_type": "authorization.forbidden"
* }
*
* @apiVersion 1.0.0
*/
public const ERROR_TYPE_FORBIDDEN = 'authorization.forbidden';
protected const ERRORS = [
self::ERROR_TYPE_UNAUTHORIZED => ['code' => 401, 'message' => 'Not authorized'],
self::ERROR_TYPE_CAPTCHA => ['code' => 429, 'message' => 'Invalid captcha',],
self::ERROR_TYPE_BANNED => ['code' => 423, 'message' => 'Enhance Your Calm'],
self::ERROR_TYPE_TOKEN_MISMATCH => ['code' => 401, 'message' => 'Token mismatch'],
self::ERROR_TYPE_TOKEN_EXPIRED => ['code' => 401, 'message' => 'Token expired'],
self::ERROR_TYPE_USER_DISABLED => ['code' => 403, 'message' => 'User deactivated'],
self::ERROR_TYPE_VALIDATION_FAILED => ['code' => 400, 'message' => 'Invalid params'],
self::ERROR_TYPE_USER_NOT_FOUND => ['code' => 404, 'message' => 'User with such email isnt found'],
self::ERROR_TYPE_INVALID_PASSWORD_RESET_DATA => ['code' => 401, 'message' => 'Invalid password reset data'],
self::ERROR_TYPE_FORBIDDEN => ['code' => 403, 'message' => 'This action is unauthorized']
];
public function __construct($type = self::ERROR_TYPE_UNAUTHORIZED)
{
$this->errorCode = $type;
$this->status = self::ERRORS[$type]['code'];
parent::__construct(self::ERRORS[$type]['message']);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Exceptions\Entities;
use Flugg\Responder\Exceptions\Http\HttpException;
class DeprecatedApiException extends HttpException
{
public function __construct()
{
$lastCalledMethod = $this->getTrace()[0];
$deprecatedMethod = "{$lastCalledMethod['class']}@{$lastCalledMethod['function']}";
\Log::warning("Deprecated method {$deprecatedMethod} called, update Cattr client", [
'user_id' => auth()->user()->id ?? null
]);
$this->errorCode = 'deprecation.api';
$this->status = 400;
parent::__construct("Deprecated method {$deprecatedMethod} called, update Cattr client");
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Exceptions\Entities;
use Flugg\Responder\Exceptions\Http\HttpException;
class InstallationException extends HttpException
{
protected $status = 400;
protected $errorCode = 'app.installation';
protected $message = 'You need to run installation';
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Exceptions\Entities;
use Flugg\Responder\Exceptions\Http\HttpException;
class IntervalAlreadyDeletedException extends HttpException
{
protected $errorCode = 'interval_already_deleted';
protected $status = 409;
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Exceptions\Entities;
use Flugg\Responder\Exceptions\Http\HttpException;
class InvalidMainException extends HttpException
{
protected $status = 422;
protected $message = 'Base mistranslation detected';
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Exceptions\Entities;
use Flugg\Responder\Exceptions\Http\HttpException;
class MethodNotAllowedException extends HttpException
{
protected $status = 405;
protected $errorCode = 'http.request.wrong_method';
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Exceptions\Entities;
use Flugg\Responder\Exceptions\Http\HttpException;
class NotEnoughRightsException extends HttpException
{
public function __construct(string $message = 'Not enoughs rights')
{
$this->status = 403;
parent::__construct($message);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Exceptions\Entities;
use Flugg\Responder\Exceptions\Http\HttpException;
class TaskRelationException extends HttpException
{
public const NOT_SAME_PROJECT = 'task_relation.not_same_project';
public const CYCLIC = 'task_relation.cyclic';
public const ALREADY_EXISTS = 'task_relation.already_exists';
public const CANNOT_START_BEFORE_PARENT_ENDS = 'task_relation.cannot_start_before_parent_ends';
public function __construct($type)
{
$ERRORS = [
self::NOT_SAME_PROJECT => [
'code' => 409,
'message' => __("validation.tasks-relations.must_have_same_project")
],
self::CYCLIC => [
'code' => 409,
'message' => __("validation.tasks-relations.cyclic_relation_detected")
],
self::ALREADY_EXISTS => [
'code' => 409,
'message' => __("validation.tasks-relations.already_exists")
],
self::CANNOT_START_BEFORE_PARENT_ENDS => [
'code' => 409,
'message' => __("validation.tasks-relations.cannot_start_before_parent_ends")
]
];
$this->errorCode = $type;
$this->status = $ERRORS[$type]['code'];
parent::__construct($ERRORS[$type]['message']);
}
}

132
app/Exceptions/Handler.php Normal file
View File

@@ -0,0 +1,132 @@
<?php
namespace App\Exceptions;
use App\Exceptions\Entities\MethodNotAllowedException;
use Crypt;
use Filter;
use Flugg\Responder\Exceptions\ConvertsExceptions;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Contracts\Container\Container;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Session\TokenMismatchException;
use Illuminate\Validation\ValidationException;
use PDOException;
use Str;
use Symfony\Component\HttpFoundation\Response;
use Flugg\Responder\Exceptions\Http\HttpException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Throwable;
/**
* Class Handler
*/
class Handler extends ExceptionHandler
{
use ConvertsExceptions;
/**
* A list of exception types with their corresponding custom log levels.
*
* @var array<class-string<\Throwable>, \Psr\Log\LogLevel::*>
*/
protected $levels = [
//
];
/**
* A list of the exception types that are not reported.
*
* @var array<int, class-string<\Throwable>>
*/
protected $dontReport
= [
AuthenticationException::class,
AuthorizationException::class,
Entities\AuthorizationException::class,
HttpException::class,
ModelNotFoundException::class,
TokenMismatchException::class,
ValidationException::class,
PDOException::class,
];
/**
* A list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
public function register(): void
{
$this->reportable(function (Throwable $e) {
if (app()->bound('sentry')) {
app('sentry')->captureException($e);
}
});
}
/**
* Get the default context variables for logging.
*
* @return array<string, mixed>
*/
protected function context(): array
{
$traceId = Str::uuid()->toString();
try {
// Only add trace_id to error response if Filter::getErrorResponseFilterName() method exists
Filter::listen(Filter::getErrorResponseFilterName(), static function (array|null $data = []) use ($traceId) {
$data['trace_id'] = $traceId;
return $data;
});
} catch (Throwable $exception) {
}
$requestContent = collect(rescue(fn() => request()->all(), [], false))
->map(function ($item, string $key) {
if (Str::contains($key, ['screenshot', 'password', 'secret', 'token', 'api_key'], true)) {
return '***';
}
return $item;
})->toArray();
if (config('app.debug') === false){
try {
$requestContent = Crypt::encryptString(json_encode($requestContent, JSON_THROW_ON_ERROR));
} catch (Throwable $exception) {
}
}
return array_merge(parent::context(), [
'trace_id' => $traceId,
'request_uri' => request()->getRequestUri(),
'request_content' => $requestContent
]);
}
public function render($request, $e): Response
{
$this->convert($e, [
MethodNotAllowedHttpException::class => fn($e) => throw new MethodNotAllowedException($e->getMessage()),
AuthenticationException::class => fn($e
) => throw new Entities\AuthorizationException(Entities\AuthorizationException::ERROR_TYPE_UNAUTHORIZED),
]);
$this->convertDefaultException($e);
if ($e instanceof HttpException) {
return $this->renderResponse($e);
}
return responder()->error($e->getCode(), $e->getMessage())->respond();
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Exceptions\Interfaces;
use Throwable;
/**
* Interface ReasonableException
*/
interface InfoExtendedException extends Throwable
{
/**
* @return mixed
*/
public function getInfo();
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Exceptions\Interfaces;
use Throwable;
/**
* Interface TypedException
*/
interface TypedException extends Throwable
{
/**
* @return string
*/
public function getType(): string;
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Facades;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\Testing\Fakes\EventFake;
class EventFacade extends Facade
{
/**
* Replace the bound instance with a fake.
*
* @param array|string $eventsToFake
* @return void
*/
public static function fake(array|string $eventsToFake = []): void
{
static::swap($fake = new EventFake(static::getFacadeRoot(), $eventsToFake));
Model::setEventDispatcher($fake);
}
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor(): string
{
return 'catevent';
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Facades;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\Testing\Fakes\EventFake;
/**
* @template T of mixed
* @see Dispatcher
* @method static subscribe(object|string $subscriber)
* @method static listen(string|array $events, mixed $listener)
* @method static T process(string $event, T $payload)
* @mixin Dispatcher
*/
class FilterFacade extends Facade
{
/**
* Replace the bound instance with a fake.
*
* @param array|string $eventsToFake
* @return void
*/
public static function fake(array|string $eventsToFake = []): void
{
static::swap($fake = new EventFake(static::getFacadeRoot(), $eventsToFake));
Model::setEventDispatcher($fake);
}
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor(): string
{
return 'filter';
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Facades;
use Illuminate\Support\Facades\Facade;
class SettingsFacade extends Facade
{
protected static function getFacadeAccessor(): string
{
return 'settings';
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Filters;
use App\Enums\AttachmentStatus;
use App\Helpers\AttachmentHelper;
use App\Models\Attachment;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\Rule;
class AttachmentFilter
{
public function __construct()
{
}
/**
* Adds request rules to attachment parent
* @param array $rules
* @return array
*/
public static function addRequestRulesForParent(array $rules): array
{
$rules['attachmentsRelation'] = 'sometimes|required|array';
$rules['attachmentsRelation.*'] = [
'sometimes',
'required',
'uuid',
Rule::exists(Attachment::class, 'id')
];
$rules['attachmentsToRemove'] = 'sometimes|required|array';
$rules['attachmentsToRemove.*'] = [
'sometimes',
'required',
'uuid',
Rule::exists(Attachment::class, 'id')
];
return $rules;
}
public static function prepareAttachmentCreateRequest($request) {
/**
* @var $file UploadedFile
*/
$file = $request['attachment'];
$request['user_id'] = auth()->user()->id;
$request['status'] = AttachmentStatus::NOT_ATTACHED;
$request['original_name'] = AttachmentHelper::getFileName($file);
$request['mime_type'] = $file->getMimeType();
$request['extension'] = $file->clientExtension();
$request['size'] = $file->getSize();
return $request;
}
public function subscribe(): array
{
return array_merge([
'filter.request.attachment.create' => [[__CLASS__, 'prepareAttachmentCreateRequest']]
], AttachmentHelper::getFilters(
addRequestRulesForParent: [[__CLASS__, 'addRequestRulesForParent']]
));
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Helpers;
use App\Models\Task;
use App\Models\TaskComment;
use Illuminate\Http\UploadedFile;
use Str;
class AttachmentHelper
{
protected const TYPE_PLACEHOLDER = '__ABLE_TYPE__';
protected const MAX_FILE_NAME_LENGTH = 255; // name.extension
public const ABLE_BY = [
Task::TYPE => Task::class,
TaskComment::TYPE => TaskComment::class
];
public static function isAble(string $able_type): bool
{
return array_key_exists($able_type, self::ABLE_BY);
}
public static function getAbles(): array
{
return array_keys(self::ABLE_BY);
}
public static function getFileName(UploadedFile $file): string
{
$fileName = $file->getClientOriginalName();
$extension = ".{$file->clientExtension()}";
$maxNameLength = (self::MAX_FILE_NAME_LENGTH - Str::length($extension));
if (Str::length($fileName) > $maxNameLength || Str::endsWith($fileName, $extension) === false){
$fileName = Str::substr($fileName, 0, $maxNameLength) . $extension;
}
return $fileName;
}
public static function getEvents($parentCreated, $parentUpdated, $parentDeleted): array
{
return self::getMappedEvents([
'event.after.action.' . self::TYPE_PLACEHOLDER . '.create' => $parentCreated,
'event.after.action.' . self::TYPE_PLACEHOLDER . '.edit' => $parentUpdated,
'event.after.action.' . self::TYPE_PLACEHOLDER . '.destroy' => $parentDeleted,
]);
}
public static function getFilters(array $addRequestRulesForParent): array
{
return self::getMappedEvents([
'filter.validation.' . self::TYPE_PLACEHOLDER . '.edit' => $addRequestRulesForParent,
'filter.validation.' . self::TYPE_PLACEHOLDER . '.create' => $addRequestRulesForParent,
]);
}
private static function getMappedEvents(array $events): array
{
return array_merge(...array_map(
static fn($event, $function) => self::eventMapper($event, $function),
array_keys($events),
array_values($events)
));
}
private static function eventMapper($event, $function): array
{
return array_reduce(self::getAbles(), static function ($carry, $class) use ($function, $event) {
$carry[str_replace(self::TYPE_PLACEHOLDER, $class, $event)] = $function;
return $carry;
}, []);
}
public static function getMaxAllowedFileSize(): int|string
{
$size = trim(ini_get("upload_max_filesize") ?? '2M');
$last = strtolower($size[strlen($size)-1]);
$size = (float)$size;
switch($last) {
case 'g':
$size *= (1024 * 1024 * 1024); //1073741824
break;
case 'm':
$size *= (1024 * 1024); //1048576
break;
case 'k':
$size *= 1024;
break;
}
return $size;
}
}

31
app/Helpers/CatHelper.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
namespace App\Helpers;
class CatHelper
{
public const CATS = [
'=^._.^=',
'(=`ェ´=)',
'(=^ ◡ ^=)',
'/ᐠ。ꞈ。ᐟ\\',
'/ᐠ.ꞈ.ᐟ\\',
'✧/ᐠ-ꞈ-ᐟ\\',
'(ミචᆽචミ)',
'(=චᆽච=)',
'(=ㅇᆽㅇ=)',
'(=ㅇ༝ㅇ=)',
'₍⸍⸌̣ʷ̣̫⸍̣⸌₎',
'=^ᵒ⋏ᵒ^=',
'( ⓛ ﻌ ⓛ *)',
'(=ↀωↀ=)',
'(=^・ω・^=)',
'(=^・ェ・^=)',
'ㅇㅅㅇ'
];
public static function getCat(): string
{
return self::CATS[array_rand(self::CATS)];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Helpers;
use App;
use Config;
class EnvUpdater
{
public static function bulkSet(array $values): void
{
foreach ($values as $key => $value) {
self::set($key, $value);
}
}
public static function set(string $key, mixed $value): void
{
$currentContents = file_get_contents(App::environmentFilePath());
$keyPosition = strpos($currentContents, "/^{$key}=.*/m");
if ($keyPosition === false) {
$currentContents .= "\n{$key}={$value}";
} else {
$currentContents = preg_replace(
"/^{$key}=.*/m",
$key . '=' . $value,
$currentContents
);
}
file_put_contents(App::environmentFilePath(), $currentContents);
Config::set($key, $value);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Helpers;
use App\Contracts\ScreenshotService;
use App\Models\TimeInterval;
use Faker\Factory;
class FakeScreenshotGenerator
{
private const SCREENSHOT_WIDTH = 1920;
private const SCREENSHOT_HEIGHT = 1080;
public static function runForTimeInterval(TimeInterval|int $timeInterval): void
{
$service = app()->make(ScreenshotService::class);
$tmpFile = tempnam(sys_get_temp_dir(), 'cattr_screenshot');
$image = imagecreatetruecolor(self::SCREENSHOT_WIDTH, self::SCREENSHOT_HEIGHT);
$background = imagecolorallocate($image, random_int(0, 255), random_int(0, 255), random_int(0, 255));
imagefill($image, 0, 0, $background);
\imagejpeg($image, $tmpFile);
imagedestroy($image);
$service->saveScreenshot(
$tmpFile,
$timeInterval
);
unlink($tmpFile);
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace App\Helpers;
use Closure;
use Illuminate\Events\Dispatcher as LaravelDispatcher;
class FilterDispatcher extends LaravelDispatcher
{
public static function getRequestFilterName(): string
{
return 'filter.request.' . request()?->route()?->getName();
}
public static function getQueryFilterName(): string
{
return 'filter.query.get.' . request()?->route()?->getName();
}
public static function getQueryAdditionalRelationsFilterName(): string
{
return 'filter.query.with.' . request()?->route()?->getName();
}
public static function getQueryAdditionalRelationsSumFilterName(): string
{
return 'filter.query.withSum.' . request()?->route()?->getName();
}
public static function getSuccessResponseFilterName(): string
{
return 'filter.response.success.' . request()?->route()?->getName();
}
public static function getErrorResponseFilterName(): string
{
return 'filter.response.error.' . request()?->route()?->getName();
}
public static function getValidationFilterName(): string
{
return 'filter.validation.' . request()?->route()?->getName();
}
public static function getAuthFilterName(): string
{
return 'filter.authorize.' . request()?->route()?->getName();
}
public static function getAuthValidationFilterName(): string
{
return 'filter.authorize.validated' . request()?->route()?->getName();
}
public static function getBeforeActionEventName(): string
{
return 'event.before.action.' . request()?->route()?->getName();
}
public static function getAfterActionEventName(): string
{
return 'event.after.action.' . request()?->route()?->getName();
}
public static function getActionFilterName(): string
{
return 'filter.action.' . request()?->route()?->getName();
}
/**
* @inerhitDoc
* @param $event
* @param mixed $payload
* @return mixed
*/
public function process($event, mixed $payload): mixed
{
return $this->dispatch($event, [$payload]);
}
/**
* @inerhitDoc
* @param object|string $event
* @param array $payload
* @param bool $halt
* @return mixed
*/
public function dispatch($event, mixed $payload = [], $halt = false): mixed
{
foreach ($this->getListeners($event) as $listener) {
$response = $listener($event, $payload);
if ($halt && null !== $response) {
return $response;
}
if ($response === false) {
break;
}
$payload[0] = $response;
}
return $halt ? null : ($payload[0] ?? null);
}
/**
* Register an event listener with the dispatcher.
*
* @param Closure|string $listener
* @param bool $wildcard
* @return Closure
*/
public function makeListener($listener, $wildcard = false): callable
{
if (is_string($listener)) {
return $this->createClassListener($listener, $wildcard);
}
return static function ($event, $payload) use ($listener, $wildcard) {
if ($wildcard) {
return $listener($event, $payload[0]);
}
return $listener($payload[0]);
};
}
/**
* Create a class based listener using the IoC container.
*
* @param string $listener
* @param bool $wildcard
* @return Closure
*/
public function createClassListener($listener, $wildcard = false): callable
{
return function ($event, $payload) use ($listener, $wildcard) {
if ($wildcard) {
return call_user_func($this->createClassCallable($listener), $event, $payload[0]);
}
return call_user_func_array(
$this->createClassCallable($listener),
$payload
);
};
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Helpers;
use JsonException;
use Module;
class ModuleHelper
{
/**
* Returns information about installed modules.
* @throws JsonException
*/
public static function getModulesInfo(): array
{
foreach (Module::all() as $name => $module) {
$info[] = [
'name' => $name,
'version' => (string)(new Version($name)),
'enabled' => $module->isEnabled()
];
}
return $info ?? [];
}
}

202
app/Helpers/QueryHelper.php Normal file
View File

@@ -0,0 +1,202 @@
<?php
namespace App\Helpers;
use Exception;
use Filter;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Contracts\Database\Query\Builder;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
class QueryHelper
{
private const RESERVED_REQUEST_KEYWORDS = [
'with',
'withCount',
'paginate',
'perPage',
'page',
'with_deleted',
'search',
];
/**
* @throws Exception
*/
public static function apply(Builder $query, Model $model, array $filter = [], bool $first = true): void
{
$table = $model->getTable();
$relations = [];
if (isset($filter['limit'])) {
$query->limit((int)$filter['limit']);
unset($filter['limit']);
}
if (isset($filter['offset'])) {
$query->offset((int)$filter['offset']);
unset($filter['offset']);
}
if (isset($filter['orderBy'])) {
$order_by = $filter['orderBy'];
[$column, $dir] = isset($order_by[1]) ? array_values($order_by) : [$order_by[0], 'asc'];
if (Schema::hasColumn($table, $column)) {
$query->orderBy($column, $dir);
}
unset($filter['orderBy']);
}
if (isset($filter['with'])) {
$query->with(self::getRelationsFilter($filter['with']));
}
if (isset($filter['withCount'])) {
$query->withCount($filter['withCount']);
}
if (isset($filter['withSum'])) {
foreach ($filter['withSum'] as $withSum) {
$query->withSum(...$withSum);
}
}
if (isset($filter['search']['query'], $filter['search']['fields'])) {
$query = self::buildSearchQuery($query, $filter['search']['query'], $filter['search']['fields']);
}
if (isset($filter['where'])) {
foreach ($filter['where'] as $key => $param) {
if (str_contains($key, '.')) {
$params = explode('.', $key);
$domain = array_shift($params);
$filterParam = implode('.', $params);
if (!isset($relations[$domain])) {
$relations[$domain] = [];
}
$relations[$domain][$filterParam] = $param;
} elseif (Schema::hasColumn($table, $key) &&
!in_array($key, static::RESERVED_REQUEST_KEYWORDS, true) &&
!in_array($key, $model->getHidden(), true)
) {
[$operator, $value] = is_array($param) ? array_values($param) : ['=', $param];
if (is_array($value) && $operator !== 'in') {
if ($operator === '=') {
$query->whereIn("$table.$key", $value);
} elseif ($operator === 'between' && count($value) >= 2) {
$query->whereBetween("$table.$key", [$value[0], $value[1]]);
}
} elseif ($operator === 'in') {
$inArgs = is_array($value) ? $value : [$value];
$query->whereIn("$table.$key", $inArgs);
} else {
$query->where("$table.$key", $operator, $value);
}
}
}
}
if (!empty($relations)) {
foreach ($relations as $domain => $filters) {
if (!method_exists($model, $domain)) {
$cls = get_class($model);
throw new RuntimeException("Unknown relation $cls::$domain()");
}
/** @var Relation $relationQuery */
$relationQuery = $model->{$domain}();
$query->whereHas($domain, static function ($q) use ($filters, $relationQuery, $first) {
QueryHelper::apply($q, $relationQuery->getModel(), ['where' => $filters], $first);
});
}
}
}
/**
* @throws Exception
*/
private static function getRelationsFilter(array $filter): array
{
$key = array_search('can', $filter, true);
if ($key !== false) {
array_splice($filter, $key, 1);
Filter::listen(Filter::getActionFilterName(), static function ($data) {
if ($data instanceof Model) {
$data->append('can');
return $data;
}
if ($data instanceof Collection) {
return $data->map(static fn(Model $el) => $el->append('can'));
}
if ($data instanceof AbstractPaginator) {
$data->setCollection($data->getCollection()->map(static fn(Model $el) => $el->append('can')));
return $data;
}
return $data;
});
}
return $filter;
}
/**
* @param Builder $query
* @param string $search
* @param string[] $fields
*
* @return Builder
*/
private static function buildSearchQuery(Builder $query, string $search, array $fields): Builder
{
$value = "%$search%";
return $query->where(static function ($query) use ($value, $fields) {
$field = array_shift($fields);
if (str_contains($field, '.')) {
[$relation, $relationField] = explode('.', $field);
$query->whereHas($relation, static fn(Builder $query) => $query->where($relationField, 'like', $value)
);
} else {
$query->where($field, 'like', $value);
}
foreach ($fields as $field) {
if (str_contains($field, '.')) {
[$relation, $relationField] = explode('.', $field);
$query->orWhereHas($relation, static fn(Builder $query) => $query->where($relationField, 'like', $value)
);
} else {
$query->orWhere($field, 'like', $value);
}
}
});
}
public static function getValidationRules(): array
{
return [
'limit' => 'sometimes|int',
'offset' => 'sometimes|int',
'orderBy' => 'sometimes|array',
'with.*' => 'sometimes|string',
'withCount.*' => 'sometimes|string',
'withSum.*.*' => 'sometimes|string',
'search.query' => 'sometimes|string|nullable',
'search.fields.*' => 'sometimes|string',
'where' => 'sometimes|array',
];
}
}

237
app/Helpers/Recaptcha.php Normal file
View File

@@ -0,0 +1,237 @@
<?php
namespace App\Helpers;
use App\Exceptions\Entities\AuthorizationException;
use Cache;
use GuzzleHttp\Client;
use Request;
use Throwable;
class Recaptcha
{
private string $userEmail;
private bool $solved = false;
/**
* Increment amount of login tries for ip and login
*/
public function incrementCaptchaAmounts(): void
{
if (!$this->captchaEnabled()) {
return;
}
$cacheKey = $this->getCaptchaCacheKey();
if (!Cache::store('octane')->has($cacheKey)) {
Cache::store('octane')->put($cacheKey, 1, config('recaptcha.ttl'));
} else {
Cache::store('octane')->increment($cacheKey);
}
}
/**
* Shows captcha status from config
*
* @return bool
*/
private function captchaEnabled(): bool
{
return config('recaptcha.enabled');
}
/**
* Returns unique for ip and user login key
* @return string
*/
private function getCaptchaCacheKey(): string
{
$ip = Request::ip();
$email = $this->userEmail;
return "AUTH_RECAPTCHA_LIMITER_{$ip}_{$email}_ATTEMPTS";
}
/**
* Forget about tries of user to login
*/
public function clearCaptchaAmounts(): void
{
if (!$this->captchaEnabled()) {
return;
}
Cache::store('octane')->forget($this->getCaptchaCacheKey());
}
/**
* @param array $credentials
* @throws AuthorizationException
*/
public function check(array $credentials): void
{
if ($this->isBanned()) {
$this->incrementBanAmounts();
throw new AuthorizationException(AuthorizationException::ERROR_TYPE_BANNED);
}
$this->userEmail = $credentials['email'];
if (isset($credentials['recaptcha'])) {
$this->solve($credentials['recaptcha']);
}
if ($this->needsCaptcha()) {
$this->incrementBanAmounts();
throw new AuthorizationException(AuthorizationException::ERROR_TYPE_CAPTCHA);
}
}
/**
* Tests if user on ban list
*
* @return bool
*/
private function isBanned(): bool
{
if (!$this->banEnabled()) {
return false;
}
$cacheKey = $this->getBanCacheKey();
$banData = Cache::store('octane')->get($cacheKey, null);
if ($banData === null || !isset($banData['amounts'], $banData['time'])) {
return false;
}
if ($banData['amounts'] < config('recaptcha.ban_attempts')) {
return false;
}
if ($banData['time'] + config('recaptcha.rate_limiter_ttl') < time()) {
Cache::store('octane')->forget($cacheKey);
return false;
}
return true;
}
/**
* Shows ban limiter status from config
*
* @return bool
*/
private function banEnabled(): bool
{
return $this->captchaEnabled() && config('recaptcha.rate_limiter_enabled');
}
/**
* Returns unique for ip key
*
* @return string
*/
private function getBanCacheKey(): string
{
$ip = Request::ip();
return "AUTH_RATE_LIMITER_{$ip}";
}
/**
* Increment amount of tries for ip
*/
private function incrementBanAmounts(): void
{
if (!$this->banEnabled()) {
return;
}
$cacheKey = $this->getBanCacheKey();
$banData = Cache::store('octane')->get($cacheKey);
if ($banData === null || !isset($banData['amounts'])) {
$banData = ['amounts' => 1, 'time' => time()];
} else {
$banData['amounts']++;
}
Cache::store('octane')->put($cacheKey, $banData, config('recaptcha.rate_limiter_ttl'));
}
/**
* Sends request to google with captcha token for verify
*
* @param string $captchaToken
*/
private function solve(string $captchaToken = ''): void
{
if (!$this->captchaEnabled()) {
return;
}
if ($this->solved) {
return;
}
$response = (new Client())->post(config('recaptcha.google_url'), [
'form_params' => [
'secret' => config('recaptcha.secret_key'),
'response' => $captchaToken,
],
]);
if ($response->getStatusCode() !== 200) {
return;
}
$response = $response->getBody();
if ($response === null) {
return;
}
try {
$data = json_decode($response, true, 512, JSON_THROW_ON_ERROR | JSON_THROW_ON_ERROR);
} catch (Throwable $throwable) {
return;
}
if (isset($data['success']) && $data['success'] === true) {
$this->solved = true;
}
}
/**
* Tests if we need to show captcha to user
*
* @return bool
*/
private function needsCaptcha(): bool
{
if (!$this->captchaEnabled()) {
return false;
}
if ($this->solved) {
return false;
}
$cacheKey = $this->getCaptchaCacheKey();
$attempts = Cache::store('octane')->get($cacheKey, null);
if ($attempts === null) {
return false;
}
if ($attempts <= config('recaptcha.failed_attempts')) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace App\Helpers;
use App\Models\Project;
use App\Models\Task;
use App\Models\TimeInterval;
use App\Models\User;
use Carbon\Carbon;
use Carbon\CarbonPeriod;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use JsonException;
use Maatwebsite\Excel\Excel;
use RuntimeException;
class ReportHelper
{
public static string $dateFormat = 'Y-m-d';
public static function getReportFormat(Request $request)
{
return array_flip(self::getAvailableReportFormats())[$request->header('accept')] ?? null;
}
public static function getAvailableReportFormats(): array
{
return [
'csv' => 'text/csv',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'pdf' => 'application/pdf',
'xls' => 'application/vnd.ms-excel',
'ods' => 'application/vnd.oasis.opendocument.spreadsheet',
'html' => 'text/html',
];
}
/**
* @param int[] $users
* @param Carbon $startAt
* @param Carbon $endAt
* @param string[] $select
* @return Builder
*/
public static function getBaseQuery(
array $users,
Carbon $startAt,
Carbon $endAt,
array $select = []
): Builder
{
return Project::join('tasks', 'tasks.project_id', '=', 'projects.id')
->join('time_intervals', 'time_intervals.task_id', '=', 'tasks.id')
->join('users', 'time_intervals.user_id', '=', 'users.id')
->select(array_unique(
array_merge($select, [
'time_intervals.id',
'projects.id as project_id',
'projects.name as project_name',
'tasks.id as task_id',
'tasks.task_name as task_name',
'users.id as user_id',
'users.full_name as full_name',
'time_intervals.start_at',
])
))
->where(fn($query) => $query->whereBetween('time_intervals.start_at', [$startAt, $endAt])
->orWhereBetween('time_intervals.end_at', [$startAt, $endAt]))
->whereNull('time_intervals.deleted_at')
->whereIn('users.id', $users)
->groupBy(['tasks.id', 'users.id', 'time_intervals.start_at'])
->orderBy('time_intervals.start_at');
}
/**
* Calculate interval duration in period
* @param CarbonPeriod $period
* @param array $durationByDay
* @return int
*/
public static function getIntervalDurationInPeriod(CarbonPeriod $period, array $durationByDay): int
{
$durationInPeriod = 0;
foreach ($durationByDay as $date => $duration) {
$period->contains($date) && $durationInPeriod += $duration;
}
return $durationInPeriod;
}
/**
* Splits interval by days on which it exists on. Considering timezone of a user if provided.
* @param $interval
* @param $companyTimezone
* @param $userTimezone
* @return array
*/
public static function getIntervalDurationByDay($interval, $companyTimezone, $userTimezone = null): array
{
if ($userTimezone === null) {
$userTimezone = $companyTimezone;
}
$startAt = Carbon::parse($interval->start_at)->shiftTimezone($companyTimezone)->setTimezone($userTimezone);
$endAt = Carbon::parse($interval->end_at)->shiftTimezone($companyTimezone)->setTimezone($userTimezone);
$startDate = $startAt->format(self::$dateFormat);
$endDate = $endAt->format(self::$dateFormat);
$durationByDay = [];
if ($startDate === $endDate) {
$durationByDay[$startDate] = $interval->duration;
} else {
// If interval spans over midnight, divide it at midnight
$startOfDay = $endAt->copy()->startOfDay();
$startDateDuration = $startOfDay->diffInSeconds($startAt);
$durationByDay[$startDate] = $startDateDuration;
$endDateDuration = $endAt->diffInSeconds($startOfDay);
$durationByDay[$endDate] = $endDateDuration;
}
return $durationByDay;
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace App\Helpers;
use App\Contracts\ScreenshotService;
use App\Models\TimeInterval;
use Cache;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Database\Eloquent\Builder;
use Storage;
class StorageCleaner
{
public static function getFreeSpace(): float
{
return disk_free_space(Storage::path(ScreenshotService::PARENT_FOLDER));
}
public static function getUsedSpace(): float
{
return config('cleaner.total_space') - self::getFreeSpace();
}
public static function needThinning(): bool
{
return
self::getUsedSpace() * 100 / config('cleaner.total_space')
>= config('cleaner.threshold');
}
/**
* @throws BindingResolutionException
*/
public static function thin($force = false): void
{
if ((!$force && !self::needThinning()) || Cache::store('octane')->get('thinning_now')) {
return;
}
Cache::store('octane')->set('thinning_now', true);
$service = app()->make(ScreenshotService::class);
while (self::getUsedSpace() > self::getWaterlineBorder()) {
$availableScreenshots = self::getAvailableScreenshots();
if (count($availableScreenshots) === 0) {
break;
}
foreach ($availableScreenshots as $screenshot) {
$service->destroyScreenshot($screenshot);
}
}
Cache::store('octane')->set('thinning_now',false);
Cache::store('octane')->set('last_thin',now());
}
/**
* @return array
* @throws BindingResolutionException
*/
public static function getAvailableScreenshots(): array
{
$service = app()->make(ScreenshotService::class);
$collection = self::getScreenshotsCollection();
$result = [];
$i = 0;
foreach ($collection->cursor() as $interval) {
if (Storage::exists($service->getScreenshotPath($interval))) {
$result[] = $interval->id;
}
if ($i >= config('cleaner.page_size')) {
break;
}
}
return $result;
}
/**
* @return int
* @throws BindingResolutionException
*/
public static function countAvailableScreenshots(): int
{
$count = 0;
$service = app()->make(ScreenshotService::class);
$collection = self::getScreenshotsCollection();
foreach ($collection->cursor() as $interval) {
$count += (int)Storage::exists($service->getScreenshotPath($interval));
}
return $count;
}
private static function getWaterlineBorder(): float
{
return config('cleaner.total_space')
* (config('cleaner.threshold') * 0.01)
* (1 - config('cleaner.waterline') * 0.01);
}
private static function getScreenshotsCollection(): Builder|TimeInterval|\Illuminate\Database\Query\Builder
{
return TimeInterval::whereHas('task', function (Builder $query) {
$query->where('important', '=', 0);
})
->whereHas('task.project', function (Builder $query) {
$query->where('important', '=', 0);
})->whereHas('user', function (Builder $query) {
$query->where('permanent_screenshots', '=', 0);
})->orderBy('id');
}
}

114
app/Helpers/Version.php Normal file
View File

@@ -0,0 +1,114 @@
<?php
namespace App\Helpers;
use Composer\InstalledVersions;
use CzProject\GitPhp\Git;
use CzProject\GitPhp\GitException;
use Exception;
use Illuminate\Support\Str;
use Nwidart\Modules\Facades\Module;
use RuntimeException;
use Throwable;
//use Module;
class Version
{
private string $version;
/**
* @throws Throwable
*/
public function __construct(protected ?string $module = null)
{
throw_if($module && !isset(self::getModules()[$module]), new RuntimeException('No such module'));
if ($module) {
$this->version = self::getModules()[$module];
return;
}
$this->version = env('APP_VERSION', 'dev') ?: self::getComposerVersion(base_path());
}
public static function getModules(): array
{
return cache()->rememberForever('app.modules', static function() {
try {
return Module::toCollection()->map(static function (\Nwidart\Modules\Laravel\Module $module) {
$modulePath = $module->getPath();
try {
return self::getComposerVersion($modulePath);
} catch (Throwable) {
try {
return self::getFileVersion($modulePath);
} catch (Throwable) {
try {
return self::getGitVersion($modulePath);
} catch (Throwable) {
return 'dev';
}
}
}
})->toArray();
} catch (Throwable) {
return [];
}
});
}
/**
* @throws Throwable
*/
private static function getComposerVersion(string $path): string
{
throw_unless(file_exists("$path/composer.json"));
$composerConfig = file_get_contents("$path/composer.json");
throw_unless(Str::isJson($composerConfig));
$package = json_decode($composerConfig, true, 512, JSON_THROW_ON_ERROR);
throw_unless(InstalledVersions::isInstalled($package['name']) || isset($package['version']));
return $package['version'] ?? InstalledVersions::getVersion($package['name']);
}
/**
* @throws Throwable
*/
private static function getFileVersion(string $path): string
{
throw_unless(file_exists("$path/module.json"));
$moduleConfig = file_get_contents("$path/module.json");
throw_unless(Str::isJson($moduleConfig));
$module = json_decode($moduleConfig, true, 512, JSON_THROW_ON_ERROR);
throw_unless(isset($module['version']));
return $module['version'];
}
/**
* @throws GitException
*/
private static function getGitVersion(string $path): string
{
$repo = (new Git())->open($path);
$tags = $repo->getTags();
return array_pop($tags);
}
public function __toString(): string
{
return preg_replace('/^v(.*)$/', '$1', $this->version);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
final class ActuatorController
{
public function __invoke(): JsonResponse
{
return responder()->success()->respond(204);
}
}

View File

@@ -0,0 +1,301 @@
<?php
namespace App\Http\Controllers\Api;
use App\Console\Commands\RotateScreenshots;
use App\Helpers\ModuleHelper;
use App\Helpers\ReportHelper;
use App\Helpers\StorageCleaner;
use App\Helpers\Version;
use Artisan;
use Cache;
use Exception;
use GuzzleHttp\Client;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\JsonResponse;
use Illuminate\Routing\Controller;
use JsonException;
use Settings;
class AboutController extends Controller
{
private Client $client;
public function __construct()
{
$this->client = new Client([
'base_uri' => config('app.stats_collector_url') . '/v2/',
'headers' => ['x-cattr-instance' => Settings::scope('core')->get('instance')],
]);
}
/**
* @api {get} /about/reports Get Available Report Formats
* @apiDescription Retrieves the list of available report formats and their corresponding MIME types.
*
* @apiVersion 4.0.0
* @apiName GetReportFormats
* @apiGroup About
*
* @apiSuccess {Object} types List of available report formats and their MIME types.
* @apiSuccess {String} types.csv MIME type for CSV format.
* @apiSuccess {String} types.xlsx MIME type for XLSX format.
* @apiSuccess {String} types.pdf MIME type for PDF format.
* @apiSuccess {String} types.xls MIME type for XLS format.
* @apiSuccess {String} types.ods MIME type for ODS format.
* @apiSuccess {String} types.html MIME type for HTML format.
*
* @apiSuccessExample {json} Success Response:
* HTTP/1.1 200 OK
* {
* "types": {
* "csv": "text/csv",
* "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
* "pdf": "application/pdf",
* "xls": "application/vnd.ms-excel",
* "ods": "application/vnd.oasis.opendocument.spreadsheet",
* "html": "text/html"
* }
* }
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse UnauthorizedError
* @apiUse ForbiddenError
*/
public function reports(): JsonResponse
{
return responder()->success([
'types' => ReportHelper::getAvailableReportFormats(),
])->respond();
}
/**
* Returns information about this instance.
* @throws JsonException
*/
/**
* @api {get} /about Get Application and Module Information
* @apiDescription Retrieves information about the application instance, modules, and image version details.
*
* @apiVersion 4.0.0
* @apiName GetAppInfo
* @apiGroup About
*
* @apiSuccess {Object} app Application information.
* @apiSuccess {String} app.version The current version of the application.
* @apiSuccess {String} app.instance_id The unique identifier of the application instance.
* @apiSuccess {Boolean} app.vulnerable Indicates if the application is vulnerable.
* @apiSuccess {String} app.last_version The latest version available for the application.
* @apiSuccess {String} app.message Any important message related to the application.
*
* @apiSuccess {Object[]} modules List of modules integrated into the application.
* @apiSuccess {String} modules.name Name of the module.
* @apiSuccess {String} modules.version Current version of the module.
* @apiSuccess {Boolean} modules.enabled Indicates if the module is enabled.
*
* @apiSuccess {Object} image Information about the image version.
* @apiSuccess {String} image.version The version of the image (if available).
* @apiSuccess {Boolean} image.vulnerable Indicates if the image is vulnerable.
* @apiSuccess {String} image.last_version The latest available version for the image.
* @apiSuccess {String} image.message Any important message related to the image.
*
* @apiSuccessExample {json} Success Response:
* HTTP/1.1 200 OK
* {
* "app": {
* "version": "dev",
* "instance_id": null,
* "vulnerable": null,
* "last_version": null,
* "message": null
* },
* "modules": [
* {
* "name": "JiraIntegration",
* "version": "3.0.0",
* "enabled": false
* }
* ],
* "image": {
* "version": null,
* "vulnerable": null,
* "last_version": null,
* "message": null
* }
* }
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse UnauthorizedError
* @apiUse ForbiddenError
*/
public function __invoke(): JsonResponse
{
$imageVersion = getenv('IMAGE_VERSION', true) ?: null;
$releaseInfo = $this->requestReleaseInfo();
$modulesInfo = $this->requestModulesInfo();
$imageInfo = ($imageVersion) ? $this->requestImageInfo($imageVersion) : false;
return responder()->success([
'app' => [
'version' => config('app.version'),
'instance_id' => Settings::scope('core')->get('instance'),
'vulnerable' => optional($releaseInfo)->vulnerable,
'last_version' => optional($releaseInfo)->lastVersion,
'message' => optional($releaseInfo)->flashMessage,
],
'modules' => $modulesInfo,
'image' => [
'version' => $imageVersion,
'vulnerable' => optional($imageInfo)->vulnerable,
'last_version' => optional($imageInfo)->lastVersion,
'message' => optional($imageInfo)->flashMessage,
]
])->respond();
}
private function requestReleaseInfo(): ?object
{
try {
return json_decode(
$this->client->get('release/' . config('app.version'))->getBody()->getContents(),
false,
512,
JSON_THROW_ON_ERROR | JSON_THROW_ON_ERROR
);
} catch (Exception) {
return null;
}
}
/**
* @throws JsonException
*/
private function requestModulesInfo(): array
{
$options = [
'json' => ModuleHelper::getModulesInfo(),
];
try {
return array_map(static function ($el) {
$el['version'] = (string)(new Version($el['name']));
return $el;
}, json_decode(
$this->client->post('modules', $options)->getBody()->getContents(),
true,
512,
JSON_THROW_ON_ERROR | JSON_THROW_ON_ERROR
)['modules']);
} catch (Exception) {
return ModuleHelper::getModulesInfo();
}
}
private function requestImageInfo(string $imageVersion): ?object
{
try {
return json_decode(
$this->client->get("image/$imageVersion")->getBody()->getContents(),
false,
512,
JSON_THROW_ON_ERROR | JSON_THROW_ON_ERROR
);
} catch (Exception) {
return null;
}
}
/**
* @throws BindingResolutionException
*/
/**
* @api {get} /storage Get Storage Information
* @apiDescription Retrieves information about the storage space, thinning status, and available screenshots.
*
* @apiVersion 4.0.0
* @apiName GetStorageInfo
* @apiGroup Storage
*
* @apiSuccess {Object} space Information about the storage space.
* @apiSuccess {Number} space.left The amount of free space left (in bytes).
* @apiSuccess {Number} space.used The amount of space currently used (in bytes).
* @apiSuccess {Number} space.total The total amount of storage space available (in bytes).
*
* @apiSuccess {Number} threshold The storage usage threshold percentage before thinning is needed.
* @apiSuccess {Boolean} need_thinning Indicates if the storage requires thinning.
* @apiSuccess {Number} screenshots_available The number of available screenshots in the storage.
*
* @apiSuccess {Object} thinning Information about the thinning process.
* @apiSuccess {Boolean} thinning.now Indicates if the thinning process is currently ongoing.
* @apiSuccess {String} thinning.last Timestamp of the last thinning process.
*
* @apiSuccessExample {json} Success Response:
* HTTP/1.1 200 OK
* {
* "space": {
* "left": 26579533824,
* "used": 178705711104,
* "total": 205285244928
* },
* "threshold": 75,
* "need_thinning": true,
* "screenshots_available": 0,
* "thinning": {
* "now": null,
* "last": null
* }
* }
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse UnauthorizedError
* @apiUse ForbiddenError
*/
public function storage(): JsonResponse
{
return responder()->success([
'space' => [
'left' => StorageCleaner::getFreeSpace(),
'used' => StorageCleaner::getUsedSpace(),
'total' => config('cleaner.total_space'),
],
'threshold' => config('cleaner.threshold'),
'need_thinning' => StorageCleaner::needThinning(),
'screenshots_available' => StorageCleaner::countAvailableScreenshots(),
'thinning' => [
'now' => Cache::store('octane')->get('thinning_now'),
'last' => Cache::store('octane')->get('last_thin'),
]
])->respond();
}
/**
* @api {get} /storage Get Storage Information
* @apiDescription Retrieves information about the storage space, thinning status, and available screenshots.
*
* @apiVersion 4.0.0
* @apiName GetStorageInfo
* @apiGroup Storage
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 204 No Content
* {
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*
*/
public function startStorageClean(): JsonResponse
{
Artisan::queue(RotateScreenshots::class);
return responder()->success()->respond(204);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Api;
use App\Contracts\AttachmentService;
use App\Enums\AttachmentStatus;
use App\Http\Requests\Attachment\CreateAttachmentRequest;
use App\Http\Requests\Attachment\DownloadAttachmentRequest;
use App\Models\Attachment;
use Filter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
use URL;
class AttachmentController extends ItemController
{
protected const MODEL = Attachment::class;
public function __construct(protected AttachmentService $attachmentService)
{
}
/**
* @param CreateAttachmentRequest $request
* @return JsonResponse
*
* @throws Throwable
*/
public function create(CreateAttachmentRequest $request): JsonResponse
{
Filter::listen(Filter::getActionFilterName(), static function (Attachment $attachment) {
$attachment->load('user');
return $attachment;
});
return $this->_create($request);
}
public function tmpDownload(Request $request, Attachment $attachment): ?StreamedResponse
{
if (! $request->hasValidSignature()) {
abort(401);
}
return $this->streamDownloadLogic($attachment);
}
public function createTemporaryUrl(DownloadAttachmentRequest $request, Attachment $attachment): string
{
// we do this because signature breaks if we use built in method for creating relative path
// also cannot determine request scheme when ran inside docker
$url = URL::temporarySignedRoute(
'attachment.temporary-download',
now()->addSeconds($request->validated('seconds') ?? 3600),
$attachment
);
$parsedUrl = parse_url($url);
// Combine the path and query string
return $parsedUrl['path'] . (isset($parsedUrl['query']) ? '?' . $parsedUrl['query'] : '');
}
/**
* @param Attachment $attachment
* @return StreamedResponse|void
*/
protected function streamDownloadLogic(Attachment $attachment)
{
if ($attachment->status === AttachmentStatus::GOOD && $this->attachmentService->fileExists($attachment)) {
$headers = [];
if ($attachment->mime_type !== '') {
$headers['Content-Type'] = $attachment->mime_type;
}
return response()->streamDownload(
function () use ($attachment) {
$stream = $this->attachmentService->readStream($attachment);
while (!feof($stream)) {
echo fread($stream, 2048);
}
fclose($stream);
},
$attachment->original_name,
$headers
);
}
abort(404);
}
}

View File

@@ -0,0 +1,229 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\CompanySettings\UpdateCompanySettingsRequest;
use App\Http\Transformers\CompanySettingsTransformer;
use App\Models\Priority;
use Illuminate\Http\JsonResponse;
use Settings;
class CompanySettingsController extends Controller
{
/**
* @api {get} /company-settings/ List
* @apiDescription Returns all company settings.
*
* @apiVersion 4.0.0
* @apiName ListCompanySettings
* @apiGroup Company Settings
*
* @apiUse AuthHeader
*
* @apiVersion 4.0.0
* @apiName ListCompanySettings
* @apiGroup Company Settings
*
* @apiUse AuthHeader
*
* @apiSuccess {String} timezone The timezone setting for the company.
* @apiSuccess {String} language The language setting for the company.
* @apiSuccess {Integer} work_time The configured work time in hours.
* @apiSuccess {Array} color Array of colors configured for the company settings.
* @apiSuccess {Array} internal_priorities Array of internal priorities.
* @apiSuccess {Integer} internal_priorities.id The unique ID of the priority.
* @apiSuccess {String} internal_priorities.name The name of the priority.
* @apiSuccess {String} internal_priorities.created_at The creation timestamp of the priority.
* @apiSuccess {String} internal_priorities.updated_at The last update timestamp of the priority.
* @apiSuccess {String} internal_priorities.color The color associated with the priority.
* @apiSuccess {Integer} heartbeat_period The period for heartbeat checks in seconds.
* @apiSuccess {Boolean} auto_thinning Indicates if automatic thinning of old data is enabled.
* @apiSuccess {Integer} screenshots_state The current state of screenshot monitoring.
* @apiSuccess {Integer} env_screenshots_state The environmental screenshot state.
* @apiSuccess {Integer} default_priority_id The default priority ID.
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* {
* "timezone": "UTC",
* "language": "ru",
* "work_time": 0,
* "color": [],
* "internal_priorities": [
* {
* "id": 1,
* "name": "Normal",
* "created_at": "2023-10-26T10:26:17.000000Z",
* "updated_at": "2024-07-12T17:57:40.000000Z",
* "color": null
* },
* {
* "id": 2,
* "name": "Normal",
* "created_at": "2023-10-26T10:26:17.000000Z",
* "updated_at": "2024-06-21T10:06:50.000000Z",
* "color": "#49E637"
* },
* {
* "id": 3,
* "name": "High",
* "created_at": "2023-10-26T10:26:17.000000Z",
* "updated_at": "2024-06-21T10:07:00.000000Z",
* "color": "#D40C0C"
* },
* {
* "id": 5,
* "name": "Normal",
* "created_at": "2024-07-12T17:10:54.000000Z",
* "updated_at": "2024-07-12T17:10:54.000000Z",
* "color": null
* }
*],
* "heartbeat_period": 60,
* "auto_thinning": true,
* "screenshots_state": 1,
* "env_screenshots_state": -1,
* "default_priority_id": 2
* }
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
public function index(): JsonResponse
{
return responder()->success(
array_merge(
Settings::scope('core')->all(),
['internal_priorities' => Priority::all()]
),
new CompanySettingsTransformer
)->respond();
}
/**
* @param UpdateCompanySettingsRequest $request
*
* @return JsonResponse
*
* @api {patch} /company-settings/ Update
* @apiDescription Updates the specified company settings.
*
* @apiVersion 4.0.0
* @apiName UpdateCompanySettings
* @apiGroup Company Settings
*
* @apiUse AuthHeader
*
* @apiParam {String} timezone The timezone setting for the company.
* @apiParam {String} language The language setting for the company.
* @apiParam {Integer} work_time The configured work time in hours.
* @apiParam {Array} color Array of colors configured for the company settings.
* @apiParam {Array} internal_priorities Array of internal priorities.
* @apiParam {Integer} internal_priorities.id The unique ID of the priority.
* @apiParam {String} internal_priorities.name The name of the priority.
* @apiParam {String} internal_priorities.created_at The creation timestamp of the priority.
* @apiParam {String} internal_priorities.updated_at The last update timestamp of the priority.
* @apiParam {String} internal_priorities.color The color associated with the priority.
* @apiParam {Integer} heartbeat_period The period for heartbeat checks in seconds.
* @apiParam {Boolean} auto_thinning Indicates if automatic thinning of old data is enabled.
* @apiParam {Integer} screenshots_state The current state of screenshot monitoring.
* @apiParam {Integer} env_screenshots_state The environmental screenshot state.
* @apiParam {Integer} default_priority_id The default priority ID.
*
* @apiParamExample {json} Request Example
* { "timezone" : "Europe/Moscow",
* "language" : "en",
* "work_time" : 0,
* "color" : [],
* "internal_priorities" : [
* {
* "id" : 1,
* "name" : "Normal",
* "created_at" : "2023-10-26T10:26:17.000000Z",
* "updated_at" : "2024-07-12T17:57:40.000000Z",
* "color" : null
* },
* {
* "id" : 2,
* "name" : "Normal",
* "created_at" : "2023-10-26T10:26:17.000000Z",
* "updated_at" : "2024-06-21T10:06:50.000000Z",
* "color" : "#49E637"},
* {
* "id" : 3,
* "name" : "High",
* "created_at" : "2023-10-26T10:26:17.000000Z",
* "updated_at" : "2024-06-21T10:07:00.000000Z",
* "color" : "#D40C0C"
* },{
* "id" : 5,
* "name" : "Normal",
* "created_at" : "2024-07-12T17:10:54.000000Z",
* "updated_at" : "2024-07-12T17:10:54.000000Z",
* "color" : null
* }
* ],
* "heartbeat_period" : 60,
* "auto_thinning" : true,
* "screenshots_state" : 1,
* "env_screenshots_state" : -1,
* "default_priority_id" : 2
* }
*
* @apiSuccess {Array} data Contains an array of settings that changes were applied to
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 204 No Content
* {
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*
*/
public function update(UpdateCompanySettingsRequest $request): JsonResponse
{
Settings::scope('core')->set($request->validated());
return responder()->success()->respond(204);
}
/**
* @api {get} /offline-sync/public-key Get Offline Sync Public Key
* @apiDescription Retrieves the public key for offline synchronization.
*
* @apiVersion 4.0.0
* @apiName GetOfflineSyncPublicKey
* @apiGroup OfflineSync
*
* @apiUse AuthHeader
*
* @apiPermission offline_sync_view
* @apiPermission offline_sync_full_access
*
* @apiSuccess {Boolean} success Indicates if the operation was successful.
* @apiSuccess {Object} data The response data.
* @apiSuccess {String} data.key The public key for offline synchronization.
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "key": null
* }
* @apiError (Error 401) Unauthorized The user is not authorized to access this resource.
* @apiError (Error 403) Forbidden The user does not have the necessary permissions.
* @apiError (Error 404) NotFound The requested resource was not found.
*
* @apiUse 400Error
* @apiUse UnauthorizedError
* @apiUse ItemNotFoundError
* @apiUse ForbiddenError
*/
public function getOfflineSyncPublicKey(): JsonResponse
{
return responder()->success(
[ 'key' => Settings::scope('core.offline-sync')->get('public_key')],
)->respond();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,306 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Requests\Invitation\CreateInvitationRequest;
use App\Http\Requests\Invitation\ListInvitationRequest;
use App\Http\Requests\Invitation\DestroyInvitationRequest;
use App\Http\Requests\Invitation\ShowInvitationRequest;
use App\Http\Requests\Invitation\UpdateInvitationRequest;
use App\Models\Invitation;
use App\Services\InvitationService;
use Exception;
use Filter;
use Illuminate\Http\JsonResponse;
use Throwable;
class InvitationController extends ItemController
{
protected const MODEL = Invitation::class;
/**
* @throws Throwable
* @api {post} /invitations/show Show
* @apiDescription Show invitation.
*
* @apiVersion 1.0.0
* @apiName Show Invitation
* @apiGroup Invitation
*
* @apiUse AuthHeader
*
* @apiParam {Integer} id Invitation ID
*
* @apiParamExample {json} Request Example
* {
* "id": 1
* }
*
* @apiSuccess {Array} res Array of records containing the id, email, key, expiration date and role id
*
* @apiUse InvitationObject
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "res": [
* {
* "id": 1
* "email": "test@example.com",
* "key": "06d4a090-9675-11ea-bf39-5f84c549e29c",
* "expires_at": "2020-01-01T00:00:00.000000Z",
* "role_id": 1
* }
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*
*/
public function show(ShowInvitationRequest $request): JsonResponse
{
return $this->_show($request);
}
/**
* @throws Exception
* @api {get} /invitations/list List
* @apiDescription Get list of invitations.
*
* @apiVersion 1.0.0
* @apiName Invitation List
* @apiGroup Invitation
*
* @apiUse AuthHeader
*
* @apiSuccess {Array} res Array of records containing the id, email, key, expiration date and role id
*
* @apiUse InvitationObject
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "res": [
* {
* "id": 1
* "email": "test@example.com",
* "key": "06d4a090-9675-11ea-bf39-5f84c549e29c",
* "expires_at": "2020-01-01T00:00:00.000000Z",
* "role_id": 1
* }
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*
*/
public function index(ListInvitationRequest $request): JsonResponse
{
return $this->_index($request);
}
/**
* @param CreateInvitationRequest $request
* @return JsonResponse
* @throws Exception
* @api {post} /invitations/create Create
* @apiDescription Creates a unique invitation token and sends an email to the users
*
* @apiVersion 1.0.0
* @apiName Create Invitation
* @apiGroup Invitation
*
* @apiUse AuthHeader
*
* @apiParam {Array} users List of users to send an invitation to
* @apiParam {String} users.email User email
* @apiParam {Integer} users.role_id ID of the role that will be assigned to the created user
*
* @apiParamExample {json} Request Example
* {
* "users": [
* {
* "email": "test@example.com",
* "role_id": 1
* }
* ]
* }
*
* @apiSuccess {String} res Array of records containing the id, email, key, expiration date and role id
*
* @apiUse InvitationObject
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "res": [
* {
* "id": 1
* "email": "test@example.com",
* "key": "06d4a090-9675-11ea-bf39-5f84c549e29c",
* "expires_at": "2020-01-01T00:00:00.000000Z",
* "role_id": 1
* }
* ]
* }
*
* @apiErrorExample {json} Email is not specified
* HTTP/1.1 400 Bad Request
* {
* "error_type": "validation",
* "message": "Validation error",
* "info": {
* "users.0.email": [
* "The email field is required."
* ]
* }
* }
*
* @apiErrorExample {json} Email already exists
* HTTP/1.1 400 Bad Request
* {
* "error_type": "validation",
* "message": "Validation error",
* "info": {
* "users.0.email": [
* "The email test@example.com has already been taken."
* ]
* }
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
public function create(CreateInvitationRequest $request): JsonResponse
{
$requestData = Filter::process(Filter::getRequestFilterName(), $request->validated());
$invitations = [];
foreach ($requestData['users'] as $user) {
$invitations[] = InvitationService::create($user);
}
return responder()->success($invitations)->respond();
}
/**
* @param UpdateInvitationRequest $request
* @return JsonResponse
* @throws Exception
*
* @api {post} /invitations/resend Resend
* @apiDescription Updates the token expiration date and sends an email to the user's email address.
*
* @apiVersion 1.0.0
* @apiName Update Invitation
* @apiGroup Invitation
*
* @apiUse AuthHeader
*
* @apiParam {Integer} id Invitation ID
*
* @apiParamExample {json} Request Example
* {
* "id": 1
* }
*
* @apiSuccess {Array} res Invitation data
*
* @apiUse InvitationObject
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "res": {
* "id": 1
* "email": "test@example.com",
* "key": "06d4a090-9675-11ea-bf39-5f84c549e29c",
* "expires_at": "2020-01-01T00:00:00.000000Z",
* "role_id": 1
* }
* }
*
* @apiErrorExample {json} The id does not exist
* HTTP/1.1 400 Bad Request
* {
* "error_type": "validation",
* "message": "Validation error",
* "info": {
* "id": [
* "The selected id is invalid."
* ]
* }
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*
*/
public function resend(UpdateInvitationRequest $request): JsonResponse
{
$requestData = Filter::process(Filter::getRequestFilterName(), $request->validated());
$invitation = InvitationService::update($requestData['id']);
return responder()->success($invitation)->respond();
}
/**
* @throws Throwable
* @api {post} /invitations/remove Destroy
* @apiDescription Destroy User
*
* @apiVersion 1.0.0
* @apiName Destroy Invitation
* @apiGroup Invitation
*
* @apiUse AuthHeader
*
* @apiParam {Integer} id ID of the target invitation
*
* @apiParamExample {json} Request Example
* {
* "id": 1
* }
*
* @apiSuccess {String} message Destroy status
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "message": "Item has been removed"
* }
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse ForbiddenError
* @apiUse UnauthorizedError
*/
public function destroy(DestroyInvitationRequest $request): JsonResponse
{
return $this->_destroy($request);
}
/**
* @param ListInvitationRequest $request
* @return JsonResponse
* @throws Exception
*/
/**
* @api {get} /api/invitations/count Count Invitations
* @apiDescription Returns the total count of invitations
*
* @apiVersion 4.0.0
* @apiName CountInvitations
* @apiGroup Invitations
*
* @apiUse TotalSuccess
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
public function count(ListInvitationRequest $request): JsonResponse
{
return $this->_count($request);
}
}

View File

@@ -0,0 +1,648 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Requests\CattrFormRequest;
use Filter;
use App\Helpers\QueryHelper;
use App\Http\Controllers\Controller;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use CatEvent;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
abstract class ItemController extends Controller
{
protected const MODEL = Model::class;
/**
* @apiDefine ItemNotFoundError
* @apiErrorExample {json} No such item
* HTTP/1.1 404 Not Found
* {
* "message": "Item not found",
* "error_type": "query.item_not_found"
* }
*
* @apiVersion 1.0.0
*/
/**
* @apiDefine ValidationError
* @apiErrorExample {json} Validation error
* HTTP/1.1 400 Bad Request
* {
* "message": "Validation error",
* "error_type": "validation",
* "info": "Invalid id"
* }
*
* @apiError (Error 400) {String} info Validation errors
*
* @apiVersion 1.0.0
*/
/**
* @apiDefine ProjectIDParam
* @apiParam {Integer} id Project ID
*
* @apiParamExample {json} Request Example
* {
* "id": 1
* }
*/
/**
* @apiDefine ProjectObject
*
* @apiSuccess {Integer} id Project ID
* @apiSuccess {Integer} company_id Company ID
* @apiSuccess {String} name Project name
* @apiSuccess {String} description Project description
* @apiSuccess {String} deleted_at Project deletion date or null
* @apiSuccess {String} created_at Project creation date
* @apiSuccess {String} updated_at Project update date
* @apiSuccess {Boolean} important Project importance
* @apiSuccess {String} source Project source
* @apiSuccess {Integer} default_priority_id Default priority ID or null
* @apiSuccess {Object[]} phases List of project phases
* @apiSuccess {Integer} phases.id Phase ID
* @apiSuccess {String} phases.name Phase name
* @apiSuccess {String} phases.description Phase description
* @apiSuccess {Integer} phases.tasks_count Number of tasks in the phase
* @apiSuccess {String} phases.created_at Phase creation date
* @apiSuccess {String} phases.updated_at Phase update date
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "id": 1,
* "company_id": 1,
* "name": "Dolores voluptates.",
* "description": "Deleniti maxime fugit nesciunt. Ut maiores deleniti tempora vel. Nisi aut doloremque accusantium tempore aut.",
* "deleted_at": null,
* "created_at": "2023-10-26T10:26:17.000000Z",
* "updated_at": "2023-10-26T10:26:17.000000Z",
* "important": 1,
* "source": "internal",
* "default_priority_id": null,
* "phases": []
* }
*/
/**
* @apiDefine UserObject
* @apiSuccess {Integer} id User ID.
* @apiSuccess {String} full_name Full name of the user.
* @apiSuccess {String} email Email address of the user.
* @apiSuccess {String} url URL associated with the user (if any).
* @apiSuccess {Integer} company_id ID of the company the user belongs to.
* @apiSuccess {String} avatar URL of the user's avatar (if any).
* @apiSuccess {Boolean} screenshots_state State of screenshot capturing.
* @apiSuccess {Boolean} manual_time Indicates whether manual time tracking is enabled.
* @apiSuccess {Integer} computer_time_popup Time in seconds for the computer time popup.
* @apiSuccess {Boolean} blur_screenshots Indicates if screenshots are blurred.
* @apiSuccess {Boolean} web_and_app_monitoring Indicates if web and app monitoring is enabled.
* @apiSuccess {Integer} screenshots_interval Interval for capturing screenshots in minutes.
* @apiSuccess {Boolean} active Indicates if the user account is active.
* @apiSuccess {String} created_at Timestamp of when the user was created.
* @apiSuccess {String} updated_at Timestamp of when the user was last updated.
* @apiSuccess {String} deleted_at Timestamp of when the user was deleted (if applicable).
* @apiSuccess {String} timezone User's timezone.
* @apiSuccess {Boolean} important Indicates if the user is marked as important.
* @apiSuccess {Boolean} change_password Indicates if the user is required to change their password.
* @apiSuccess {Integer} role_id Role ID associated with the user.
* @apiSuccess {String} user_language Language preference of the user.
* @apiSuccess {String} type User type (e.g., employee, admin).
* @apiSuccess {Boolean} invitation_sent Indicates if an invitation has been sent to the user.
* @apiSuccess {Boolean} client_installed Indicates if the tracking client is installed.
* @apiSuccess {Boolean} permanent_screenshots Indicates if permanent screenshots are enabled.
* @apiSuccess {String} last_activity Timestamp of the user's last activity.
* @apiSuccess {Boolean} screenshots_state_locked Indicates if the screenshot state is locked.
* @apiSuccess {Boolean} online Indicates if the user is currently online.
* @apiSuccess {Boolean} can_view_team_tab Indicates if the user can view the team tab.
* @apiSuccess {Boolean} can_create_task Indicates if the user can create tasks.
*
* @apiSuccessExample {json} Response Example:
* HTTP/1.1 200 OK
* {
* "id": 1,
* "full_name": "Admin",
* "email": "admin@cattr.app",
* "url": "",
* "company_id": 1,
* "avatar": "",
* "screenshots_state": 1,
* "manual_time": 0,
* "computer_time_popup": 300,
* "blur_screenshots": false,
* "web_and_app_monitoring": true,
* "screenshots_interval": 5,
* "active": 1,
* "deleted_at": null,
* "created_at": "2023-10-26T10:26:17.000000Z",
* "updated_at": "2024-08-20T09:22:02.000000Z",
* "timezone": null,
* "important": 0,
* "change_password": 0,
* "role_id": 0,
* "user_language": "en",
* "type": "employee",
* "invitation_sent": false,
* "nonce": 0,
* "client_installed": 0,
* "permanent_screenshots": 0,
* "last_activity": "2024-08-20 09:22:02",
* "screenshots_state_locked": false,
* "online": false,
* "can_view_team_tab": true,
* "can_create_task": true
* }
*/
/**
* @apiDefine UserParams
*/
/**
* @apiDefine User
* @apiSuccess {String} access_token Token
* @apiSuccess {String} token_type Token Type
* @apiSuccess {String} expires_in Token TTL (ISO 8601 Date)
* @apiSuccess {Object} user User Entity
* @apiSuccess {Integer} user.id ID of the user
* @apiSuccess {String} user.full_name Full name of the user
* @apiSuccess {String} user.email Email of the user
* @apiSuccess {String} [user.url] URL of the user (optional)
* @apiSuccess {Integer} user.company_id Company ID of the user
* @apiSuccess {String} [user.avatar] Avatar URL of the user (optional)
* @apiSuccess {Boolean} user.screenshots_active Indicates if screenshots are active
* @apiSuccess {Boolean} user.manual_time Indicates if manual time tracking is allowed
* @apiSuccess {Integer} user.computer_time_popup Time interval for computer time popup
* @apiSuccess {Boolean} user.blur_screenshots Indicates if screenshots are blurred
* @apiSuccess {Boolean} user.web_and_app_monitoring Indicates if web and app monitoring is enabled
* @apiSuccess {Integer} user.screenshots_interval Interval for taking screenshots
* @apiSuccess {Boolean} user.active Indicates if the user is active
* @apiSuccess {String} [user.deleted_at] Deletion timestamp (if applicable, otherwise null)
* @apiSuccess {String} user.created_at Creation timestamp
* @apiSuccess {String} user.updated_at Last update timestamp
* @apiSuccess {String} [user.timezone] Timezone of the user (optional)
* @apiSuccess {Boolean} user.important Indicates if the user is marked as important
* @apiSuccess {Boolean} user.change_password Indicates if the user needs to change password
* @apiSuccess {Integer} user.role_id Role ID of the user
* @apiSuccess {String} user.user_language Language of the user
* @apiSuccess {String} user.type Type of the user (e.g., "employee")
* @apiSuccess {Boolean} user.invitation_sent Indicates if invitation is sent to the user
* @apiSuccess {Integer} user.nonce Nonce value of the user
* @apiSuccess {Boolean} user.client_installed Indicates if client is installed
* @apiSuccess {Boolean} user.permanent_screenshots Indicates if screenshots are permanent
* @apiSuccess {String} user.last_activity Last activity timestamp of the user
* @apiSuccess {Boolean} user.online Indicates if the user is online
* @apiSuccess {Boolean} user.can_view_team_tab Indicates if the user can view team tab
* @apiSuccess {Boolean} user.can_create_task Indicates if the user can create tasks
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "access_token": "51|d6HvWGk6zY1aqqRms5pkp6Pb6leBs7zaW4IAWGvQ5d00b8be",
* "token_type": "bearer",
* "expires_in": "2024-07-12T11:59:31+00:00",
* "user": {
* "id": 1,
* "full_name": "Admin",
* "email": "johndoe@example.com",
* "url": "",
* "company_id": 1,
* "avatar": "",
* "screenshots_active": 1,
* "manual_time": 0,
* "computer_time_popup": 300,
* "blur_screenshots": false,
* "web_and_app_monitoring": true,
* "screenshots_interval": 5,
* "active": 1,
* "deleted_at": null,
* "created_at": "2023-10-26T10:26:17.000000Z",
* "updated_at": "2024-02-15T19:06:42.000000Z",
* "timezone": null,
* "important": 0,
* "change_password": 0,
* "role_id": 0,
* "user_language": "en",
* "type": "employee",
* "invitation_sent": false,
* "nonce": 0,
* "client_installed": 0,
* "permanent_screenshots": 0,
* "last_activity": "2023-10-26 10:26:17",
* "online": false,
* "can_view_team_tab": true,
* "can_create_task": true
* }
* }
*/
/**
* @apiDefine ScreenshotObject
* @apiSuccess {Integer} id ID of the screenshot
* @apiSuccess {Integer} time_interval_id Time interval ID to which the screenshot belongs
* @apiSuccess {String} path Path to the screenshot
* @apiSuccess {String} created_at Timestamp when the screenshot was created
* @apiSuccess {String} updated_at Timestamp when the screenshot was last updated
* @apiSuccess {String} [deleted_at] Timestamp when the screenshot was deleted (if applicable)
* @apiSuccess {String} [thumbnail_path] Path to the thumbnail of the screenshot (if applicable)
* @apiSuccess {Boolean} important Indicates if the screenshot is marked as important
* @apiSuccess {Boolean} is_removed Indicates if the screenshot is removed
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "data": {
* "id": 1,
* "time_interval_id": 1,
* "path": "uploads\/screenshots\/1_1_1.png",
* "created_at": "2020-01-23T09:42:26+00:00",
* "updated_at": "2020-01-23T09:42:26+00:00",
* "deleted_at": null,
* "thumbnail_path": null,
* "important": false,
* "is_removed": false
* }
* }
*/
/**
* @apiDefine ScreenshotParams
*/
/**
* @apiDefine TimeIntervalObject
* @apiSuccess {Integer} id ID of the time interval
* @apiSuccess {Integer} task_id ID of the task
* @apiSuccess {String} start_at Start timestamp of the time interval
* @apiSuccess {String} end_at End timestamp of the time interval
* @apiSuccess {String} created_at Creation timestamp
* @apiSuccess {String} updated_at Last update timestamp
* @apiSuccess {String} [deleted_at] Deletion timestamp (if applicable)
* @apiSuccess {String} user_id The ID of the user
* @apiSuccess {Boolean} is_manual Indicates whether the time was logged manually (true) or automatically
* @apiSuccess {Integer} activity_fill Activity fill percentage
* @apiSuccess {Integer} mouse_fill Mouse activity fill percentage
* @apiSuccess {Integer} keyboard_fill Keyboard activity fill percentage
* @apiSuccess {Integer} location Additional location information, if available
* @apiSuccess {Integer} screenshot_id The ID of the screenshot associated with this interval
* @apiSuccess {Integer} has_screenshot Indicates if there is a screenshot for this interval
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* {
* "id": 1,
* "task_id": 1,
* "start_at": "2023-10-26 10:21:17",
* "end_at": "2023-10-26 10:26:17",
* "created_at": "2023-10-26T10:26:17.000000Z",
* "updated_at": "2023-10-26T10:26:17.000000Z",
* "deleted_at": null,
* "user_id": 2,
* "is_manual": false,
* "activity_fill": 60,
* "mouse_fill": 47,
* "keyboard_fill": 13,
* "location": null,
* "screenshot_id": null,
* "has_screenshot": true
* },...
* }
*/
/**
* @apiDefine TimeIntervalParams
*/
/**
* @apiDefine InvitationObject
*/
/**
* @apiDefine PriorityObject
* @apiSuccess {Boolean} status Indicates if the request was successful
* @apiSuccess {Boolean} success Indicates if the request was successful
* @apiSuccess {Object} data Response object
* @apiSuccess {Integer} data.id Priority ID
* @apiSuccess {String} data.name Priority name
* @apiSuccess {String} data.color Priority color (can be null)
* @apiSuccess {String} data.created_at Creation timestamp
* @apiSuccess {String} data.updated_at Update timestamp
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "status": 200,
* "success": true,
* "data": {
* "id": 1,
* "name": "Normal",
* "created_at": "2023-10-26T10:26:17.000000Z",
* "updated_at": "2024-07-12T17:57:40.000000Z",
* "color": null
* }
* }
*/
/**
* @apiDefine ProjectParams
* @apiParam {Object} [filters] Filters to apply to the project list.
* @apiParam {Object} [filters.id] Filter by project ID.
* @apiParam {String} [filters.name] Filter by project name.
* @apiParam {String} [filters.description] Filter by project description.
* @apiParam {String} [filters.created_at] Filter by project creation date.
* @apiParam {String} [filters.updated_at] Filter by project update date.
* @apiParam {Integer} [page=1] Page number for pagination.
* @apiParam {Integer} [perPage=15] Number of items per page.
*/
/**
* @apiDefine StatusObject
* @apiSuccess {Number} id The ID of the status.
* @apiSuccess {String} name The name of the status.
* @apiSuccess {Boolean} active Indicates if the status is active.
* @apiSuccess {String} color The color of the status (in HEX).
* @apiSuccess {Number} order The sort order of the status.
* @apiSuccess {String} created_at The creation timestamp.
* @apiSuccess {String} updated_at The last update timestamp.
*
* @apiSuccessExample {json} Success Response Example:
* HTTP/1.1 200 OK
* {
* "id": 1,
* "name": "Normal",
* "active": true,
* "color": "#363334",
* "order": 1,
* "created_at": "2024-08-26T10:47:30.000000Z",
* "updated_at": "2024-08-26T10:48:35.000000Z"
* }
*/
/**
* @apiDefine ParamTimeInterval
*
* @apiParam {String} start_at The start datetime for the interval
* @apiParam {String} end_at The end datetime for the interval
* @apiParam {Integer} user_id The ID of the user
* @apiParamExample {json} Request Example
* {
* "start_at": "2024-08-16T12:32:11.000000Z",
* "end_at": "2024-08-17T12:32:11.000000Z",
* "user_id": 1
* }
*/
/**
* @apiDefine AuthHeader
* @apiHeader {String} Authorization Token for user auth
* @apiHeaderExample {json} Authorization Header Example
* {
* "Authorization": "bearer 16184cf3b2510464a53c0e573c75740540fe..."
* }
*/
/**
* @apiDefine 400Error
* @apiError (Error 4xx) {String} message Message from server
* @apiError (Error 4xx) {Boolean} success Indicates erroneous response when `FALSE`
* @apiError (Error 4xx) {String} error_type Error type
*
* @apiVersion 1.0.0
*/
/**
* @apiDefine UnauthorizedError
* @apiErrorExample {json} Unauthorized
* HTTP/1.1 401 Unauthorized
* {
* "message": "Not authorized",
* "error_type": "authorization.unauthorized"
* }
*
* @apiVersion 1.0.0
*/
/**
* @apiDefine ForbiddenError
* @apiErrorExample {json} Forbidden
* HTTP/1.1 403 Forbidden
* {
* "message": "Access denied to this item",
* "error_type": "authorization.forbidden"
* }
*
* @apiVersion 1.0.0
*/
/**
* @apiDefine TotalSuccess
* @apiSuccess {Boolean} success Indicates if the request was successful
* @apiSuccess {Object} data The response data
* @apiSuccess {Integer} data.total The total count of items
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "status": 200,
* "success": true,
* "data": {
* "total": 0
* }
* }
*/
/**
* @throws Exception
*/
public function _index(CattrFormRequest $request): JsonResponse
{
$requestData = Filter::process(Filter::getRequestFilterName(), $request->validated());
$itemsQuery = $this->getQuery($requestData);
CatEvent::dispatch(Filter::getBeforeActionEventName(), $requestData);
$items = $request->header('X-Paginate', true) !== 'false' ? $itemsQuery->paginate() : $itemsQuery->get();
Filter::process(
Filter::getActionFilterName(),
$items,
);
CatEvent::dispatch(Filter::getAfterActionEventName(), [$items, $requestData]);
return responder()->success($items)->respond();
}
/**
* @throws Exception
*/
protected function getQuery(array $filter = []): Builder
{
$model = static::MODEL;
$model = new $model;
$query = new Builder($model::getQuery());
$query->setModel($model);
$modelScopes = $model->getGlobalScopes();
foreach ($modelScopes as $key => $value) {
$query->withGlobalScope($key, $value);
}
foreach (Filter::process(Filter::getQueryAdditionalRelationsFilterName(), []) as $with) {
$query->with($with);
}
foreach (Filter::process(Filter::getQueryAdditionalRelationsSumFilterName(), []) as $withSum) {
$query->withSum(...$withSum);
}
QueryHelper::apply($query, $model, $filter);
return Filter::process(
Filter::getQueryFilterName(),
$query
);
}
/**
* @throws Throwable
*/
public function _create(CattrFormRequest $request): JsonResponse
{
$requestData = Filter::process(Filter::getRequestFilterName(), $request->validated());
CatEvent::dispatch(Filter::getBeforeActionEventName(), [$requestData]);
/** @var Model $cls */
$cls = static::MODEL;
$item = Filter::process(
Filter::getActionFilterName(),
$cls::create($requestData),
);
CatEvent::dispatch(Filter::getAfterActionEventName(), [$item, $requestData]);
return responder()->success($item)->respond();
}
/**
* @throws Throwable
*/
public function _edit(CattrFormRequest $request): JsonResponse
{
$requestData = Filter::process(Filter::getRequestFilterName(), $request->validated());
throw_unless(is_int($request->get('id')), ValidationException::withMessages(['Invalid id']));
$itemsQuery = $this->getQuery();
/** @var Model $item */
$item = $itemsQuery->get()->collect()->firstWhere('id', $request->get('id'));
if (!$item) {
/** @var Model $cls */
$cls = static::MODEL;
throw_if($cls::find($request->get('id'))?->count(), new AccessDeniedHttpException);
throw new NotFoundHttpException;
}
CatEvent::dispatch(Filter::getBeforeActionEventName(), [$item, $requestData]);
$item = Filter::process(Filter::getActionFilterName(), $item->fill($requestData));
$item->save();
CatEvent::dispatch(Filter::getAfterActionEventName(), [$item, $requestData]);
return responder()->success($item)->respond();
}
/**
* @throws Throwable
*/
public function _destroy(CattrFormRequest $request): JsonResponse
{
$requestId = Filter::process(Filter::getRequestFilterName(), $request->validated('id'));
throw_unless(is_int($requestId), ValidationException::withMessages(['Invalid id']));
$itemsQuery = $this->getQuery(['where' => ['id' => $requestId]]);
/** @var Model $item */
$item = $itemsQuery->first();
if (!$item) {
/** @var Model $cls */
$cls = static::MODEL;
throw_if($cls::find($requestId)?->count(), new AccessDeniedHttpException);
throw new NotFoundHttpException;
}
CatEvent::dispatch(Filter::getBeforeActionEventName(), $requestId);
CatEvent::dispatch(
Filter::getAfterActionEventName(),
tap(
Filter::process(Filter::getActionFilterName(), $item),
static fn ($item) => $item->delete(),
)
);
return responder()->success()->respond(204);
}
/**
* @throws Exception
*/
protected function _count(CattrFormRequest $request): JsonResponse
{
$requestData = Filter::process(Filter::getRequestFilterName(), $request->validated());
CatEvent::dispatch(Filter::getBeforeActionEventName(), $requestData);
$itemsQuery = $this->getQuery($requestData);
$count = Filter::process(Filter::getActionFilterName(), $itemsQuery->count());
CatEvent::dispatch(Filter::getAfterActionEventName(), [$count, $requestData]);
return responder()->success(['total' => $count])->respond();
}
/**
* @throws Throwable
*/
protected function _show(CattrFormRequest $request): JsonResponse
{
$requestData = Filter::process(Filter::getRequestFilterName(), $request->validated());
$itemId = (int)$requestData['id'];
throw_unless($itemId, ValidationException::withMessages(['Invalid id']));
$filters = [
'where' => ['id' => $itemId]
];
if (!empty($requestData['with'])) {
$filters['with'] = $requestData['with'];
}
if (!empty($requestData['withSum'])) {
$filters['withSum'] = $requestData['withSum'];
}
CatEvent::dispatch(Filter::getBeforeActionEventName(), $filters);
$itemsQuery = $this->getQuery($filters ?: []);
$item = Filter::process(Filter::getActionFilterName(), $itemsQuery->first());
throw_unless($item, new NotFoundHttpException);
CatEvent::dispatch(Filter::getAfterActionEventName(), [$item, $filters]);
return responder()->success($item)->respond();
}
}

View File

@@ -0,0 +1,220 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Requests\Priority\CreatePriorityRequest;
use App\Http\Requests\Priority\DestroyPriorityRequest;
use App\Http\Requests\Priority\ListPriorityRequest;
use App\Http\Requests\Priority\ShowPriorityRequest;
use App\Http\Requests\Priority\UpdatePriorityRequest;
use App\Models\Priority;
use Exception;
use Illuminate\Http\JsonResponse;
use Throwable;
class PriorityController extends ItemController
{
protected const MODEL = Priority::class;
/**
* @throws Throwable
* @api {post} /priorities/show Show
* @apiDescription Show priority.
*
* @apiVersion 4.0.0
* @apiName Show Priority
* @apiGroup Priority
*
* @apiUse AuthHeader
*
* @apiParam {Integer} id Priority ID
*
* @apiParamExample {json} Request Example
* {
* "id": 1
* }
*
* @apiSuccess {Object} res Priority
*
* @apiUse PriorityObject
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "res": {
* "id": 1
* "name": "Normal",
* "color": null
* }
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*
*/
public function show(ShowPriorityRequest $request): JsonResponse
{
return $this->_show($request);
}
/**
* @throws Exception
* @api {get} /priorities/list List
* @apiDescription Get list of priorities.
*
* @apiVersion 4.0.0
* @apiName Priority List
* @apiGroup Priority
*
* @apiUse AuthHeader
*
* @apiSuccess {Object} res Priority
*
* @apiUse PriorityObject
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "res": [{
* "id": 1
* "name": "Normal",
* "color": null
* }]
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*
*/
public function index(ListPriorityRequest $request): JsonResponse
{
return $this->_index($request);
}
/**
* @param CreatePriorityRequest $request
* @return JsonResponse
* @throws Throwable
* @api {post} /priorities/create Create
* @apiDescription Creates priority
*
* @apiVersion 4.0.0
* @apiName Create Priority
* @apiGroup Priority
*
* @apiUse AuthHeader
*
* @apiParam {String} name Priority name
* @apiParam {String} color Priority color
*
* @apiParamExample {json} Request Example
* {
* "name": "Normal",
* "color": null
* }
*
* @apiSuccess {Object} res Priority
*
* @apiUse PriorityObject
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
public function create(CreatePriorityRequest $request): JsonResponse
{
return $this->_create($request);
}
/**
* @throws Throwable
* @api {post} /priorities/edit Edit
* @apiDescription Edit Priority
*
* @apiVersion 4.0.0
* @apiName Edit
* @apiGroup Priority
*
* @apiUse AuthHeader
*
* @apiParam {Integer} id ID
* @apiParam {String} name Priority name
* @apiParam {String} color Priority color
*
* @apiParamExample {json} Simple Request Example
* {
* "id": 1,
* "name": "Normal",
* "color": null
* }
*
* @apiSuccess {Object} res Priority
*
* @apiUse PriorityObject
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse UnauthorizedError
* @apiUse ItemNotFoundError
*/
public function edit(UpdatePriorityRequest $request): JsonResponse
{
return $this->_edit($request);
}
/**
* @throws Throwable
* @api {post} /priorities/remove Destroy
* @apiDescription Destroy User
*
* @apiVersion 4.0.0
* @apiName Destroy Priority
* @apiGroup Priority
*
* @apiUse AuthHeader
*
* @apiParam {Integer} id ID of the target priority
*
* @apiParamExample {json} Request Example
* {
* "id": 1
* }
*
* @apiSuccess {String} message Destroy status
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "message": "Item has been removed"
* }
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse ForbiddenError
* @apiUse UnauthorizedError
*/
public function destroy(DestroyPriorityRequest $request): JsonResponse
{
return $this->_destroy($request);
}
/**
* @api {get} /api/priorities/count Count Priorities
* @apiDescription Returns the total count of priorities
*
* @apiVersion 4.0.0
* @apiName CountPriorities
* @apiGroup Priorities
*
* @apiUse TotalSuccess
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
/**
* @param ListPriorityRequest $request
* @return JsonResponse
* @throws Exception
*/
public function count(ListPriorityRequest $request): JsonResponse
{
return $this->_count($request);
}
}

View File

@@ -0,0 +1,521 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Requests\Project\CreateProjectRequest;
use App\Http\Requests\Project\EditProjectRequest;
use App\Http\Requests\Project\DestroyProjectRequest;
use App\Http\Requests\Project\GanttDataRequest;
use App\Http\Requests\Project\ListProjectRequest;
use App\Http\Requests\Project\PhasesRequest;
use App\Http\Requests\Project\ShowProjectRequest;
use CatEvent;
use Filter;
use App\Models\Project;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Http\JsonResponse;
use DB;
use Staudenmeir\LaravelAdjacencyList\Eloquent\Builder as AdjacencyListBuilder;
use Throwable;
class ProjectController extends ItemController
{
protected const MODEL = Project::class;
/**
* @api {get, post} /projects/list List
* @apiDescription Get list of Projects
*
* @apiVersion 4.0.0
* @apiName GetProjectList
* @apiGroup Project
*
* @apiUse AuthHeader
*
* @apiPermission projects_list
* @apiPermission projects_full_access
*
* @apiUse ProjectParams
*
* @apiParamExample {json} Simple Request Example
* {
* "id": [">", 1],
* "user_id": ["=", [1,2,3]],
* "name": ["like", "%lorem%"],
* "description": ["like", "%lorem%"],
* "created_at": [">", "2019-01-01 00:00:00"],
* "updated_at": ["<", "2019-01-01 00:00:00"]
* }
*
* @apiUse ProjectObject
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* [
* {
* "id": 1,
* "company_id": 1,
* "name": "Dolores voluptates.",
* "description": "Deleniti maxime fugit nesciunt. Ut maiores deleniti tempora vel. Nisi aut doloremque accusantium tempore aut.",
* "deleted_at": null,
* "created_at": "2023-10-26T10:26:17.000000Z",
* "updated_at": "2023-10-26T10:26:17.000000Z",
* "important": 1,
* "source": "internal",
* "default_priority_id": null
* },
* {
* "id": 2,
* "company_id": 5,
* "name": "Et veniam velit tempore.",
* "description": "Consequatur nulla distinctio reprehenderit rerum omnis debitis. Fugit illum ratione quia harum. Optio porro consequatur enim esse.",
* "deleted_at": null,
* "created_at": "2023-10-26T10:26:42.000000Z",
* "updated_at": "2023-10-26T10:26:42.000000Z",
* "important": 1,
* "source": "internal",
* "default_priority_id": null
* }
* ]
*
* @apiUse 400Error
* @apiUse UnauthorizedError
* @apiUse ForbiddenError
*/
/**
* @param ListProjectRequest $request
* @return JsonResponse
* @throws Exception
*/
public function index(ListProjectRequest $request): JsonResponse
{
return $this->_index($request);
}
/**
* @api {get} /projects/gantt-data Gantt Data
* @apiDescription Получение данных для диаграммы Ганта по проекту
*
* @apiVersion 4.0.0
* @apiName GetGanttData
* @apiGroup Project
*
* @apiUse AuthHeader
* @apiUse ProjectIDParam
*
* @apiSuccess {Integer} id Project ID
* @apiSuccess {Integer} company_id Company ID
* @apiSuccess {String} name Project name
* @apiSuccess {String} description Project description
* @apiSuccess {String} deleted_at Deletion date (null if not deleted)
* @apiSuccess {String} created_at Creation date
* @apiSuccess {String} updated_at Update date
* @apiSuccess {Integer} important Project importance (1 - important, 0 - not important)
* @apiSuccess {String} source Project source (internal/external)
* @apiSuccess {Integer} default_priority_id Default priority ID (null if not set)
* @apiSuccess {Object[]} tasks_relations Task relations
* @apiSuccess {Integer} tasks_relations.parent_id Parent task ID
* @apiSuccess {Integer} tasks_relations.child_id Child task ID
* @apiSuccess {Object[]} tasks List of tasks
* @apiSuccess {Object[]} phases List of project phases
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "id": 1,
* "company_id": 1,
* "name": "Dolores voluptates.",
* "description": "Deleniti maxime fugit nesciunt. Ut maiores deleniti tempora vel. Nisi aut doloremque accusantium tempore aut.",
* "deleted_at": null,
* "created_at": "2023-10-26T10:26:17.000000Z",
* "updated_at": "2023-10-26T10:26:17.000000Z",
* "important": 1,
* "source": "internal",
* "default_priority_id": null,
* "tasks_relations": [
* {
* "parent_id": 5,
* "child_id": 1
* }
* ],
* "tasks": [],
* "phases": []
*
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
/**
* @param GanttDataRequest $request
* @return JsonResponse
* @throws Exception
* @throws Throwable
*/
public function ganttData(GanttDataRequest $request): JsonResponse
{
Filter::listen(Filter::getQueryFilterName(), static fn(Builder $query) => $query->with([
'tasks' => fn(HasMany $queue) => $queue
->orderBy('start_date')
->select([
'id',
'task_name',
'priority_id',
'status_id',
'estimate',
'start_date',
'due_date',
'project_phase_id',
'project_id'
])->with(['status', 'priority'])
->withSum(['workers as total_spent_time'], 'duration')
->withSum(['workers as total_offset'], 'offset')
->withCasts(['start_date' => 'string', 'due_date' => 'string'])
->whereNotNull('start_date')->whereNotNull('due_date'),
'phases' => fn(HasMany $queue) => $queue
->select(['id', 'name', 'project_id'])
->withMin([
'tasks as start_date' => fn(AdjacencyListBuilder $q) => $q
->whereNotNull('start_date')
->whereNotNull('due_date')
], 'start_date')
->withMax([
'tasks as due_date' => fn(AdjacencyListBuilder $q) => $q
->whereNotNull('start_date')
->whereNotNull('due_date')
], 'due_date'),
]));
Filter::listen(Filter::getActionFilterName(), static function (Project $item) {
$item->append('tasks_relations');
return $item;
});
return $this->_show($request);
}
/**
* @api {get} /projects/phases Project Phases
* @apiDescription Retrieve project phases along with the number of tasks in each phase.
*
* @apiVersion 4.0.0
* @apiName GetProjectPhases
* @apiGroup Project
*
* @apiUse AuthHeader
* @apiUse ProjectIDParam
* @apiUse ProjectObject
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
/**
* @param PhasesRequest $request
* @return JsonResponse
* @throws Exception
* @throws Throwable
*/
public function phases(PhasesRequest $request): JsonResponse
{
Filter::listen(
Filter::getQueryFilterName(),
static fn(Builder $query) => $query
->with([
'phases'=> fn(HasMany $q) => $q->withCount('tasks')
])
);
return $this->_show($request);
}
/**
* @throws Throwable
* @api {get} /projects/show Project Show
* @apiDescription Retrieve project show along with the number of tasks in each phase.
*
* @apiVersion 4.0.0
* @apiName GetProjectShow
* @apiGroup Project
*
* @apiUse AuthHeader
* @apiUse ProjectIDParam
* @apiUse ProjectObject
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
public function show(ShowProjectRequest $request): JsonResponse
{
Filter::listen(
Filter::getQueryFilterName(),
static fn(Builder $query) => $query
->with([
'phases'=> fn(HasMany $q) => $q->withCount('tasks')
])
);
return $this->_show($request);
}
/**
* @throws Throwable
* @api {post} /projects/create Create Project
* @apiDescription Creates a new project
*
* @apiVersion 4.0.0
* @apiName CreateProject
* @apiGroup Project
*
* @apiUse AuthHeader
*
* @apiParam {Boolean} important Project importance
* @apiParam {Integer} screenshots_state State of the screenshots
* @apiParam {String} name Project name
* @apiParam {String} description Project description
* @apiParam {Integer} default_priority_id Default priority ID
* @apiParam {Object[]} statuses Project statuses
* @apiParam {Integer} statuses.id Status ID
* @apiParam {String} statuses.color Status color
*
* @apiParamExample {json} Request Example
* {
* "important": true,
* "screenshots_state": 1,
* "name": "test",
* "description": "test",
* "default_priority_id": 2,
* "statuses": [
* {
* "id": 2,
* "color": null
* }
* ]
* }
*
* @apiSuccess {String} name Project name
* @apiSuccess {String} description Project description
* @apiSuccess {Boolean} important Project importance
* @apiSuccess {Integer} default_priority_id Default priority ID
* @apiSuccess {Integer} screenshots_state State of the screenshots
* @apiSuccess {String} created_at Creation timestamp
* @apiSuccess {String} updated_at Update timestamp
* @apiSuccess {Object[]} statuses Project statuses
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "name": "test",
* "description": "test",
* "important": 1,
* "default_priority_id": 2,
* "screenshots_state": 1,
* "updated_at": "2024-08-06T12:28:07.000000Z",
* "created_at": "2024-08-06T12:28:07.000000Z",
* "id": 161,
* "statuses": []
* }
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse UnauthorizedError
* @apiUse ForbiddenError
*/
public function create(CreateProjectRequest $request): JsonResponse
{
Filter::listen(Filter::getRequestFilterName(), static function ($requestData) {
if (isset($requestData['group']) && is_array($requestData['group'])) {
$requestData['group'] = $requestData['group']['id'];
}
return $requestData;
});
CatEvent::listen(Filter::getAfterActionEventName(), static function (Project $project, $requestData) use ($request) {
if ($request->has('statuses')) {
$statuses = [];
foreach ($request->get('statuses') as $status) {
$statuses[$status['id']] = ['color' => $status['color']];
}
$project->statuses()->sync($statuses);
}
if (isset($requestData['phases'])) {
$project->phases()->createMany($requestData['phases']);
}
});
Filter::listen(Filter::getActionFilterName(), static fn($data) => $data->load('statuses'));
return $this->_create($request);
}
/**
* @throws Throwable
* @api {post} /projects/edit Edit
* @apiDescription Edit Project
*
* @apiVersion 4.0.0
* @apiName EditProject
* @apiGroup Project
*
* @apiUse AuthHeader
*
* @apiPermission projects_edit
* @apiPermission projects_full_access
*
* @apiParamExample {json} Request Example
* {
* "id": 1,
* "name": "test",
* "description": "test"
* }
*
* @apiParam {String} id Project id
* @apiParam {String} name Project name
* @apiParam {String} description Project description
*
* @apiSuccess {Integer} id Project ID
* @apiSuccess {Integer} company_id Company ID
* @apiSuccess {String} name Project name
* @apiSuccess {String} description Project description
* @apiSuccess {String} deleted_at Deletion timestamp
* @apiSuccess {String} created_at Creation timestamp
* @apiSuccess {String} updated_at Update timestamp
* @apiSuccess {Boolean} important Project importance
* @apiSuccess {String} source Project source
* @apiSuccess {Integer} default_priority_id Default priority ID
* @apiSuccess {Integer} screenshots_state State of the screenshots
* @apiSuccess {Object[]} statuses Project statuses
*
* @apiSuccessExample {json} Response Example
* {
* "id": 1,
* "company_id": 1,
* "name": "test",
* "description": "test",
* "deleted_at": null,
* "created_at": "2023-10-26T10:26:17.000000Z",
* "updated_at": "2024-08-07T16:47:01.000000Z",
* "important": 1,
* "source": "internal",
* "default_priority_id": null,
* "screenshots_state": 1,
* "statuses": []
* }
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse UnauthorizedError
* @apiUse ItemNotFoundError
*/
public function edit(EditProjectRequest $request): JsonResponse
{
Filter::listen(Filter::getRequestFilterName(), static function ($requestData) {
if (isset($requestData['group']) && is_array($requestData['group'])) {
$requestData['group'] = $requestData['group']['id'];
}
return $requestData;
});
CatEvent::listen(Filter::getAfterActionEventName(), static function (Project $project, $requestData) use ($request) {
if ($request->has('statuses')) {
$statuses = [];
foreach ($request->get('statuses') as $status) {
$statuses[$status['id']] = ['color' => $status['color']];
}
$project->statuses()->sync($statuses);
}
if (isset($requestData['phases'])) {
$phases = collect($requestData['phases']);
$project->phases()
->whereNotIn('id', $phases->pluck('id')->filter())
->delete();
$project->phases()->upsert(
$phases->filter(fn (array $val) => isset($val['id']))->toArray(),
['id'],
['name']
);
$project->phases()->createMany($phases->filter(fn (array $val) => !isset($val['id'])));
}
});
Filter::listen(Filter::getActionFilterName(), static fn($data) => $data->load('statuses'));
return $this->_edit($request);
}
/**
* @throws Throwable
* @api {post} /projects/remove Destroy
* @apiDescription Destroy Project
*
* @apiVersion 4.0.0
* @apiName DestroyProject
* @apiGroup Project
*
* @apiUse AuthHeader
*
* @apiPermission projects_remove
* @apiPermission projects_full_access
*
* @apiParam {String} id Project id
*
* @apiParamExample {json} Request Example
* {
* "id": 1
* }
*
* @apiSuccess {String} message Destroy status
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 204 No Content
* {
* }
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse ForbiddenError
* @apiUse UnauthorizedError
* @apiUse ItemNotFoundError
*/
public function destroy(DestroyProjectRequest $request): JsonResponse
{
return $this->_destroy($request);
}
/**
* @throws Exception
* @api {get,post} /projects/count Count
* @apiDescription Count Projects
*
* @apiVersion 4.0.0
* @apiName Count
* @apiGroup Project
*
* @apiUse AuthHeader
*
* @apiPermission projects_count
* @apiPermission projects_full_access
*
* @apiSuccess {Integer} total Amount of projects that we have
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "total": 159
* }
*
* @apiUse 400Error
* @apiUse ForbiddenError
* @apiUse UnauthorizedError
*/
public function count(ListProjectRequest $request): JsonResponse
{
return $this->_count($request);
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace App\Http\Controllers\Api;
use App\Helpers\QueryHelper;
use App\Http\Requests\ProjectGroup\CreateProjectGroupRequest;
use App\Http\Requests\ProjectGroup\DestroyProjectGroupRequest;
use App\Http\Requests\ProjectGroup\EditProjectGroupRequest;
use App\Http\Requests\ProjectGroup\ListProjectGroupRequest;
use App\Http\Requests\ProjectGroup\ShowProjectGroupRequest;
use App\Models\ProjectGroup;
use CatEvent;
use Exception;
use Filter;
use Illuminate\Http\JsonResponse;
use Kalnoy\Nestedset\QueryBuilder;
use Throwable;
class ProjectGroupController extends ItemController
{
protected const MODEL = ProjectGroup::class;
/**
* Display a listing of the resource.
*
* @param ListProjectGroupRequest $request
* @return JsonResponse
* @throws Exception
*/
public function index(ListProjectGroupRequest $request): JsonResponse
{
$requestData = Filter::process(Filter::getRequestFilterName(), $request->validated());
$itemsQuery = $this->getQuery($requestData);
CatEvent::dispatch(Filter::getBeforeActionEventName(), $requestData);
$itemsQuery->withDepth()->withCount('projects')->defaultOrder();
$items = $request->header('X-Paginate', true) !== 'false' ? $itemsQuery->paginate($request->input('limit', null)) : $itemsQuery->get();
Filter::process(
Filter::getActionFilterName(),
$items,
);
CatEvent::dispatch(Filter::getAfterActionEventName(), [$items, $requestData]);
return responder()->success($items)->respond();
}
/**
* @throws Exception
*/
protected function getQuery(array $filter = []): QueryBuilder
{
$model = static::MODEL;
$model = new $model;
$query = new QueryBuilder($model::getQuery());
$query->setModel($model);
$modelScopes = $model->getGlobalScopes();
foreach ($modelScopes as $key => $value) {
$query->withGlobalScope($key, $value);
}
foreach (Filter::process(Filter::getQueryAdditionalRelationsFilterName(), []) as $with) {
$query->with($with);
}
QueryHelper::apply($query, $model, $filter);
return Filter::process(
Filter::getQueryFilterName(),
$query
);
}
/**
* Show the form for creating a new resource.
*
* @param CreateProjectGroupRequest $request
* @return JsonResponse
* @throws Throwable
*/
public function create(CreateProjectGroupRequest $request): JsonResponse
{
if ($parent_id = $request->safe(['parent_id'])['parent_id'] ?? null) {
CatEvent::listen(
Filter::getAfterActionEventName(),
static fn(ProjectGroup $group) => $group->parent()->associate($parent_id)->save(),
);
}
return $this->_create($request);
}
/**
* Display the specified resource.
*
* @param ShowProjectGroupRequest $request
* @return JsonResponse
* @throws Throwable
*/
public function show(ShowProjectGroupRequest $request): JsonResponse
{
return $this->_show($request);
}
/**
* Show the form for editing the specified resource.
*
* @param EditProjectGroupRequest $request
* @return JsonResponse
* @throws Throwable
*/
public function edit(EditProjectGroupRequest $request): JsonResponse
{
CatEvent::listen(
Filter::getAfterActionEventName(),
static function (ProjectGroup $group) use ($request) {
if ($parent_id = $request->input('parent_id', null)) {
$group->parent()->associate($parent_id)->save();
} else {
$group->saveAsRoot();
}
},
);
return $this->_edit($request);
}
/**
* Remove the specified resource from storage.
*
* @param DestroyProjectGroupRequest $request
* @return JsonResponse
* @throws Throwable
*/
public function destroy(DestroyProjectGroupRequest $request): JsonResponse
{
return $this->_destroy($request);
}
/**
* @throws Exception
*/
public function count(ListProjectGroupRequest $request): JsonResponse
{
return $this->_count($request);
}
}

View File

@@ -0,0 +1,237 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\ProjectMember\BulkEditProjectMemberRequest;
use App\Http\Requests\ProjectMember\ShowProjectMemberRequest;
use App\Services\ProjectMemberService;
use CatEvent;
use Filter;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
class ProjectMemberController extends Controller
{
/**
* @api {post} /project-members/list List Project Members
* @apiDescription Get list of project members
*
* @apiVersion 4.0.0
* @apiName ListProjectMembers
* @apiGroup ProjectMember
*
* @apiUse AuthHeader
*
* @apiParam {Integer} project_id ID of the project
*
* @apiParamExample {json} Request Example
* {
* "project_id": 1
* }
*
* @apiSuccess {Integer} id Project ID
* @apiSuccess {Object[]} users List of users
* @apiSuccess {Integer} users.id User ID
* @apiSuccess {String} users.full_name User full name
* @apiSuccess {String} users.email User email
* @apiSuccess {String} users.url User URL
* @apiSuccess {Integer} users.company_id Company ID
* @apiSuccess {String} users.avatar User avatar
* @apiSuccess {Integer} users.screenshots_active Screenshots active status
* @apiSuccess {Integer} users.manual_time Manual time status
* @apiSuccess {Integer} users.computer_time_popup Computer time popup interval
* @apiSuccess {Boolean} users.blur_screenshots Blur screenshots status
* @apiSuccess {Boolean} users.web_and_app_monitoring Web and app monitoring status
* @apiSuccess {Integer} users.screenshots_interval Screenshots interval
* @apiSuccess {Boolean} users.active User active status
* @apiSuccess {String} users.deleted_at Deletion timestamp
* @apiSuccess {String} users.created_at Creation timestamp
* @apiSuccess {String} users.updated_at Last update timestamp
* @apiSuccess {String} users.timezone User timezone
* @apiSuccess {Boolean} users.important User importance status
* @apiSuccess {Boolean} users.change_password Change password status
* @apiSuccess {Integer} users.role_id User role ID
* @apiSuccess {String} users.user_language User language
* @apiSuccess {String} users.type User type
* @apiSuccess {Boolean} users.invitation_sent Invitation sent status
* @apiSuccess {Integer} users.nonce User nonce
* @apiSuccess {Boolean} users.client_installed Client installed status
* @apiSuccess {Boolean} users.permanent_screenshots Permanent screenshots status
* @apiSuccess {String} users.last_activity Last activity timestamp
* @apiSuccess {Boolean} users.online Online status
* @apiSuccess {Boolean} users.can_view_team_tab Can view team tab status
* @apiSuccess {Boolean} users.can_create_task Can create task status
* @apiSuccess {Object} users.pivot Pivot data
* @apiSuccess {Integer} users.pivot.project_id Project ID in pivot
* @apiSuccess {Integer} users.pivot.user_id User ID in pivot
* @apiSuccess {Integer} users.pivot.role_id Role ID in pivot
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "id": 1,
* "users": [
* {
* "id": 1,
* "full_name": "Admin",
* "email": "admin@cattr.app",
* "url": "",
* "company_id": 1,
* "avatar": "",
* "screenshots_active": 1,
* "manual_time": 0,
* "computer_time_popup": 300,
* "blur_screenshots": false,
* "web_and_app_monitoring": true,
* "screenshots_interval": 5,
* "active": 1,
* "deleted_at": null,
* "created_at": "2023-10-26T10:26:17.000000Z",
* "updated_at": "2024-02-15T19:06:42.000000Z",
* "timezone": null,
* "important": 0,
* "change_password": 0,
* "role_id": 0,
* "user_language": "en",
* "type": "employee",
* "invitation_sent": false,
* "nonce": 0,
* "client_installed": 0,
* "permanent_screenshots": 0,
* "last_activity": "2023-10-26 10:26:17",
* "online": false,
* "can_view_team_tab": true,
* "can_create_task": true,
* "pivot": {
* "project_id": 1,
* "user_id": 1,
* "role_id": 2
* }
* },
* {
* "id": 2,
* "full_name": "Fabiola Mertz",
* "email": "projectManager@example.com",
* "url": "",
* "company_id": 1,
* "avatar": "",
* "screenshots_active": 1,
* "manual_time": 0,
* "computer_time_popup": 300,
* "blur_screenshots": false,
* "web_and_app_monitoring": true,
* "screenshots_interval": 5,
* "active": 1,
* "deleted_at": null,
* "created_at": "2023-10-26T10:26:17.000000Z",
* "updated_at": "2023-10-26T10:26:17.000000Z",
* "timezone": null,
* "important": 0,
* "change_password": 0,
* "role_id": 2,
* "user_language": "en",
* "type": "employee",
* "invitation_sent": false,
* "nonce": 0,
* "client_installed": 0,
* "permanent_screenshots": 0,
* "last_activity": "2023-10-26 09:44:17",
* "online": false,
* "can_view_team_tab": false,
* "can_create_task": false,
* "pivot": {
* "project_id": 1,
* "user_id": 2,
* "role_id": 2
* }
* },
* ...
* ]
* }
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
/**
*
* @param ShowProjectMemberRequest $request
* @return JsonResponse
* @throws Throwable
*/
public function list(ShowProjectMemberRequest $request): JsonResponse
{
$data = $request->validated();
throw_unless($data, ValidationException::withMessages([]));
$projectMembers = ProjectMemberService::getMembers($data['project_id']);
$projectMembers['users'] = $projectMembers['users'] ?? [];
return responder()->success($projectMembers)->respond();
}
/**
* @api {post} /api/project-members/bulk-edit Bulk Edit Project Members
* @apiDescription Edit roles of multiple project members
*
* @apiVersion 4.0.0
* @apiName BulkEditProjectMembers
* @apiGroup ProjectMember
*
* @apiUse AuthHeader
*
* @apiParam {Integer} project_id Project ID
* @apiParam {Object[]} user_roles Array of user roles
* @apiParam {Integer} user_roles.user_id User ID
* @apiParam {Integer} user_roles.role_id Role ID
*
* @apiParamExample {json} Request Example
* {
* "project_id": 1,
* "user_roles": [
* {
* "user_id": 1,
* "role_id": 2
* },
* {
* "user_id": 2,
* "role_id": 3
* }
* ]
* }
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 204 No Content
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse UnauthorizedError
*/
/**
* @param BulkEditProjectMemberRequest $request
* @return JsonResponse
*/
public function bulkEdit(BulkEditProjectMemberRequest $request): JsonResponse
{
$data = Filter::process(Filter::getRequestFilterName(), $request->validated());
$userRoles = [];
foreach ($data['user_roles'] as $value) {
$userRoles[$value['user_id']] = ['role_id' => $value['role_id']];
}
CatEvent::dispatch(Filter::getBeforeActionEventName(), [$data['project_id'], $userRoles]);
ProjectMemberService::syncMembers($data['project_id'], $userRoles);
CatEvent::dispatch(Filter::getAfterActionEventName(), [$data['project_id'], $userRoles]);
return responder()->success()->respond(204);
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace App\Http\Controllers\Api\Reports;
use App\Enums\DashboardSortBy;
use App\Enums\SortDirection;
use App\Helpers\ReportHelper;
use App\Http\Requests\Reports\DashboardRequest;
use App\Jobs\GenerateAndSendReport;
use App\Models\Project;
use App\Models\User;
use App\Reports\DashboardExport;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Http\JsonResponse;
use Settings;
use Throwable;
class DashboardController
{
/**
* @api {post} /report/dashboard Dashboard Data
* @apiDescription Retrieve dashboard data based on provided parameters.
*
* @apiVersion 4.0.0
* @apiName DashboardData
* @apiGroup Dashboard
*
* @apiUse AuthHeader
*
* @apiParam {String} start_at Start date-time in "Y-m-d H:i:s" format.
* @apiParam {String} end_at End date-time in "Y-m-d H:i:s" format.
* @apiParam {String} user_timezone User's timezone.
* @apiParam {Array} [users] Array of user IDs. If not provided, all users are considered.
* @apiParam {Array} [projects] Array of project IDs. If not provided, all projects are considered.
*
* @apiParamExample {json} Request Example
* {
* "start_at": "2006-05-31 16:15:09",
* "end_at": "2006-05-31 16:20:07",
* "user_timezone": "Asia/Omsk"
* }
*
* @apiSuccess {Object} data Dashboard data keyed by user ID.
* @apiSuccess {Array} data.7 Array of records for user with ID 7.
* @apiSuccess {String} data.7.start_at Start date-time of the record.
* @apiSuccess {Integer} data.7.activity_fill Activity fill percentage.
* @apiSuccess {Integer} data.7.mouse_fill Mouse activity fill percentage.
* @apiSuccess {Integer} data.7.keyboard_fill Keyboard activity fill percentage.
* @apiSuccess {String} data.7.end_at End date-time of the record.
* @apiSuccess {Boolean} data.7.is_manual Indicates if the record is manual.
* @apiSuccess {String} data.7.user_email User's email address.
* @apiSuccess {Integer} data.7.id Record ID.
* @apiSuccess {Integer} data.7.project_id Project ID.
* @apiSuccess {String} data.7.project_name Project name.
* @apiSuccess {Integer} data.7.task_id Task ID.
* @apiSuccess {String} data.7.task_name Task name.
* @apiSuccess {Integer} data.7.user_id User ID.
* @apiSuccess {String} data.7.full_name User's full name.
* @apiSuccess {Integer} data.7.duration Duration in seconds.
* @apiSuccess {Integer} data.7.from_midnight Time from midnight in seconds.
* @apiSuccess {Object} data.7.durationByDay Duration grouped by day.
* @apiSuccess {Integer} data.7.durationByDay.2018-05-31 Duration for the day 2018-05-31.
* @apiSuccess {Integer} data.7.durationByDay.2018-06-04 Duration for the day 2018-06-04.
* @apiSuccess {Integer} data.7.durationAtSelectedPeriod Duration for the selected period.
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "status": 200,
* "success": true,
* "data": {
* "7": [
* {
* "start_at": "2018-05-31 10:43:45",
* "activity_fill": 111,
* "mouse_fill": 64,
* "keyboard_fill": 47,
* "end_at": "2018-06-03 22:03:45",
* "is_manual": 0,
* "user_email": "projectManager1231@example.com",
* "id": 2109,
* "project_id": 159,
* "project_name": "Voluptas ab et ea.",
* "task_id": 54,
* "task_name": "Quo consequatur mollitia nam.",
* "user_id": 7,
* "full_name": "Dr. Adaline Toy",
* "duration": 300000,
* "from_midnight": 38625,
* "durationByDay": {
* "2018-05-31": 285375,
* "2018-06-04": 14625
* },
* "durationAtSelectedPeriod": 14625
* }
* ]
* }
* }
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse UnauthorizedError
*/
public function __invoke(DashboardRequest $request): JsonResponse
{
$companyTimezone = Settings::scope('core')->get('timezone', 'UTC');
return responder()->success(
DashboardExport::init(
$request->input('users') ?? User::all()->pluck('id')->toArray(),
$request->input('projects') ?? Project::all()->pluck('id')->toArray(),
Carbon::parse($request->input('start_at'))->setTimezone($companyTimezone),
Carbon::parse($request->input('end_at'))->setTimezone($companyTimezone),
$companyTimezone,
$request->input('user_timezone'),
)->collection()->all(),
)->respond();
}
/**
* @throws Throwable
* @api {post} /report/dashboard/download Download Dashboard Report
* @apiDescription Generate and download a dashboard report
*
* @apiVersion 4.0.0
* @apiName DownloadDashboardReport
* @apiGroup Report
*
* @apiUse AuthHeader
*
* @apiHeader {String} Accept Specifies the content type of the response. (Example: `text/csv`)
* @apiHeader {String} Authorization Bearer token for API access. (Example: `82|LosbyrFljFDJqUcqMNG6UveCgrclt6OzTrCWdnJBEZ1fee08e6`)
* @apiPermission report_generate
* @apiPermission report_full_access
*
* @apiParam {String} start_at Start date and time (ISO 8601 format)
* @apiParam {String} end_at End date and time (ISO 8601 format)
* @apiParam {String} user_timezone User's timezone
* @apiParam {String} sort_column Column to sort by
* @apiParam {String} sort_direction Direction to sort (asc/desc)
*
* @apiParamExample {json} Request Example
* {
* "start_at": "2024-08-06T18:00:00.000Z",
* "end_at": "2024-08-07T17:59:59.999Z",
* "user_timezone": "Asia/Omsk",
* "sort_column": "user",
* "sort_direction": "asc",
* }
*
* @apiSuccess {String} url URL to download the generated report
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "status": 200,
* "success": true,
* "data": {
* "url": "/storage/reports/f7ac500e-a741-47ee-9e61-1b62a341fb8d/Dashboard_Report.csv"
* }
* }
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse UnauthorizedError
* @apiUse ForbiddenError
* @apiUse ItemNotFoundError
*/
public function download(DashboardRequest $request): JsonResponse
{
$companyTimezone = Settings::scope('core')->get('timezone', 'UTC');
$job = new GenerateAndSendReport(
DashboardExport::init(
$request->input('users') ?? User::all()->pluck('id')->toArray(),
$request->input('projects') ?? Project::all()->pluck('id')->toArray(),
Carbon::parse($request->input('start_at'))->setTimezone($companyTimezone),
Carbon::parse($request->input('end_at'))->setTimezone($companyTimezone),
$companyTimezone,
$request->input('user_timezone'),
DashboardSortBy::tryFrom($request->input('sort_column')),
SortDirection::tryFrom($request->input('sort_direction')),
),
$request->user(),
ReportHelper::getReportFormat($request),
);
app(Dispatcher::class)->dispatchSync($job);
return responder()->success(['url' => $job->getPublicPath()])->respond();
}
}

View File

@@ -0,0 +1,159 @@
<?php
namespace App\Http\Controllers\Api\Reports;
use App\Helpers\ReportHelper;
use App\Http\Requests\Reports\PlannedTimeReportRequest;
use App\Jobs\GenerateAndSendReport;
use App\Models\Project;
use App\Reports\PlannedTimeReportExport;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Http\JsonResponse;
use Throwable;
class PlannedTimeReportController
{
/**
* @api {post} /report/planned-time Planned Time Report
* @apiDescription Generate a report on planned tasks and associated time for a given project
*
* @apiVersion 4.0.0
* @apiName PlannedTimeReport
* @apiGroup Report
*
* @apiUse AuthHeader
*
* @apiPermission report_generate
* @apiPermission report_full_access
*
* @apiParam {Integer} id Project ID
*
* @apiParamExample {json} Request Example
* {
* "id": 1
* }
*
* @apiSuccess {Object} reportData Report data object
* @apiSuccess {Integer} reportData.id Project ID
* @apiSuccess {Integer} reportData.company_id Company ID
* @apiSuccess {String} reportData.name Project name
* @apiSuccess {String} reportData.description Project description
* @apiSuccess {String} reportData.deleted_at Deleted timestamp (null if not deleted)
* @apiSuccess {String} reportData.created_at Creation timestamp
* @apiSuccess {String} reportData.updated_at Update timestamp
* @apiSuccess {Boolean} reportData.important Whether the project is marked as important
* @apiSuccess {String} reportData.source Source of the project (e.g., "internal")
* @apiSuccess {Integer} reportData.screenshots_state Screenshots state (1 if active)
* @apiSuccess {Integer} reportData.default_priority_id Default priority ID (null if not set)
* @apiSuccess {Integer} reportData.total_spent_time Total time spent on the project
* @apiSuccess {Object[]} reportData.tasks List of tasks under the project
* @apiSuccess {Integer} reportData.tasks.id Task ID
* @apiSuccess {String} reportData.tasks.task_name Task name
* @apiSuccess {String} reportData.tasks.due_date Task due date (null if not set)
* @apiSuccess {String} reportData.tasks.estimate Estimated time for the task (null if not set)
* @apiSuccess {Integer} reportData.tasks.project_id Project ID to which the task belongs
* @apiSuccess {Integer} reportData.tasks.total_spent_time Total time spent on the task
* @apiSuccess {Object[]} reportData.tasks.workers List of workers assigned to the task
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "reportData": [
* {
* "id": 2,
* "company_id": 5,
* "name": "Et veniam velit tempore.",
* "description": "Consequatur nulla distinctio reprehenderit rerum omnis debitis. Fugit illum ratione quia harum. Optio porro consequatur enim esse.",
* "deleted_at": null,
* "created_at": "2023-10-26T10:26:42.000000Z",
* "updated_at": "2023-10-26T10:26:42.000000Z",
* "important": 1,
* "source": "internal",
* "default_priority_id": null,
* "screenshots_state": 1,
* "total_spent_time": null,
* "tasks": [
* {
* "id": 11,
* "task_name": "Qui velit fugiat magni accusantium.",
* "due_date": null,
* "estimate": null,
* "project_id": 2,
* "total_spent_time": null,
* "workers": []
* },
* ...
* ]
* }
* ]
*
* }
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse UnauthorizedError
* @apiUse ForbiddenError
* @apiUse ItemNotFoundError
*/
public function __invoke(PlannedTimeReportRequest $request): JsonResponse
{
return responder()->success(
PlannedTimeReportExport::init(
$request->input('projects', Project::all()->pluck('id')->toArray()),
)->collection()->all(),
)->respond();
}
/**
* @api {post} /report/planned-time/download Download Planned Time Report
* @apiDescription Generate and download a report on planned time for specific projects.
*
* @apiVersion 4.0.0
* @apiName DownloadPlannedTimeReport
* @apiGroup Report
*
* @apiUse AuthHeader
*
* @apiHeader {String} Accept Specifies the content type of the response. (Example: `text/csv`)
* @apiHeader {String} Authorization Bearer token for API access. (Example: `82|LosbyrFljFDJqUcqMNG6UveCgrclt6OzTrCWdnJBEZ1fee08e6`)
* @apiPermission report_generate
* @apiPermission report_full_access
*
* @apiParam {Array} projects Array of project IDs to include in the report. If not provided, all projects will be included.
*
* @apiParamExample {json} Request Example
* {
* "projects": [2]
* }
*
* @apiSuccess {String} url URL to the generated report file.
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "url": "/storage/reports/0611766a-2807-4524-9add-2e8be33c3e58/PlannedTime_Report.csv"
* }
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse UnauthorizedError
* @apiUse ForbiddenError
*/
/**
* @throws Throwable
*/
public function download(PlannedTimeReportRequest $request): JsonResponse
{
$job = new GenerateAndSendReport(
PlannedTimeReportExport::init(
$request->input('projects', Project::all()->pluck('id')->toArray()),
),
$request->user(),
ReportHelper::getReportFormat($request),
);
app(Dispatcher::class)->dispatchSync($job);
return responder()->success(['url' => $job->getPublicPath()])->respond();
}
}

View File

@@ -0,0 +1,228 @@
<?php
namespace App\Http\Controllers\Api\Reports;
use App\Helpers\ReportHelper;
use App\Http\Requests\Reports\ProjectReportRequest;
use App\Jobs\GenerateAndSendReport;
use App\Models\User;
use App\Models\Project;
use App\Reports\ProjectReportExport;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Http\JsonResponse;
use Settings;
use Throwable;
class ProjectReportController
{
/**
* @api {post} /report/project Project Report
* @apiDescription Retrieve detailed project report data including user tasks and time intervals.
*
* @apiVersion 4.0.0
* @apiName ProjectReport
* @apiGroup Report
* @apiUse AuthHeader
* @apiPermission report_view
* @apiPermission report_full_access
*
* @apiParam {String} start_at The start date and time for the report period (ISO 8601 format).
* @apiParam {String} end_at The end date and time for the report period (ISO 8601 format).
* @apiParam {String} user_timezone The timezone of the user making the request.
*
* @apiParamExample {json} Request Example
* {
* "start_at": "2023-05-31 16:15:09",
* "end_at": "2023-11-30 16:20:07",
* "user_timezone": "Asia/Omsk"
* }
*
* @apiSuccess {Object[]} data List of projects.
* @apiSuccess {Integer} data.id Project ID.
* @apiSuccess {String} data.name Project name.
* @apiSuccess {Integer} data.time Total time spent on the project.
* @apiSuccess {Object[]} data.users List of users associated with the project.
* @apiSuccess {Integer} data.users.id User ID.
* @apiSuccess {String} data.users.full_name User's full name.
* @apiSuccess {String} data.users.email User's email address.
* @apiSuccess {Integer} data.users.time Total time spent by the user on the project.
* @apiSuccess {Object[]} data.users.tasks List of tasks associated with the user.
* @apiSuccess {Integer} data.users.tasks.id Task ID.
* @apiSuccess {String} data.users.tasks.task_name Task name.
* @apiSuccess {Integer} data.users.tasks.time Total time spent on the task.
* @apiSuccess {Object[]} data.users.tasks.intervals List of time intervals for the task.
* @apiSuccess {String} data.users.tasks.intervals.date Date of the interval.
* @apiSuccess {Integer} data.users.tasks.intervals.time Time spent in the interval.
* @apiSuccess {Object[]} data.users.tasks.intervals.items Detailed breakdown of intervals.
* @apiSuccess {String} data.users.tasks.intervals.items.start_at Start time of the interval.
* @apiSuccess {Integer} data.users.tasks.intervals.items.activity_fill Activity fill percentage during the interval.
* @apiSuccess {Integer} data.users.tasks.intervals.items.mouse_fill Mouse activity fill percentage.
* @apiSuccess {Integer} data.users.tasks.intervals.items.keyboard_fill Keyboard activity fill percentage.
* @apiSuccess {String} data.users.tasks.intervals.items.end_at End time of the interval.
* @apiSuccess {String} data.users.tasks.intervals.items.user_email User's email associated with the interval.
* @apiSuccess {Integer} data.users.tasks.intervals.items.id Interval ID.
* @apiSuccess {Integer} data.users.tasks.intervals.items.project_id Project ID associated with the interval.
* @apiSuccess {String} data.users.tasks.intervals.items.project_name Project name associated with the interval.
* @apiSuccess {Integer} data.users.tasks.intervals.items.task_id Task ID associated with the interval.
* @apiSuccess {String} data.users.tasks.intervals.items.task_name Task name associated with the interval.
* @apiSuccess {Integer} data.users.tasks.intervals.items.user_id User ID associated with the interval.
* @apiSuccess {String} data.users.tasks.intervals.items.full_name User's full name associated with the interval.
* @apiSuccess {Integer} data.users.tasks.intervals.items.hour Hour of the day for the interval.
* @apiSuccess {String} data.users.tasks.intervals.items.day Day of the interval.
* @apiSuccess {Integer} data.users.tasks.intervals.items.minute Minute of the hour for the interval.
* @apiSuccess {Integer} data.users.tasks.intervals.items.duration Duration of the interval.
* @apiSuccess {Object} data.users.tasks.intervals.items.durationByDay Duration of the interval by day.
* @apiSuccess {Integer} data.users.tasks.intervals.items.durationAtSelectedPeriod Duration at the selected period.
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "status": 200,
* "success": true,
* "data": [
* {
* "id": 159,
* "name": "Voluptas ab et ea.",
* "time": 3851703975,
* "users": [
* {
* "id": 7,
* "full_name": "Dr. Adaline Toy",
* "email": "projectManager1231@example.com",
* "time": 3851703975,
* "tasks": [
* {
* "id": 54,
* "task_name": "Quo consequatur mollitia nam.",
* "time": 550243425,
* "intervals": [
* {
* "date": "2006-05-29",
* "time": 43425,
* "items": [
* {
* "start_at": "2006-05-29 00:43:45",
* "activity_fill": 87,
* "mouse_fill": 81,
* "keyboard_fill": 6,
* "end_at": "2006-06-01 12:03:45",
* "user_email": "projectManager1231@example.com",
* "id": 3372,
* "project_id": 159,
* "project_name": "Voluptas ab et ea.",
* "task_id": 54,
* "task_name": "Quo consequatur mollitia nam.",
* "user_id": 7,
* "full_name": "Dr. Adaline Toy",
* "hour": 0,
* "day": "2006-05-29",
* "minute": 40,
* "duration": 300000,
* "durationByDay": {
* "2006-05-29": 256575,
* "2006-06-01": 43425
* },
* "durationAtSelectedPeriod": 43425
* }
* ]
* },
* // More intervals...
* ]
* }
* // More tasks...
* ]
* }
* // More users...
* ]
* }
* // More projects...
* ]
* }
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse UnauthorizedError
* @apiUse ForbiddenError
*/
public function __invoke(ProjectReportRequest $request): JsonResponse
{
$companyTimezone = Settings::scope('core')->get('timezone', 'UTC');
return responder()->success(
ProjectReportExport::init(
$request->input('users', User::all()->pluck('id')->toArray()),
$request->input('projects', Project::all()->pluck('id')->toArray()),
Carbon::parse($request->input('start_at'))->setTimezone($companyTimezone),
Carbon::parse($request->input('end_at'))->setTimezone($companyTimezone),
$companyTimezone
)->collection()->all(),
)->respond();
}
/**
* @api {post} /api/report/dashboard/download Download Dashboard Report
* @apiDescription Downloads a dashboard report in the specified file format.
*
* @apiVersion 4.0.0
* @apiName DownloadDashboardReport
* @apiGroup Reports
* @apiUse AuthHeader
* @apiHeader {String} Accept Accept mime type. Example: `text/csv`.
*
* @apiParam {String} start_at The start date and time for the report in ISO 8601 format.
* @apiParam {String} end_at The end date and time for the report in ISO 8601 format.
* @apiParam {String} user_timezone The timezone of the user. Example: `Asia/Omsk`.
* @apiParam {Array} users List of user IDs to include in the report.
* @apiParam {Array} projects List of project IDs to include in the report.
*
* @apiParamExample {json} Request Example:
* {
* "start_at": "2023-11-01T16:15:09Z",
* "end_at": "2023-11-30T23:59:07Z",
* "user_timezone": "Asia/Omsk",
* "users": [7],
* "projects": [159]
* }
*
* @apiSuccess {String} url The URL where the generated report can be downloaded.
*
* @apiSuccessExample {json} Success Response:
* HTTP/1.1 200 OK
* {
* "status": 200,
* "success": true,
* "data": {
* "url": "/storage/reports/1b11d8f9-c5a3-4fe5-86bd-ae6a3031352c/Dashboard_Report.csv"
* }
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
* @apiUse ForbiddenError
*/
/**
* @throws Throwable
*/
public function download(ProjectReportRequest $request): JsonResponse
{
$companyTimezone = Settings::scope('core')->get('timezone', 'UTC');
$job = new GenerateAndSendReport(
ProjectReportExport::init(
$request->input('users', User::all()->pluck('id')->toArray()),
$request->input('projects', Project::all()->pluck('id')->toArray()),
Carbon::parse($request->input('start_at'))->setTimezone($companyTimezone),
Carbon::parse($request->input('end_at'))->setTimezone($companyTimezone),
$companyTimezone
),
$request->user(),
ReportHelper::getReportFormat($request),
);
app(Dispatcher::class)->dispatchSync($job);
return responder()->success(['url' => $job->getPublicPath()])->respond();
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers\Api\Reports;
use App\Http\Requests\Reports\TimeUseReportRequest;
use App\Models\User;
use App\Reports\TimeUseReportExport;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Settings;
/**
* Class TimeUseReportController
*
*/
class TimeUseReportController
{
/**
* @api {post} /api/report/time Get User Time Report
* @apiDescription Retrieves the time report for specified users within a given time range.
*
* @apiVersion 4.0.0
* @apiName GetUserTimeReport
* @apiGroup Reports
*
* @apiParam {String} start_at The start date and time for the report in ISO 8601 format.
* @apiParam {String} end_at The end date and time for the report in ISO 8601 format.
* @apiParam {String} user_timezone The timezone of the user. Example: `Asia/Omsk`.
* @apiParam {Array} users List of user IDs to include in the report.
*
* @apiParamExample {json} Request Example:
* {
* "start_at": "2023-11-01T16:15:09Z",
* "end_at": "2023-11-30T23:59:07Z",
* "user_timezone": "Asia/Omsk",
* "users": [7]
* }
*
* @apiSuccess {Object[]} data List of users and their respective time logs.
* @apiSuccess {Number} data.time Total time logged by the user within the specified period (in seconds).
* @apiSuccess {Object} data.user User information.
* @apiSuccess {Number} data.user.id User ID.
* @apiSuccess {String} data.user.email User's email address.
* @apiSuccess {String} data.user.full_name User's full name.
* @apiSuccess {Object[]} data.tasks List of tasks the user has logged time for.
* @apiSuccess {Number} data.tasks.time Time logged for the task (in seconds).
* @apiSuccess {Number} data.tasks.task_id Task ID.
* @apiSuccess {String} data.tasks.task_name Task name.
* @apiSuccess {Number} data.tasks.project_id Project ID associated with the task.
* @apiSuccess {String} data.tasks.project_name Project name associated with the task.
*
* @apiSuccessExample {json} Success Response:
* HTTP/1.1 200 OK
* {
* "status": 200,
* "success": true,
* "data": [
* {
* "time": 2151975,
* "user": {
* "id": 7,
* "email": "projectManager1231@example.com",
* "full_name": "Dr. Adaline Toy"
* },
* "tasks": [
* {
* "time": 307425,
* "task_id": 56,
* "task_name": "Similique enim aspernatur.",
* "project_id": 159,
* "project_name": "Voluptas ab et ea."
* },
* ...
* ]
* }
* ]
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
* @apiUse ForbiddenError
*/
public function __invoke(TimeUseReportRequest $request): JsonResponse
{
$companyTimezone = Settings::scope('core')->get('timezone', 'UTC');
return responder()->success(
TimeUseReportExport::init(
$request->input('users') ?? User::all()->pluck('id')->toArray(),
Carbon::parse($request->input('start_at'))->setTimezone($companyTimezone),
Carbon::parse($request->input('end_at'))->setTimezone($companyTimezone),
$companyTimezone
)->collection()->all(),
)->respond();
}
}

View File

@@ -0,0 +1,201 @@
<?php
namespace App\Http\Controllers\Api\Reports;
use App\Enums\UniversalReportType;
use App\Enums\UniversalReportBase;
use App\Exceptions\Entities\NotEnoughRightsException;
use App\Helpers\ReportHelper;
use App\Http\Requests\Reports\UniversalReport\UniversalReportEditRequest;
use App\Http\Requests\Reports\UniversalReport\UniversalReportRequest;
use App\Http\Requests\Reports\UniversalReport\UniversalReportShowRequest;
use App\Http\Requests\Reports\UniversalReport\UniversalReportStoreRequest;
use App\Http\Requests\Reports\UniversalReport\UniversalReportDestroyRequest;
use App\Jobs\GenerateAndSendReport;
use App\Models\Project;
use App\Models\UniversalReport;
use App\Reports\PlannedTimeReportExport;
use App\Reports\UniversalReportExport;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Settings;
use Throwable;
class UniversalReportController
{
public function index()
{
$items = [
UniversalReportType::COMPANY->value => [],
UniversalReportType::PERSONAL->value => [],
];
$user = request()->user();
if (request()->user()->isAdmin()) {
UniversalReport::select('id', 'name', 'type')
->where([
['type', '=', UniversalReportType::COMPANY->value, 'or'],
['user_id', '=', request()->user()->id, 'or']
])
->get()
->each(function ($item) use (&$items) {
$items[$item->type->value][] = $item->toArray();
});
return responder()->success($items)->respond();
}
UniversalReport::select('id', 'name', 'data_objects', 'base', 'type')->get()->each(function ($item) use (&$items) {
if ($item->base->checkAccess($item->data_objects)) {
unset($item->data_objects, $item->base);
$items[$item->type->value][] = $item->toArray();
}
});
return responder()->success($items)->respond();
}
public function getBases()
{
return responder()->success(UniversalReportBase::bases())->respond();
}
public function getDataObjectsAndFields(Request $request)
{
$base = UniversalReportBase::tryFrom($request->input('base', null));
// dd($base->dataObjects());
return responder()->success([
'fields' => $base->fields(),
'dataObjects' => $base->dataObjects(),
'charts' => $base->charts(),
])->respond();
}
public function store(UniversalReportStoreRequest $request)
{
$user = $request->user();
if ($request->input('type') === UniversalReportType::COMPANY->value) {
if ($request->user()->isAdmin()) {
$report = $user->universalReports()->create([
'name' => $request->name,
'type' => $request->type,
'base' => $request->base,
'data_objects' => $request->dataObjects,
'fields' => $request->fields,
'charts' => $request->charts,
]);
return responder()->success(['message' => "The report was saved successfully", 'id' => $report->id])->respond(200);
} else {
return throw new NotEnoughRightsException('User rights do not allow saving the report for the company');
}
}
$report = $user->universalReports()->create([
'name' => $request->name,
'type' => $request->type,
'base' => $request->base,
'data_objects' => $request->dataObjects,
'fields' => $request->fields,
'charts' => $request->charts,
]);
return responder()->success(['message' => "The report was saved successfully", 'id' => $report->id])->respond(200);
}
public function show(UniversalReportShowRequest $request)
{
return responder()->success(UniversalReport::find($request->id))->respond();
}
public function edit(UniversalReportEditRequest $request)
{
if ($request->input('type') === UniversalReportType::COMPANY->value) {
if ($request->user()->isAdmin()) {
UniversalReport::where('id', $request->id)->update([
'name' => $request->name,
'type' => $request->type,
'base' => $request->base,
'data_objects' => $request->dataObjects,
'fields' => $request->fields,
'charts' => $request->charts,
]);
} else {
return throw new NotEnoughRightsException('User rights do not allow saving the report for the company');
}
}
UniversalReport::where('id', $request->id)->update([
'name' => $request->name,
'type' => $request->type,
'base' => $request->base,
'data_objects' => $request->dataObjects,
'fields' => $request->fields,
'charts' => $request->charts,
]);
}
public function __invoke(UniversalReportRequest $request): JsonResponse
{
$companyTimezone = Settings::scope('core')->get('timezone', 'UTC');
return responder()->success(
UniversalReportExport::init(
$request->input('id'),
Carbon::parse($request->input('start_at'))->setTimezone($companyTimezone),
Carbon::parse($request->input('end_at'))->setTimezone($companyTimezone),
Settings::scope('core')->get('timezone', 'UTC'),
)->collection()->all(),
)->respond();
}
public function destroy(UniversalReportDestroyRequest $request)
{
UniversalReport::find($request->input('id', null))->delete();
return responder()->success()->respond(204);
}
public function download(UniversalReportRequest $request): JsonResponse
{
$companyTimezone = Settings::scope('core')->get('timezone', 'UTC');
$job = new GenerateAndSendReport(
UniversalReportExport::init(
$request->id,
Carbon::parse($request->start_at) ?? Carbon::parse(),
Carbon::parse($request->end_at) ?? Carbon::parse(),
Settings::scope('core')->get('timezone', 'UTC'),
$request->user()?->timezone ?? 'UTC',
),
$request->user(),
ReportHelper::getReportFormat($request),
);
$job->handle();
// app(Dispatcher::class)->dispatchSync($job);
return responder()->success(['url' => $job->getPublicPath()])->respond();
}
// /**
// * @throws Throwable
// */
// public function download(UniversalReportRequest $request): JsonResponse
// {
// $job = new GenerateAndSendReport(
// PlannedTimeReportExport::init(
// $request->input('projects', Project::all()->pluck('id')->toArray()),
// ),
// $request->user(),
// ReportHelper::getReportFormat($request),
// );
// app(Dispatcher::class)->dispatchSync($job);
// return responder()->success(['url' => $job->getPublicPath()])->respond();
// }
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers\Api;
use App\Enums\Role;
use CatEvent;
use Filter;
use Illuminate\Http\JsonResponse;
class RoleController extends ItemController
{
/**
* @api {post} /api/roles/list Get Roles List
* @apiDescription Retrieves the list of roles available in the system.
*
* @apiVersion 4.0.0
* @apiName GetRolesList
* @apiGroup Roles
*
* @apiSuccess {Number} status HTTP status code.
* @apiSuccess {Boolean} success Request success status.
* @apiSuccess {Object[]} data List of roles.
* @apiSuccess {String} data.name Role name.
* @apiSuccess {Number} data.id Role ID.
*
* @apiSuccessExample {json} Success Response:
* HTTP/1.1 200 OK
* {
* "status": 200,
* "success": true,
* "data": [
* {
* "name": "ANY",
* "id": -1
* },
* {
* "name": "ADMIN",
* "id": 0
* },
* {
* "name": "MANAGER",
* "id": 1
* },
* {
* "name": "USER",
* "id": 2
* },
* {
* "name": "AUDITOR",
* "id": 3
* }
* ]
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
* @apiUse ForbiddenError
*/
public function index(): JsonResponse
{
CatEvent::dispatch(Filter::getBeforeActionEventName());
$items = Filter::process(
Filter::getActionFilterName(),
//For compatibility reasons generate serialized model-like array
array_map(fn ($role) => ['name' => $role->name, 'id' => $role->value], Role::cases()),
);
CatEvent::dispatch(Filter::getAfterActionEventName(), [$items]);
return responder()->success($items)->respond();
}
}

View File

@@ -0,0 +1,253 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Status;
use App\Http\Requests\Status\CreateStatusRequest;
use App\Http\Requests\Status\DestroyStatusRequest;
use App\Http\Requests\Status\ListStatusRequest;
use App\Http\Requests\Status\ShowStatusRequestStatus;
use App\Http\Requests\Status\UpdateStatusRequest;
use CatEvent;
use Exception;
use Filter;
use Illuminate\Http\JsonResponse;
use Throwable;
class StatusController extends ItemController
{
protected const MODEL = Status::class;
/**
* @throws Throwable
* @api {post} /statuses/show Show
* @apiDescription Show status.
*
* @apiVersion 4.0.0
* @apiName Show Status
* @apiGroup Status
*
* @apiUse AuthHeader
*
* @apiParam {Integer} id Status ID
*
* @apiParamExample {json} Request Example
* {
* "id": 1
* }
*
* @apiUse StatusObject
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*
*/
public function show(ShowStatusRequestStatus $request): JsonResponse
{
return $this->_show($request);
}
/**
* @throws Throwable
* @api {get} /statuses/list List
* @apiDescription Get list of statuses.
*
* @apiVersion 4.0.0
* @apiName Status List
* @apiGroup Status
*
* @apiUse AuthHeader
*
* @apiSuccess {Object} res Status
*
* @apiUse StatusObject
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*
*/
public function index(ListStatusRequest $request): JsonResponse
{
return $this->_index($request);
}
/**
* @param CreateStatusRequest $request
* @return JsonResponse
* @throws Throwable
* @api {post} /statuses/create Create
* @apiDescription Creates status
*
* @apiVersion 4.0.0
* @apiName Create Status
* @apiGroup Status
*
* @apiUse AuthHeader
*
* @apiParam {String} name Status name
* @apiParam {String} active Status active
*
* @apiParamExample {json} Request Example
* {
* "name": "Normal",
* "active": false
* }
*
* @apiSuccess {Object} res Status
*
* @apiSuccess {Number} id The ID of the status.
* @apiSuccess {String} name The name of the status.
* @apiSuccess {Boolean} active Indicates if the status is active.\
* @apiSuccess {String} created_at The creation timestamp.
* @apiSuccess {String} updated_at The last update timestamp.
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "id": 10,
* "name": "Normal",
* "active": false,
* "created_at": "2024-08-15T14:04:03.000000Z",
* "updated_at": "2024-08-15T14:04:03.000000Z"
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
public function create(CreateStatusRequest $request): JsonResponse
{
Filter::listen(Filter::getRequestFilterName(), static function ($item) {
$maxOrder = Status::max('order');
$item['order'] = $maxOrder + 1;
return $item;
});
return $this->_create($request);
}
/**
* @throws Throwable
* @api {post} /statuses/edit Edit
* @apiDescription Edit Status
*
* @apiVersion 4.0.0
* @apiName Edit
* @apiGroup Status
*
* @apiUse AuthHeader
*
* @apiParam {Integer} id ID
* @apiParam {String} name Status name
* @apiParam {String} active Status active
*
* @apiParamExample {json} Simple Request Example
* {
* "id": 1,
* "name": "Normal",
* "active": false
* }
*
* @apiSuccess {Object} res Status
*
* @apiUse StatusObject
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse UnauthorizedError
* @apiUse ItemNotFoundError
*/
public function edit(UpdateStatusRequest $request): JsonResponse
{
CatEvent::listen(Filter::getBeforeActionEventName(), static function ($item, $requestData) {
if (isset($requestData['order'])) {
$newOrder = $requestData['order'];
$oldOrder = $item->order;
if ($newOrder < 1) {
$newOrder = 1;
}
$maxOrder = Status::max('order');
if ($newOrder > $maxOrder) {
$newOrder = $maxOrder + 1;
}
$swapItem = Status::where('order', '=', $newOrder)->first();
if (isset($swapItem)) {
$swapItemOrder = $swapItem->order;
$item->order = 0;
$item->save();
$swapItem->order = $oldOrder;
$swapItem->save();
$item->order = $swapItemOrder;
$item->save();
} else {
$item->order = $newOrder;
}
}
});
return $this->_edit($request);
}
/**
* @throws Throwable
* @api {post} /statuses/remove Destroy
* @apiDescription Destroy User
*
* @apiVersion 4.0.0
* @apiName Destroy Status
* @apiGroup Status
*
* @apiUse AuthHeader
*
* @apiParam {Integer} id ID of the target status
*
* @apiParamExample {json} Request Example
* {
* "id": 1
* }
*
* @apiSuccess (204) No Content Indicates that the status was successfully removed or deactivated.
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse ForbiddenError
* @apiUse UnauthorizedError
*/
public function destroy(DestroyStatusRequest $request): JsonResponse
{
return $this->_destroy($request);
}
/**
* @param ListStatusRequest $request
* @return JsonResponse
* @throws Exception
*/
/**
* @api {get} /invitations/count Count Invitations
* @apiDescription Get the count of invitations
*
* @apiVersion 4.0.0
* @apiName CountInvitations
* @apiGroup Invitations
*
* @apiSuccess {Integer} total The total count of pending invitations.
*
* @apiSuccessExample {json} Success Response:
* {
* "total": 0
* }
*
* @apiUse TotalSuccess
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
public function count(ListStatusRequest $request): JsonResponse
{
return $this->_count($request);
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Api;
use App\Enums\ActivityType;
use App\Enums\SortDirection;
use App\Http\Requests\TaskActivity\ShowTaskActivityRequest;
use App\Models\TaskComment;
use App\Models\TaskHistory;
use CatEvent;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Filter;
use App\Helpers\QueryHelper;
class TaskActivityController extends ItemController
{
private function getQueryBuilder(array $filter, string $model): Builder
{
$model = new $model;
$query = new Builder($model::getQuery());
$query->setModel($model);
$modelScopes = $model->getGlobalScopes();
foreach ($modelScopes as $key => $value) {
$query->withGlobalScope($key, $value);
}
foreach (Filter::process(Filter::getQueryAdditionalRelationsFilterName(), []) as $with) {
$query->with($with);
}
QueryHelper::apply($query, $model, $filter);
$sortDirection = SortDirection::tryFrom($filter['orderBy'][1])?->value ?? 'asc';
$query->orderBy('id', $sortDirection);
return Filter::process(
Filter::getQueryFilterName(),
$query
);
}
private function getCollectionFromModel(array $requestData, string $model): LengthAwarePaginator
{
if ($model === TaskComment::class) {
$requestData['with'][] = 'attachmentsRelation';
$requestData['with'][] = 'attachmentsRelation.user:id,full_name';
}
$itemsQuery = $this->getQueryBuilder($requestData, $model);
$items = $itemsQuery->paginate(30);
Filter::process(
Filter::getActionFilterName(),
$items,
);
return $items;
}
/**
* @param ShowTaskActivityRequest $request
* @return JsonResponse
* @throws Exception
*/
public function index(ShowTaskActivityRequest $request): JsonResponse
{
$requestData = Filter::process(Filter::getRequestFilterName(), $request->validated());
$requestedActivity = ActivityType::from($requestData['type']);
CatEvent::dispatch(Filter::getBeforeActionEventName(), $requestData);
$items = [];
$total = 0;
$perPage = 0;
if ($requestedActivity === ActivityType::ALL) {
$taskComments = $this->getCollectionFromModel($requestData, TaskComment::class);
$taskHistory = $this->getCollectionFromModel($requestData, TaskHistory::class);
$total = $taskComments->total() + $taskHistory->total();
$perPage = $taskComments->perPage() + $taskHistory->perPage();
$sortDirection = SortDirection::tryFrom($requestData['orderBy'][1])?->value ?? 'asc';
$items = collect(array_merge($taskComments->items(), $taskHistory->items()))->sortBy([
fn ($a, $b) => $sortDirection === 'asc'
? strtotime($a['created_at']) <=> strtotime($b['created_at'])
: strtotime($b['created_at']) <=> strtotime($a['created_at']),
], SORT_REGULAR, $sortDirection === 'desc');
} elseif ($requestedActivity === ActivityType::HISTORY) {
$taskHistory = $this->getCollectionFromModel($requestData, TaskHistory::class);
$total = $taskHistory->total();
$perPage = $taskHistory->perPage();
$items = $taskHistory->items();
} elseif ($requestedActivity === ActivityType::COMMENTS) {
$taskComments = $this->getCollectionFromModel($requestData, TaskComment::class);
$total = $taskComments->total();
$perPage = $taskComments->perPage();
$items = $taskComments->items();
}
CatEvent::dispatch(Filter::getAfterActionEventName(), [$items, $requestData]);
return responder()->success(new LengthAwarePaginator($items, $total, $perPage))->respond();
}
}

View File

@@ -0,0 +1,281 @@
<?php
namespace App\Http\Controllers\Api;
use App\Enums\Role;
use App\Http\Requests\Status\DestroyStatusRequest;
use App\Http\Requests\TaskComment\CreateTaskCommentRequest;
use App\Http\Requests\TaskComment\DestroyTaskCommentRequest;
use App\Http\Requests\TaskComment\ListTaskCommentRequest;
use App\Http\Requests\TaskComment\ShowTaskCommentRequestStatus;
use App\Http\Requests\TaskComment\UpdateTaskCommentRequest;
use Filter;
use App\Models\TaskComment;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Throwable;
class TaskCommentController extends ItemController
{
protected const MODEL = TaskComment::class;
/**
* @api {post} /task-comment/create Create Task Comment
* @apiDescription Create a new task comment
*
* @apiVersion 4.0.0
* @apiName CreateTaskComment
* @apiGroup TaskComments
*
*
* @apiPermission task_comment_create
* @apiPermission task_comment_full_access
* @apiParam {Integer} task_id ID of the task
* @apiParam {String} comment The content of the comment
*
* @apiParamExample {json} Request Example:
* {
* "task_id": 1,
* "comment": "This is a new comment"
* }
*
* @apiSuccess {Integer} id ID of the created comment
* @apiSuccess {Integer} task_id ID of the task
* @apiSuccess {Integer} user_id ID of the user who created the comment
* @apiSuccess {String} comment The content of the comment
* @apiSuccess {String} created_at Creation timestamp
* @apiSuccess {String} updated_at Last update timestamp
*
* @apiSuccessExample {json} Response Example:
* HTTP/1.1 201 Created
* {
* "id": 1,
* "task_id": 1,
* "user_id": 1,
* "comment": "This is a new comment",
* "created_at": "2024-07-09T10:00:00.000000Z",
* "updated_at": "2024-07-09T10:00:00.000000Z"
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
public function create(CreateTaskCommentRequest $request): JsonResponse
{
Filter::listen(
Filter::getRequestFilterName(),
static function (array $data) use ($request) {
$data['user_id'] = $request->user()->id;
return $data;
}
);
return $this->_create($request);
}
/**
* @api {post} /task-comment/edit Edit Task Comment
* @apiDescription Edit an existing task comment
*
* @apiVersion 4.0.0
* @apiName EditTaskComment
* @apiGroup TaskComments
*
*
* @apiParam {Integer} id ID of the comment to edit
* @apiParam {String} comment The updated content of the comment
*
* @apiParamExample {json} Request Example:
* {
* "id": 1,
* "comment": "This is the updated comment"
* }
*
* @apiSuccess {Integer} id ID of the edited comment
* @apiSuccess {Integer} task_id ID of the task
* @apiSuccess {Integer} user_id ID of the user who edited the comment
* @apiSuccess {String} comment The updated content of the comment
* @apiSuccess {String} created_at Creation timestamp
* @apiSuccess {String} updated_at Last update timestamp
* @apiSuccess {String} deleted_at Deletion timestamp (if applicable, otherwise null)
*
* @apiSuccessExample {json} Response Example:
* HTTP/1.1 200 OK
* {
* "id": 1,
* "task_id": 1,
* "user_id": 1,
* "content": "2344",
* "created_at": "2024-05-03T10:45:36.000000Z",
* "updated_at": "2024-05-03T10:45:36.000000Z",
* "deleted_at": null
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
public function edit(UpdateTaskCommentRequest $request): JsonResponse
{
return $this->_edit($request);
}
/**
* @api {any} /task-comment/list List Task Comments
* @apiDescription Get list of Task Comments
*
* @apiVersion 4.0.0
* @apiName GetTaskCommentList
* @apiGroup Task Comments
*
* @apiPermission task_comment_list
* @apiPermission task_comment_full_access
*
* @apiParam {Integer} [task_id] Optional task ID to filter comments
*
* @apiParamExample {json} Request Example:
* {
* "task_id": 1
* }
*
* @apiSuccess {Integer} id ID of the comment
* @apiSuccess {Integer} task_id ID of the task
* @apiSuccess {Integer} user_id ID of the user who created the comment
* @apiSuccess {String} content Content of the comment
* @apiSuccess {String} created_at Creation timestamp
* @apiSuccess {String} updated_at Last update timestamp
* @apiSuccess {String} deleted_at Deletion timestamp (if applicable, otherwise null)
* @apiSuccess {Object} user User who created the comment
*
* @apiSuccessExample {json} Response Example:
* HTTP/1.1 200 OK
* {
* "id": 1,
* "task_id": 1,
* "user_id": 1,
* "content": "2344",
* "created_at": "2024-05-03T10:45:36.000000Z",
* "updated_at": "2024-05-03T10:45:36.000000Z",
* "deleted_at": null,
* "user": {
* "id": 1,
* "full_name": "Admin",
* "email": "admin@cattr.app",
* "url": "",
* "company_id": 1,
* "avatar": "",
* "screenshots_active": 1,
* "manual_time": 0,
* "computer_time_popup": 300,
* "blur_screenshots": false,
* "web_and_app_monitoring": true,
* "screenshots_interval": 5,
* "active": 1,
* "deleted_at": null,
* "created_at": "2023-10-26T10:26:17.000000Z",
* "updated_at": "2024-02-15T19:06:42.000000Z",
* "timezone": null,
* "important": 0,
* "change_password": 0,
* "role_id": 0,
* "user_language": "en",
* "type": "employee",
* "invitation_sent": false,
* "nonce": 0,
* "client_installed": 0,
* "permanent_screenshots": 0,
* "last_activity": "2023-10-26 10:26:17",
* "online": false,
* "can_view_team_tab": true,
* "can_create_task": true
* }
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
public function index(ListTaskCommentRequest $request): JsonResponse
{
Filter::listen(
Filter::getQueryFilterName(),
static function ($query) use ($request) {
if (!$request->user()->can('edit', TaskComment::class)) {
$query = $query->whereHas(
'task',
static fn (Builder $taskQuery) => $taskQuery->where(
'user_id',
'=',
$request->user()->id
)
);
}
return $query->with('user');
}
);
return $this->_index($request);
}
/**
* @apiDeprecated since 1.0.0
* @throws Throwable
* @api {post} /task-comment/show Show
* @apiDescription Show Task Comment
*
* @apiVersion 1.0.0
* @apiName ShowTaskComment
* @apiGroup Task Comment
*
* @apiPermission task_comment_show
* @apiPermission task_comment_full_access
*/
public function show(ShowTaskCommentRequestStatus $request): JsonResponse
{
return $this->_show($request);
}
/**
* @apiDeprecated since 4.0.0
* @api {post} /task-comment/remove Destroy Task Comment
* @apiDescription Destroy a Task Comment
*
* @apiVersion 4.0.0
* @apiName DestroyTaskComment
* @apiGroup Task Comment
*
* @apiPermission task_comment_remove
* @apiPermission task_comment_full_access
*
* @apiParam {Integer} id ID of the task comment to destroy
*
* @apiParamExample {json} Request Example:
* {
* "id": 1
* }
*
* @apiSuccess {Integer} status Response status code
* @apiSuccess {Boolean} success Response success status
* @apiSuccess {String} message Success message
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
public function destroy(DestroyTaskCommentRequest $request): JsonResponse
{
$user = $request->user();
Filter::listen(
Filter::getQueryFilterName(),
static fn($query) => $user->hasRole([Role::ADMIN, Role::MANAGER]) ? $query :
$query->where(['user_id' => $user->id])
->whereHas('task', static fn($taskQuery) => $taskQuery->where(['user_id' => $user->id]))
);
return $this->_destroy($request);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,279 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Filter;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Validator;
/**
* @deprecated
*/
class TimeController extends Controller
{
/**
* @deprecated
*/
public static function getControllerRules(): array
{
return [
'total' => 'time.total',
'project' => 'time.project',
'tasks' => 'time.tasks',
'task' => 'time.task',
'taskUser' => 'time.task-user',
];
}
/**
* @api {get,post} /time/total Total
* @apiDescription Get total of Time
*
* @apiVersion 4.0.0
* @apiName Total
* @apiGroup Time
*
* @apiUse AuthHeader
*
* @apiPermission time_total
* @apiPermission time_full_access
*
* @apiParam {String} start_at Start DataTime
* @apiParam {String} end_at End DataTime
* @apiParam {Integer} user_id User ID
*
* @apiParamExample {json} Request Example
* {
* "user_id": 1,
* "start_at": "2005-01-01 00:00:00",
* "end_at": "2019-01-01 00:00:00"
* }
*
* @apiSuccess {Integer} time Total time in seconds
* @apiSuccess {String} start Datetime of first Time Interval start_at
* @apiSuccess {String} end Datetime of last Time Interval end_at
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "time": 338230,
* "start": "2020-01-23T19:42:27+00:00",
* "end": "2020-04-30T21:58:31+00:00"
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
* @apiUse ForbiddenError
* @apiUse ValidationError
*/
/**
* Display a total of time
* @param Request $request
* @return JsonResponse
* @throws Exception
* @deprecated
*/
public function total(Request $request): JsonResponse
{
$validationRules = [
'start_at' => 'required|date',
'end_at' => 'required|date',
'user_id' => 'required|integer|exists:users,id'
];
$validator = Validator::make($request->all(), $validationRules);
if ($validator->fails()) {
return new JsonResponse(
Filter::process($this->getEventUniqueName('answer.error.time.total'), [
'error_type' => 'validation',
'message' => 'Validation error',
'info' => $validator->errors()
]),
400
);
}
$filters = [
'start_at' => ['>=', $request->get('start_at')],
'end_at' => ['<=', $request->get('end_at')],
'user_id' => ['=', $request->get('user_id')]
];
/** @var Builder $itemsQuery */
$itemsQuery = Filter::process(
$this->getEventUniqueName('answer.success.item.query.prepare'),
$this->applyQueryFilter($this->getQuery(), $filters)
);
$timeIntervals = $itemsQuery->get();
$totalTime = $timeIntervals->sum(static fn($el) => Carbon::parse($el->end_at)->diffInSeconds($el->start_at));
return responder()->success([
'time' => $totalTime,
'start' => $timeIntervals->min('start_at'),
'end' => $timeIntervals->max('end_at')
])->respond();
}
/**
* @api {get,post} /time/tasks Tasks
* @apiDescription Get tasks and its total time
*
* @apiVersion 4.0.0
* @apiName Tasks
* @apiGroup Time
*
* @apiUse TimeIntervalParams
*
* @apiParamExample {json} Request Example:
* {
* "user_id": 1,
* "task_id": 1,
* "project_id": 2,
* "start_at": "2005-01-01 00:00:00",
* "end_at": "2019-01-01 00:00:00",
* "activity_fill": 42,
* "mouse_fill": 43,
* "keyboard_fill": 43,
* "id": [">", 1]
* }
*
* @apiSuccess {String} current_datetime Current datetime of server
* @apiSuccess {Object[]} tasks Array of objects Task
* @apiSuccess {Integer} tasks.id Tasks id
* @apiSuccess {Integer} tasks.user_id Tasks User id
* @apiSuccess {Integer} tasks.project_id Tasks Project id
* @apiSuccess {Integer} tasks.time Tasks total time in seconds
* @apiSuccess {String} tasks.start Datetime of first Tasks Time Interval start_at
* @apiSuccess {String} tasks.end Datetime of last Tasks Time Interval end_at
* @apiSuccess {Object[]} total Array of total tasks time
* @apiSuccess {Integer} total.time Total time of tasks in seconds
* @apiSuccess {String} total.start Datetime of first Time Interval start_at
* @apiSuccess {String} total.end DateTime of last Time Interval end_at
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "current_datetime": "2020-01-28T10:57:40+00:00",
* "tasks": [
* {
* "id": 1,
* "user_id": 1,
* "project_id": 1,
* "time": 1490,
* "start": "2020-01-23T19:42:27+00:00",
* "end": "2020-01-23T20:07:21+00:00"
* },
* ],
* "total": {
* "time": 971480,
* "start": "2020-01-23T19:42:27+00:00",
* "end": "2020-11-01T08:28:06+00:00"
* }
* }
*
* @apiUse 400Error
* @apiUse ForbiddenError
* @apiUse UnauthorizedError
* @apiUse ValidationError
*/
/**
* Display the Tasks and theirs total time
*
* @param Request $request
* @return JsonResponse
* @throws Exception
* @deprecated
*/
public function tasks(Request $request): JsonResponse
{
$validationRules = [
'start_at' => 'date',
'end_at' => 'date',
'project_id' => 'exists:projects,id',
'task_id' => 'exists:tasks,id'
];
$validator = Validator::make($request->all(), $validationRules);
if ($validator->fails()) {
return new JsonResponse(
Filter::process($this->getEventUniqueName('answer.error.time.total'), [
'error_type' => 'validation',
'message' => 'Validation error',
'info' => $validator->errors()
]),
400
);
}
$filters = $request->all();
$request->get('start_at') ? $filters['start_at'] = ['>=', (string)$request->get('start_at')] : false;
$request->get('end_at') ? $filters['end_at'] = ['<=', (string)$request->get('end_at')] : false;
$request->get('project_id') ? $filters['task.project_id'] = $request->get('project_id') : false;
$request->get('task_id') ? $filters['task_id'] = ['in', $request->get('task_id')] : false;
$baseQuery = $this->applyQueryFilter(
$this->getQuery(),
$filters ?: []
);
$itemsQuery = Filter::process(
$this->getEventUniqueName('answer.success.item.list.query.prepare'),
$baseQuery
);
$totalTime = 0;
$tasks = $itemsQuery
->with('task')
->get()
->groupBy(['task_id', 'user_id'])
->map(static function ($taskIntervals, $taskId) use (&$totalTime) {
$task = [];
foreach ($taskIntervals as $userId => $userIntervals) {
$taskTime = 0;
foreach ($userIntervals as $interval) {
$taskTime += Carbon::parse($interval->end_at)->diffInSeconds($interval->start_at);
}
$firstUserInterval = $userIntervals->first();
$lastUserInterval = $userIntervals->last();
$task = [
'id' => $taskId,
'user_id' => $userId,
'project_id' => $userIntervals[0]['task']['project_id'],
'time' => $taskTime,
'start' => Carbon::parse($firstUserInterval->start_at)->toISOString(),
'end' => Carbon::parse($lastUserInterval->end_at)->toISOString()
];
$totalTime += $taskTime;
}
return $task;
})
->values();
$first = $itemsQuery->get()->first();
$last = $itemsQuery->get()->last();
return responder()->success([
'tasks' => $tasks,
'total' => [
'time' => $totalTime,
'start' => $first ? Carbon::parse($first->start_at)->toISOString() : null,
'end' => $last ? Carbon::parse($last->end_at)->toISOString() : null,
]
])->respond();
}
}

View File

@@ -0,0 +1,570 @@
<?php
namespace App\Http\Controllers\Api;
use App;
use App\Enums\Role;
use App\Enums\ScreenshotsState;
use App\Http\Requests\User\ListUsersRequest;
use App\Scopes\UserAccessScope;
use Settings;
use Carbon\Carbon;
use Exception;
use Filter;
use App\Mail\UserCreated;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use CatEvent;
use Mail;
use App\Http\Requests\User\CreateUserRequest;
use App\Http\Requests\User\EditUserRequest;
use App\Http\Requests\User\SendInviteUserRequest;
use App\Http\Requests\User\ShowUserRequest;
use App\Http\Requests\User\DestroyUserRequest;
use App\Models\Setting;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
class UserController extends ItemController
{
protected const MODEL = User::class;
/**
* @throws Exception
* @api {get, post} /users/list List
* @apiDescription Get list of Users with any params
*
* @apiVersion 4.0.0
* @apiName GetUserList
* @apiGroup User
*
* @apiUse AuthHeader
*
* @apiPermission users_list
* @apiPermission users_full_access
*
* @apiSuccess {Object[]} users List of users.
* @apiSuccess {Integer} users.id The unique ID of the user.
* @apiSuccess {String} users.full_name Full name of the user.
* @apiSuccess {String} users.email Email address of the user.
* @apiSuccess {String} users.url URL associated with the user.
* @apiSuccess {Integer} users.company_id ID of the company the user belongs to.
* @apiSuccess {String} users.avatar URL of the user's avatar image.
* @apiSuccess {Integer} users.screenshots_state The current state of screenshot monitoring.
* @apiSuccess {Boolean} users.manual_time Indicates if manual time tracking is allowed.
* @apiSuccess {Integer} users.computer_time_popup Time in seconds before showing a time popup.
* @apiSuccess {Boolean} users.blur_screenshots Indicates if screenshots are blurred.
* @apiSuccess {Boolean} users.web_and_app_monitoring Indicates if web and app monitoring is enabled.
* @apiSuccess {Integer} users.screenshots_interval Interval in minutes for taking screenshots.
* @apiSuccess {Boolean} users.active Indicates if the user is active.
* @apiSuccess {String} users.deleted_at Deletion timestamp, or `null` if the user is not deleted.
* @apiSuccess {String} users.created_at Creation timestamp of the user.
* @apiSuccess {String} users.updated_at Last update timestamp of the user.
* @apiSuccess {String} users.timezone The timezone of the user, or `null`.
* @apiSuccess {Boolean} users.important Indicates if the user is marked as important.
* @apiSuccess {Boolean} users.change_password Indicates if the user must change their password.
* @apiSuccess {Integer} users.role_id ID of the user's role.
* @apiSuccess {String} users.user_language Language preference of the user.
* @apiSuccess {String} users.type The user type, e.g., "employee".
* @apiSuccess {Boolean} users.invitation_sent Indicates if an invitation has been sent.
* @apiSuccess {Integer} users.nonce Nonce value for secure actions.
* @apiSuccess {Boolean} users.client_installed Indicates if the client software is installed.
* @apiSuccess {Boolean} users.permanent_screenshots Indicates if permanent screenshots are enabled.
* @apiSuccess {String} users.last_activity The last recorded activity timestamp.
* @apiSuccess {Boolean} users.screenshots_state_locked Indicates if screenshot state is locked.
* @apiSuccess {Boolean} users.online Indicates if the user is currently online.
* @apiSuccess {Boolean} users.can_view_team_tab Indicates if the user can view the team tab.
* @apiSuccess {Boolean} users.can_create_task Indicates if the user can create tasks.
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* [
* {
* "id": 1,
* "full_name": "Admin",
* "email": "admin@cattr.app",
* "url": "",
* "company_id": 1,
* "avatar": "",
* "screenshots_state": 1,
* "manual_time": 0,
* "computer_time_popup": 300,
* "blur_screenshots": false,
* "web_and_app_monitoring": true,
* "screenshots_interval": 5,
* "active": 1,
* "deleted_at": null,
* "created_at": "2023-10-26T10:26:17.000000Z",
* "updated_at": "2024-08-19T10:42:18.000000Z",
* "timezone": null,
* "important": 0,
* "change_password": 0,
* "role_id": 0,
* "user_language": "en",
* "type": "employee",
* "invitation_sent": false,
* "nonce": 0,
* "client_installed": 0,
* "permanent_screenshots": 0,
* "last_activity": "2024-08-19 10:42:18",
* "screenshots_state_locked": false,
* "online": false,
* "can_view_team_tab": true,
* "can_create_task": true
* },
* {
* "id": 2,
* "full_name": "Fabiola Mertz",
* "email": "projectManager@example.com",
* "url": "",
* "company_id": 1,
* "avatar": "",
* "screenshots_state": 2,
* "manual_time": 0,
* "computer_time_popup": 300,
* "blur_screenshots": false,
* "web_and_app_monitoring": true,
* "screenshots_interval": 5,
* "active": 1,
* "deleted_at": null,
* "created_at": "2023-10-26T10:26:17.000000Z",
* "updated_at": "2023-10-26T10:26:17.000000Z",
* "timezone": null,
* "important": 0,
* "change_password": 0,
* "role_id": 2,
* "user_language": "en",
* "type": "employee",
* "invitation_sent": false,
* "nonce": 0,
* "client_installed": 0,
* "permanent_screenshots": 0,
* "last_activity": "2023-10-26 09:44:17",
* "screenshots_state_locked": false,
* "online": false,
* "can_view_team_tab": false,
* "can_create_task": false
* },...
* ]
* }
* @apiUse 400Error
* @apiUse UnauthorizedError
* @apiUse ForbiddenError
*/
public function index(ListUsersRequest $request): JsonResponse
{
return $this->_index($request);
}
/**
* @api {post} /users/create Create
* @apiDescription Create User Entity
*
* @apiVersion 4.0.0
* @apiName CreateUser
* @apiGroup User
*
* @apiUse AuthHeader
*
* @apiPermission users_create
* @apiPermission users_full_access
*
* @apiParam {String} user_language The language of the new user (e.g., "en")
* @apiParam {String} timezone The timezone of the new user (e.g., "Europe/Moscow")
* @apiParam {Integer} role_id ID of the role of the new user
* @apiParam {Integer} active Will new user be active or not `(1 - active, 0 - not)`
* @apiParam {Integer} screenshots_state State of screenshots monitoring (e.g., 1 for enabled)
* @apiParam {Boolean} send_invite Whether to send an invitation to the new user (true - send, false - do not send)
* @apiParam {Boolean} manual_time Whether manual time tracking is enabled for the new user
* @apiParam {Integer} screenshots_interval Interval in minutes for taking screenshots
* @apiParam {Integer} computer_time_popup Time in minutes before showing a time popup
* @apiParam {String} type The type of user (e.g., "employee")
* @apiParam {Boolean} web_and_app_monitoring Whether web and app monitoring is enabled
* @apiParam {String} email New user email
* @apiParam {String} password New user password
* @apiParam {String} full_name New user name
* @apiParamExample {json} Request Example
* {
* "user_language" : "en",
* "timezone" : "Europe/Moscow",
* "role_id" : 2,
* "active" : true,
* "screenshots_state" : 1,
* "send_invite" : 1,
* "manual_time" : 1,
* "screenshots_interval" : 10,
* "computer_time_popup" : 3,
* "type" : "employee",
* "web_and_app_monitoring" : 1,
* "email" : "123@cattr.app",
* "password" : "password",
* "full_name" : "name"
* }
* @apiSuccess {String} full_name Full name of the user.
* @apiSuccess {String} email Email address of the user.
* @apiSuccess {String} user_language Language of the user.
* @apiSuccess {Boolean} active Whether the user is active.
* @apiSuccess {Integer} screenshots_state State of screenshots monitoring.
* @apiSuccess {Boolean} manual_time Whether manual time tracking is enabled.
* @apiSuccess {Integer} screenshots_interval Interval in minutes for taking screenshots.
* @apiSuccess {Integer} computer_time_popup Time in minutes before showing a time popup.
* @apiSuccess {String} timezone Timezone of the user.
* @apiSuccess {Integer} role_id ID of the role assigned to the user.
* @apiSuccess {String} type Type of the user (e.g., "employee").
* @apiSuccess {Boolean} web_and_app_monitoring Whether web and app monitoring is enabled.
* @apiSuccess {Boolean} screenshots_state_locked Whether the screenshot state is locked.
* @apiSuccess {Boolean} invitation_sent Whether an invitation has been sent.
* @apiSuccess {String} updated_at Timestamp of the last update.
* @apiSuccess {String} created_at Timestamp of when the user was created.
* @apiSuccess {Integer} id ID of the created user.
* @apiSuccess {Boolean} online Whether the user is currently online.
* @apiSuccess {Boolean} can_view_team_tab Whether the user can view the team tab.
* @apiSuccess {Boolean} can_create_task Whether the user can create tasks.
*
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "full_name": "name",
* "email": "123@cattr.app",
* "user_language": "en",
* "active": 1,
* "screenshots_state": 1,
* "manual_time": 1,
* "screenshots_interval": 10,
* "computer_time_popup": 3,
* "timezone": "Europe/Moscow",
* "role_id": 2,
* "type": "employee",
* "web_and_app_monitoring": true,
* "screenshots_state_locked": true,
* "invitation_sent": true,
* "updated_at": "2024-08-21T14:29:06.000000Z",
* "created_at": "2024-08-21T14:29:06.000000Z",
* "id": 10,
* "online": false,
* "can_view_team_tab": false,
* "can_create_task": false
* }
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse UnauthorizedError
* @apiUse ForbiddenError
*/
/**
* @param CreateUserRequest $request
* @return JsonResponse
* @throws Throwable
*/
public function create(CreateUserRequest $request): JsonResponse
{
Filter::listen(Filter::getRequestFilterName(), static function ($requestData) use ($request) {
$requestData['screenshots_state_locked'] = $request->user()->isAdmin() && ScreenshotsState::tryFrom($requestData['screenshots_state'])->mustBeInherited();
return $requestData;
});
return $this->_create($request);
}
/**
* @api {post} /users/edit Edit
* @apiDescription Edit User
*
* @apiVersion 4.0.0
* @apiName EditUser
* @apiGroup User
*
* @apiUse AuthHeader
*
* @apiPermission users_edit
* @apiPermission users_full_access
* @apiParam {String} user_language The language of the new user (e.g., "en")
* @apiParam {String} timezone The timezone of the new user (e.g., "Europe/Moscow")
* @apiParam {Integer} role_id ID of the role of the new user
* @apiParam {Integer} id The ID of the user being edited.
* @apiParam {String} full_name New user name
* @apiParam {String} email New user email
* @apiParam {String} url URL associated with the user
* @apiParam {Integer} company_id The ID of the company to which the user belongs
* @apiParam {String} avatar The URL of the users avatar
* @apiParam {Integer} screenshots_state State of screenshots monitoring (e.g., 1 for enabled)
* @apiParam {Boolean} manual_time Whether manual time tracking is enabled for the new user
* @apiParam {Integer} computer_time_popup Time in minutes before showing a time popup
* @apiParam {Boolean} blur_screenshots Indicates if screenshots are blurred
* @apiParam {Boolean} web_and_app_monitoring Whether web and app monitoring is enabled
* @apiParam {Integer} screenshots_interval Interval in minutes for taking screenshots
* @apiParam {Integer} active Will new user be active or not `(1 - active, 0 - not)`
* @apiParam {String} deleted_at Deletion timestamp, or `null` if the user is not deleted.
* @apiParam {Boolean} send_invite Whether to send an invitation to the new user (true - send, false - do not send)
*
*
*
* @apiParam {String} type The type of user (e.g., "employee")
*
* @apiParam {String} password New user password
*
*
* @apiParamExample {json} Request Example
* {
* "user_language" : "en",
* "timezone" : "Europe/Moscow",
* "role_id" : 2,
* "id" : 3,
* "full_name" : "Rachael Reichert",
* "email": "projectAuditor@example.com",
* "url" : null,
* "company_id" : 1,
* "avatar" : null,
* "screenshots_state" : 1,
* "manual_time" : 0,
* "computer_time_popup" : 300,
* "blur_screenshots" : false,
* "web_and_app_monitoring" : true,
* "screenshots_interval" : 5,
* "active" : true,
* "deleted_at" : null,
* "created_at" : "2023-10-26T10:26:42.000000Z",
* "updated_at" : "2023-10-26T10:26:42.000000Z",
* "important" : 0,
* "change_password" : 0,
* "type" : "employee",
* "invitation_sent" : false,
* "nonce" : 0,
* "client_installed" : 0,
* "permanent_screenshots" : 0,
* "last_activity" : "2023-10-26 10:05:42",
* "screenshots_state_locked" : false,
* "online" : false,
* "can_view_team_tab" : false,
* "can_create_task" : false
* }
* @apiUse UserObject
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse UnauthorizedError
* @apiUse ItemNotFoundError
*/
/**
* @param EditUserRequest $request
* @return JsonResponse
* @throws Throwable
*/
public function edit(EditUserRequest $request): JsonResponse
{
Filter::listen(Filter::getActionFilterName(), static function (User $user) use ($request) {
if ($user->screenshots_state_locked && !$request->user()->isAdmin()) {
$user->screenshots_state = $user->getOriginal('screenshots_state');
return $user;
}
$user->screenshots_state_locked = $request->user()->isAdmin() && ScreenshotsState::tryFrom($user->screenshots_state)->mustBeInherited();
return $user;
});
return $this->_edit($request);
}
/**
* @api {get, post} /users/show Show User
* @apiDescription Retrieves detailed information about a specific user.
*
* @apiVersion 4.0.0
* @apiName ShowUser
* @apiGroup User
*
* @apiUse AuthHeader
*
* @apiPermission users_show
* @apiPermission users_full_access
*
* @apiParam {Integer} id User id
*
* @apiParamExample {json} Request Example:
* {
* "id": 1
* }
* @apiUse UserObject
*
* @apiUse 400Error
* @apiUse UnauthorizedError
* @apiUse ItemNotFoundError
* @apiUse ForbiddenError
* @apiUse ValidationError
*/
/**
* @param ShowUserRequest $request
* @return JsonResponse
* @throws Exception
* @throws Throwable
*/
public function show(ShowUserRequest $request): JsonResponse
{
return $this->_show($request);
}
/**
* @throws Throwable
* @api {post} /users/remove Destroy
* @apiDescription Destroy User
*
* @apiVersion 4.0.0
* @apiName DestroyUser
* @apiGroup User
*
* @apiUse AuthHeader
*
* @apiPermission users_remove
* @apiPermission users_full_access
*
* @apiParam {Integer} id ID of the target user
*
* @apiParamExample {json} Request Example
* {
* "id": 1
* }
*
* @apiSuccess {String} message Destroy status
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 204 No Content
* {
* }
*
* @apiUse 400Error
* @apiUse ValidationError
* @apiUse ForbiddenError
* @apiUse UnauthorizedError
*/
public function destroy(DestroyUserRequest $request): JsonResponse
{
return $this->_destroy($request);
}
/**
* @throws Exception
* @api {get,post} /users/count Count
* @apiDescription Count Users
*
* @apiVersion 4.0.0
* @apiName Count
* @apiGroup User
*
* @apiUse AuthHeader
*
* @apiPermission users_count
* @apiPermission users_full_access
*
* @apiSuccess {String} total Amount of users that we have
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "total": 2
* }
*
* @apiUse 400Error
* @apiUse ForbiddenError
* @apiUse UnauthorizedError
*/
public function count(ListUsersRequest $request): JsonResponse
{
return $this->_count($request);
}
/**
* @param SendInviteUserRequest $request
* @return JsonResponse
* @throws Throwable
*/
/**
* @api {post} /api/users/send-invite Send User Invitation
* @apiDescription Sends an invitation to a user by generating a password, marking the invitation as sent, and dispatching relevant events.
*
* @apiVersion 4.0.0
* @apiName SendUserInvite
* @apiGroup User
*
* @apiUse AuthHeader
*
* @apiPermission users_invite
*
* @apiParam {Integer} id The ID of the user to whom the invitation will be sent.
*
* @apiParamExample {json} Request Example:
* {
* "id": 1
* }
*
* @apiSuccess {String} message A confirmation that the invite was sent successfully.
*
* @apiSuccessExample {json} Success Response:
* HTTP/1.1 204 No Content
*
* @apiUse 400Error
* @apiUse ForbiddenError
* @apiUse UnauthorizedError
*/
public function sendInvite(SendInviteUserRequest $request): JsonResponse
{
$requestId = Filter::process(Filter::getRequestFilterName(), $request->validated('id'));
$itemsQuery = $this->getQuery(['id' => $requestId]);
CatEvent::dispatch(Filter::getBeforeActionEventName(), $requestId);
$item = Filter::process(Filter::getActionFilterName(), $itemsQuery->first());
$password = Str::random();
$item->password = $password;
$item->invitation_sent = true;
$item->save();
throw_unless($item, new NotFoundHttpException);
CatEvent::dispatch(Filter::getAfterActionEventName(), [$requestId, $item]);
$language = Settings::scope('core')->get('language', 'en');
Mail::to($item->email)->locale($language)->send(new UserCreated($item->email, $password));
return responder()->success()->respond(204);
}
/**
* @api {patch} /users/activity Activity
* @apiDescription Updates the time of the user's last activity
*
* @apiVersion 4.0.0
* @apiName Activity
* @apiGroup User
*
* @apiUse AuthHeader
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 204 No Content
* {
* }
*
* @apiUse UnauthorizedError
*/
public function updateActivity(): JsonResponse
{
$user = request()->user();
CatEvent::dispatch(Filter::getBeforeActionEventName(), $user);
Filter::process(Filter::getActionFilterName(), $user)->update(['last_activity' => Carbon::now()]);
CatEvent::dispatch(Filter::getAfterActionEventName(), $user);
return responder()->success()->respond(204);
}
}

View File

@@ -0,0 +1,334 @@
<?php
namespace App\Http\Controllers;
use App\Exceptions\Entities\AuthorizationException;
use App\Exceptions\Entities\DeprecatedApiException;
use App\Helpers\Recaptcha;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Transformers\AuthTokenTransformer;
use Cache;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Str;
class AuthController extends BaseController
{
/**
* @apiDefine AuthHeader
* @apiHeader {String} Authorization Token for user auth
* @apiHeaderExample {json} Authorization Header Example
* {
* "Authorization": "bearer 16184cf3b2510464a53c0e573c75740540fe..."
* }
*/
public function __construct(protected Recaptcha $recaptcha)
{
}
/**
* @api {post} /auth/login Login
* @apiDescription Get user Token
*
* @apiVersion 4.0.0
* @apiName Login
* @apiGroup Auth
*
* @apiParam {String} email User email
* @apiParam {String} password User password
* @apiParam {String} [recaptcha] Recaptcha token
*
* @apiParamExample {json} Request Example
* {
* "email": "johndoe@example.com",
* "password": "amazingpassword",
* "recaptcha": "03AOLTBLR5UtIoenazYWjaZ4AFZiv1OWegWV..."
* }
* @apiUse User
* @apiUse 400Error
* @apiUse ParamsValidationError
* @apiUse UnauthorizedError
* @apiUse UserDeactivatedError
* @apiUse CaptchaError
* @apiUse LimiterError
*/
public function login(LoginRequest $request): JsonResponse
{
$credentials = $request->only(['email', 'password', 'recaptcha']);
$this->recaptcha->check($credentials);
if (!auth()->attempt([
'email' => $credentials['email'],
'password' => $credentials['password'],
])) {
$this->recaptcha->incrementCaptchaAmounts();
$this->recaptcha->check($credentials);
throw new AuthorizationException(AuthorizationException::ERROR_TYPE_UNAUTHORIZED);
}
$user = auth()->user();
if (!$user || !$user->active) {
$this->recaptcha->incrementCaptchaAmounts();
throw new AuthorizationException(AuthorizationException::ERROR_TYPE_USER_DISABLED);
}
if ($user->invitation_sent) {
$user->invitation_sent = false;
$user->save();
}
$this->recaptcha->clearCaptchaAmounts();
if (preg_match('/' . config('auth.cattr-client-agent') . '/', $request->header('User_agent'))) {
$user->client_installed = 1;
$user->save();
}
return responder()->success([
'token' => $user->createToken(Str::uuid())->plainTextToken,
], new AuthTokenTransformer)->respond();
}
/**
* @api {post} /auth/logout Logout
* @apiDescription Invalidate current token
*
* @apiVersion 4.0.0
* @apiName Logout
* @apiGroup Auth
*
* @apiUse AuthHeader
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 204 OK
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
public function logout(Request $request): JsonResponse
{
$request->user()->currentAccessToken()->delete();
return responder()->success()->respond(204);
}
/**
* @api {post} /auth/logout-from-all Logout from all
* @apiDescription Invalidate all user tokens
*
* @apiVersion 4.0.0
* @apiName Logout all
* @apiGroup Auth
*
* @apiUse AuthHeader
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 204 OK
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
public function logoutFromAll(Request $request): JsonResponse
{
$request->user()->tokens()->delete();
return responder()->success()->respond(204);
}
/**
* @api {get} /auth/me Me
* @apiDescription Get authenticated User Entity
*
* @apiVersion 4.0.0
* @apiName Me
* @apiGroup Auth
*
* @apiUse AuthHeader
*
* @apiSuccess {Integer} id ID of the user
* @apiSuccess {String} full_name Full name of the user
* @apiSuccess {String} email Email of the user
* @apiSuccess {String} [url] URL of the user (optional)
* @apiSuccess {Integer} company_id Company ID of the user
* @apiSuccess {String} [avatar] Avatar URL of the user (optional)
* @apiSuccess {Boolean} screenshots_active Indicates if screenshots are active
* @apiSuccess {Boolean} manual_time Indicates if manual time tracking is allowed
* @apiSuccess {Integer} computer_time_popup Time interval for computer time popup
* @apiSuccess {Boolean} blur_screenshots Indicates if screenshots are blurred
* @apiSuccess {Boolean} web_and_app_monitoring Indicates if web and app monitoring is enabled
* @apiSuccess {Integer} screenshots_interval Interval for taking screenshots
* @apiSuccess {Boolean} active Indicates if the user is active
* @apiSuccess {String} [deleted_at] Deletion timestamp (if applicable, otherwise null)
* @apiSuccess {String} created_at Creation timestamp
* @apiSuccess {String} updated_at Last update timestamp
* @apiSuccess {String} [timezone] Timezone of the user (optional)
* @apiSuccess {Boolean} important Indicates if the user is marked as important
* @apiSuccess {Boolean} change_password Indicates if the user needs to change password
* @apiSuccess {Integer} role_id Role ID of the user
* @apiSuccess {String} user_language Language of the user
* @apiSuccess {String} type Type of the user (e.g., "employee")
* @apiSuccess {Boolean} invitation_sent Indicates if invitation is sent to the user
* @apiSuccess {Integer} nonce Nonce value of the user
* @apiSuccess {Boolean} client_installed Indicates if client is installed
* @apiSuccess {Boolean} permanent_screenshots Indicates if screenshots are permanent
* @apiSuccess {String} last_activity Last activity timestamp of the user
* @apiSuccess {Boolean} online Indicates if the user is online
* @apiSuccess {Boolean} can_view_team_tab Indicates if the user can view team tab
* @apiSuccess {Boolean} can_create_task Indicates if the user can create tasks
*
* @apiSuccessExample {json} Response Example:
* HTTP/1.1 200 OK
* {
* "id": 1,
* "full_name": "Admin",
* "email": "admin@cattr.app",
* "url": "",
* "company_id": 1,
* "avatar": "",
* "screenshots_active": 1,
* "manual_time": 0,
* "computer_time_popup": 300,
* "blur_screenshots": false,
* "web_and_app_monitoring": true,
* "screenshots_interval": 5,
* "active": 1,
* "deleted_at": null,
* "created_at": "2023-10-26T10:26:17.000000Z",
* "updated_at": "2024-02-15T19:06:42.000000Z",
* "timezone": null,
* "important": 0,
* "change_password": 0,
* "role_id": 0,
* "user_language": "en",
* "type": "employee",
* "invitation_sent": false,
* "nonce": 0,
* "client_installed": 0,
* "permanent_screenshots": 0,
* "last_activity": "2023-10-26 10:26:17",
* "online": false,
* "can_view_team_tab": true,
* "can_create_task": true
* }
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
public function me(Request $request): JsonResponse
{
return responder()->success($request->user())->respond();
}
/**
* @api {get} /auth/desktop-key Issue key
* @apiDescription Issues key for desktop auth
*
* @apiVersion 4.0.0
* @apiName Issue key
* @apiGroup Auth
*
* @apiUse AuthHeader
* @apiUse User
* @apiUse UnauthorizedError
*/
/**
* @param Request $request
*
* @return JsonResponse
* @throws Exception
*/
public function issueDesktopKey(Request $request): JsonResponse
{
$token = Str::random(40);
$lifetime = now()->addMinutes(config('auth.lifetime_minutes.desktop_token'));
Cache::store('octane')->put(
sha1($request->ip()) . ":$token",
$request->user()->id,
$lifetime,
);
return responder()->success([
'token' => $token,
'type' => 'desktop',
'expires' => now()->addMinutes(config('auth.lifetime_minutes.desktop_token'))->toIso8601String(),
], new AuthTokenTransformer)->meta(['frontend_uri' => config('app.frontend_url')])->respond();
}
/**
* @api {put} /auth/desktop-key Key auth
* @apiDescription Exchange desktop key to JWT
*
* @apiVersion 4.0.0
* @apiName Key auth
* @apiGroup Auth
*
* @apiUse AuthHeader
* @apiUse User
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
/**
* @param Request $request
*
* @return JsonResponse
* @throws Exception
*/
public function authDesktopKey(Request $request): JsonResponse
{
$token = $request->header('Authorization');
if (!$token) {
throw new AuthorizationException(AuthorizationException::ERROR_TYPE_UNAUTHORIZED);
}
$token = explode(' ', $token);
if (count($token) !== 2 || $token[0] !== 'desktop' || !Cache::store('octane')->has(sha1($request->ip()) . ":$token[1]")) {
throw new AuthorizationException(AuthorizationException::ERROR_TYPE_UNAUTHORIZED);
}
$user = auth()->loginUsingId(Cache::store('octane')->get(sha1($request->ip()) . ":$token[1]"));
if (!optional($user)->active) {
throw new AuthorizationException(AuthorizationException::ERROR_TYPE_USER_DISABLED);
}
return responder()->success([
'token' => $user->createToken(Str::uuid())->plainTextToken,
], new AuthTokenTransformer)->respond();
}
/**
* @apiDeprecated Exists only for compatibility with old Cattr client
* @api {post} /auth/refresh Refresh
* @apiDescription Refreshes JWT
*
* @apiVersion 4.0.0
* @apiName Refresh
* @apiGroup Auth
*
* @apiUse AuthHeader
*
* @apiSuccess {String} access_token Token
* @apiSuccess {String} token_type Token Type
* @apiSuccess {String} expires_in Token TTL 8601String Date
*
* @apiUse 400Error
* @apiUse UnauthorizedError
*/
/**
* @return JsonResponse
* @deprecated Exists only for compatibility with old Cattr client
*/
public function refresh(): JsonResponse
{
throw new DeprecatedApiException();
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Routing\Route as RouteModel;
use Illuminate\Routing\RouteCollection;
use Illuminate\Routing\Router;
use Illuminate\Support\Collection;
use Route;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class Controller extends BaseController
{
use AuthorizesRequests;
use DispatchesJobs;
use ValidatesRequests;
public static function getControllerRules(): array
{
return [];
}
public function frontendRoute(Request $request) {
return view('app');
}
/**
* Laravel router pass to fallback not non-exist urls only but wrong-method requests too.
* So required to check if route have alternative request methods
* throw not-found or wrong-method exceptions manually
* @param Request $request
*/
public function universalRoute(Request $request): void
{
/** @var Router $router */
$router = app('router');
/** @var RouteCollection $routes */
$routeCollection = $router->getRoutes();
/** @var string[] $methods */
$methods = array_diff(Router::$verbs, [$request->getMethod(), 'OPTIONS']);
foreach ($methods as $method) {
// Get all routes for method without fallback routes
/** @var Route[]|Collection $routes */
$routes = collect($routeCollection->get($method))->filter(static function ($route) {
/** @var RouteModel $route */
return !$route->isFallback && $route->uri !== '{fallbackPlaceholder}';
});
// Look if any route have match with current request
$mismatch = $routes->first(static function ($value) use ($request) {
/** @var RouteModel $value */
return $value->matches($request, false);
});
// Throw wrong-method exception if matches found
if ($mismatch !== null) {
throw new MethodNotAllowedHttpException([]);
}
}
// No matches, throw not-found exception
throw new NotFoundHttpException();
}
}

View File

@@ -0,0 +1,237 @@
<?php
namespace App\Http\Controllers;
use App\Exceptions\Entities\AuthorizationException;
use App\Helpers\Recaptcha;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset as PasswordResetEvent;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use DB;
use Mail;
use Password;
use Validator;
class PasswordResetController extends BaseController
{
public function __construct(private Recaptcha $recaptcha)
{
}
/**
* @api {get} /v1/auth/password/reset/validate Validate
* @apiDescription Validates password reset token
*
* @apiVersion 1.0.0
* @apiName Validate token
* @apiGroup Password Reset
*
* @apiParam {String} email User email
* @apiParam {String} token Password reset token
*
* @apiParamExample {json} Request Example
* {
* "email": "johndoe@example.com",
* "token": "03AOLTBLR5UtIoenazYWjaZ4AFZiv1OWegWV..."
* }
*
* @apiSuccess {String} message Message from server
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "message": "Password reset data is valid"
* }
*
* @apiUse 400Error
* @apiUse ParamsValidationError
* @apiUse InvalidPasswordResetDataError
*/
/**
* @param Request $request
* @return JsonResponse
* @throws AuthorizationException
*/
public function validate(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'email' => 'required|email',
'token' => 'required|string'
]);
if ($validator->fails()) {
throw new AuthorizationException(AuthorizationException::ERROR_TYPE_VALIDATION_FAILED);
}
$user = Password::broker()->getUser($request->all());
if (!$user) {
throw new AuthorizationException(AuthorizationException::ERROR_TYPE_INVALID_PASSWORD_RESET_DATA);
}
$isValidToken = Password::broker()->getRepository()->exists($user, $request->input('token'));
if (!$isValidToken) {
throw new AuthorizationException(AuthorizationException::ERROR_TYPE_INVALID_PASSWORD_RESET_DATA);
}
return new JsonResponse(['message' => 'Password reset data is valid']);
}
/**
* @api {post} /v1/auth/password/reset/request Request
* @apiDescription Sends email to user with reset link
*
* @apiVersion 1.0.0
* @apiName Request
* @apiGroup Password Reset
*
* @apiParam {String} login User login
* @apiParam {String} [recaptcha] Recaptcha token
*
* @apiParamExample {json} Request Example
* {
* "email": "johndoe@example.com",
* "recaptcha": "03AOLTBLR5UtIoenazYWjaZ4AFZiv1OWegWV..."
* }
*
* @apiSuccess {String} message Message from server
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "message": "Link for restore password has been sent to specified email"
* }
*
* @apiUse 400Error
* @apiUse ParamsValidationError
* @apiUse NoSuchUserError
* @apiUse CaptchaError
* @apiUse LimiterError
*/
/**
* @param Request $request
* @return JsonResponse
* @throws AuthorizationException
*/
public function request(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), ['email' => 'required|email']);
if ($validator->fails()) {
throw new AuthorizationException(AuthorizationException::ERROR_TYPE_VALIDATION_FAILED);
}
$credentials = $request->only(['email', 'recaptcha']);
$this->recaptcha->check($credentials);
$user = User::where('email', $credentials['email'])->first();
if (!$user) {
$this->recaptcha->incrementCaptchaAmounts();
$this->recaptcha->check($credentials);
throw new AuthorizationException(AuthorizationException::ERROR_TYPE_USER_NOT_FOUND);
}
$this->recaptcha->clearCaptchaAmounts();
Password::broker()->sendResetLink(['email' => $credentials['email']]);
return new JsonResponse([
'message' => 'Link for restore password has been sent to specified email',
]);
}
/**
* @api {post} /v1/auth/password/reset/process Process
* @apiDescription Resets user password
*
* @apiVersion 1.0.0
* @apiName Process
* @apiGroup Password Reset
*
* @apiParam {String} email User email
* @apiParam {String} token Password reset token
* @apiParam {String} password New password
* @apiParam {String} password_confirmation Password confirmation
*
* @apiParamExample {json} Request Example
* {
* "email": "johndoe@example.com",
* "token": "16184cf3b2510464a53c0e573c75740540fe...",
* "password_confirmation": "amazingpassword",
* "password": "amazingpassword"
* }
*
* @apiSuccess {String} access_token Token
* @apiSuccess {String} token_type Token Type
* @apiSuccess {String} expires_in Token TTL in seconds
* @apiSuccess {Object} user User Entity
*
* @apiUse UserObject
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "access_token": "16184cf3b2510464a53c0e573c75740540fe...",
* "token_type": "bearer",
* "password": "amazingpassword",
* "expires_in": "3600",
* "user": {}
* }
*
* @apiUse 400Error
* @apiUse ParamsValidationError
* @apiUse InvalidPasswordResetDataError
* @apiUse UnauthorizedError
*/
/**
* @param Request $request
* @return JsonResponse
* @throws AuthorizationException
*/
public function process(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'email' => 'required|email',
'token' => 'required|string',
'password' => 'required',
'password_confirmation' => 'required'
]);
if ($validator->fails()) {
throw new AuthorizationException(AuthorizationException::ERROR_TYPE_VALIDATION_FAILED);
}
$resetRequest = DB::table('password_resets')
->where('email', $request->input('email'))
->first();
if (!$resetRequest) {
throw new AuthorizationException(AuthorizationException::ERROR_TYPE_INVALID_PASSWORD_RESET_DATA);
}
$response = Password::broker()->reset(
$request->all(),
static function (User $user, string $password) {
$user->password = $password;
$user->save();
event(new PasswordResetEvent($user));
auth()->login($user);
}
);
if ($response !== Password::PASSWORD_RESET) {
throw new AuthorizationException(AuthorizationException::ERROR_TYPE_UNAUTHORIZED);
}
$token = auth()->setTTL(config('auth.lifetime_minutes.jwt'))->refresh();
return new JsonResponse([
'access_token' => $token,
'token_type' => 'bearer',
'expires_in' => now()->addMinutes(config('auth.lifetime_minutes.jwt'))->toIso8601String(),
'user' => auth()->user(),
]);
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers;
use App\Enums\ScreenshotsState;
use App\Models\Invitation;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Settings;
/**
* Class RegistrationController
* @codeCoverageIgnore until it is implemented on frontend
*/
class RegistrationController extends Controller
{
/**
* @param $key
* @return JsonResponse
* @api {get} /auth/register/{key} Get Form
* @apiDescription Returns invitation form data by a invitation token
*
* @apiVersion 1.0.0
* @apiName GetRegistration
* @apiGroup Invitation
*
* @apiParam (Parameters from url) {String} key User invitation key
*
* @apiSuccess {String} email UserInvited email
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "email": "test@example.com"
* }
*
* @apiErrorExample {json} Email not found
* HTTP/1.1 404 Not found
* {
* "error": "Not found"
* }
*
* @apiUse 400Error
*
*/
public function getForm($key): JsonResponse
{
$invitation = Invitation::where('key', $key)
->where('expires_at', '>=', time())
->first();
if (!isset($invitation)) {
return new JsonResponse([
'message' => __('The specified key has expired or does not exist')
], 404);
}
return responder()->success(['email' => $invitation->email])->respond();
}
/**
* Creates a new user.
*
* @param Request $request
* @param string $key
* @return JsonResponse
* @api {post} /auth/register/{key} Post Form
* @apiDescription Registers user by key
*
* @apiVersion 1.0.0
* @apiName PostRegistration
* @apiGroup Invitation
*
* @apiParam (Parameters from url) {String} key User invitation key
*
* @apiParam {String} email New user email
* @apiParam {String} password New user password
* @apiParam {String} fullName New user name
*
* @apiParamExample {json} Request Example
* {
* "email": "johndoe@example.com",
* "password": "amazingpassword",
* "fullName": "John Doe"
* }
*
* @apiSuccess {Number} user_id New user ID
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "user_id": 2
* }
*
* @apiErrorExample {json} Email not found
* HTTP/1.1 404 Not found
* {
* "message": "The specified key has expired or does not exist"
* }
*
* @apiErrorExample {json} Email mismatch
* HTTP/1.1 400 Bad request
* {
* "message": "The email address does not match the key"
* }
*
* @apiUse 400Error
*
*/
public function postForm(Request $request, string $key): JsonResponse
{
$invitation = Invitation::where('key', $key)
->where('expires_at', '>=', time())
->first();
if (!isset($invitation)) {
return new JsonResponse([
'message' => __('The specified key has expired or does not exist'),
], 404);
}
if ($request->input('email') !== $invitation->email) {
return new JsonResponse([
'message' => __('The email address does not match the key'),
], 400);
}
$language = Settings::scope('core')->get('language', 'en');
/** @var User $user */
$user = User::create([
'full_name' => $request->input('full_name'),
'email' => $request->input('email'),
'password' => $request->input('password'),
'active' => true,
'manual_time' => false,
'screenshots_state' => ScreenshotsState::REQUIRED,
'screenshots_state_locked' => true,
'computer_time_popup' => 3,
'screenshots_interval' => 10,
'role_id' => $invitation->role_id,
'user_language' => $language,
]);
$invitation->delete();
return responder()->success(['user_id' => $user->id])->respond();
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Helpers\CatHelper;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Routing\Controller;
class StatusController extends Controller
{
/**
* @api {get} /status Status
* @apiDescription Check API status
*
* @apiVersion 4.0.0
* @apiName Status
* @apiGroup Status
*
* @apiSuccess {String} cat A cat for you
* @apiSuccess {Array} modules Information about installed modules
* @apiSuccess {Array} version Information about version modules
*
* @apiSuccessExample {json} Response Example
* HTTP/1.1 200 OK
* {
* "cattr": true,
* "cat": "(=ㅇ༝ㅇ=)"
* "version": "dev"
* }
*/
/**
* @return JsonResponse
* @throws Exception
*/
public function __invoke(): JsonResponse
{
return responder()->success([
'cattr' => true,
'cat' => CatHelper::getCat(),
'version' => config('app.version'),
])->respond();
}
}

85
app/Http/Kernel.php Normal file
View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http;
use App\Http\Middleware\Authenticate;
use App\Http\Middleware\RegisterModulesEvents;
use App\Http\Middleware\SentryContext;
use App\Http\Middleware\TrimStrings;
use Illuminate\Http\Middleware\HandleCors;
use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
use Illuminate\Auth\Middleware\Authorize;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Http\Middleware\TrustProxies;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Routing\Middleware\ValidateSignature;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware are run during every request to your application.
*
* @var array
*/
protected $middleware = [
CheckForMaintenanceMode::class,
ValidatePostSize::class,
TrimStrings::class,
ConvertEmptyStringsToNull::class,
HandleCors::class,
SentryContext::class,
TrustProxies::class,
RegisterModulesEvents::class,
];
/**
* The application's route middleware groups.
*
* @var array
*/
protected $middlewareGroups = [
'web' => [
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
ShareErrorsFromSession::class,
SubstituteBindings::class,
],
'api' => [
SubstituteBindings::class,
EnsureFrontendRequestsAreStateful::class,
],
];
/**
* The application's route middleware.
*
* These middleware may be assigned to groups or used individually.
*
* @var array
*/
protected $routeMiddleware = [
'auth' => Authenticate::class,
'auth.basic' => AuthenticateWithBasicAuth::class,
'bindings' => SubstituteBindings::class,
'can' => Authorize::class,
'throttle' => ThrottleRequests::class,
'abilities' => CheckAbilities::class,
'ability' => CheckForAnyAbility::class,
'signed' => ValidateSignature::class,
];
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use App\Exceptions\Entities\AuthorizationException;
use Closure;
use Illuminate\Auth\Middleware\Authenticate as BaseAuthenticate;
use Lang;
class Authenticate extends BaseAuthenticate
{
public const DEFAULT_USER_LANGUAGE = 'en';
public function handle($request, Closure $next, ...$guards): mixed
{
$this->authenticate($request, $guards);
if (!$request->user()->active) {
$request->user()->tokens()->whereId($request->user()->currentAccessToken()->id)->delete();
throw new AuthorizationException(AuthorizationException::ERROR_TYPE_USER_DISABLED);
}
Lang::setLocale($request->user()->user_language ?: self::DEFAULT_USER_LANGUAGE);
return $next($request);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
class EncryptCookies extends Middleware
{
/**
* The names of the cookies that should not be encrypted.
*
* @var array
*/
protected $except = [
//
];
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Http\Middleware;
use App;
use App\Events\ChangeEvent;
use App\Filters\AttachmentFilter;
use App\Models\Project;
use App\Models\ProjectGroup;
use App\Models\Task;
use App\Models\TaskHistory;
use App\Models\TimeInterval;
use App\Observers\AttachmentObserver;
use CatEvent;
use Filter;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Nwidart\Modules\Facades\Module;
use Symfony\Component\HttpFoundation\Response;
class RegisterModulesEvents
{
/**
* @param Task|Project|TimeInterval|TaskHistory $model
*/
public static function broadcastEvent(string $entityType, string $action, $model): void
{
foreach (ChangeEvent::getRelatedUserIds($model) as $userId) {
broadcast(new ChangeEvent($entityType, $action, $model, $userId));
}
}
/**
* Add subscribers from modules for Event and Filter
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
// TODO:
// [ ] move to Observers folder
// [ ] rewrite with laravel Event so updates that come from modules will trigger update
CatEvent::listen('event.after.action.*', static function (string $eventName, array $data) {
$eventNameParts = explode('.', $eventName);
[$entityType, $action] = array_slice($eventNameParts, 3, 2); // Strip "event.after.action" and get the next two parts
if (!in_array($entityType, ['tasks', 'projects', 'projects_members', 'intervals', 'task_comments', 'project_groups'])) {
return;
}
if (!in_array($action, ['create', 'edit', 'destroy'])) {
return;
}
if ($entityType === 'projects_members') {
$entityType = 'projects';
$projectId = $data[0];
$model = Project::query()->find($projectId);
} elseif ($entityType === 'task_comments') {
$entityType = 'tasks_activities';
$model = $data[0];
} elseif ($entityType === 'project_groups') {
$entityType = 'projects';
$action = 'edit';
/** @var ProjectGroup $group */
$group = $data[0];
$model = $group->projects()->get();
} else {
$model = $data[0];
}
App::terminating(static function () use ($entityType, $action, $model) {
$items = is_array($model) || $model instanceof Collection ? $model : [$model];
foreach ($items as $item) {
static::broadcastEvent($entityType, $action, $item);
if (in_array($entityType, ['tasks', 'projects'])) {
$project = match (true) {
$item instanceof Task => $item->project,
$item instanceof Project => $item,
};
static::broadcastEvent('gantt', 'updateAll', $project);
}
}
});
});
// CatEvent and Filter are scoped to request, subscribe on every request for it to work with laravel octane
Filter::subscribe(AttachmentFilter::class);
CatEvent::subscribe(AttachmentObserver::class);
collect(Module::allEnabled())->each(static function (\Nwidart\Modules\Module $module) {
App::call([preg_grep("/ModuleServiceProvider$/i", $module->get('providers'))[0], 'registerEvents']);
});
return $next($request);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Middleware;
use App\Enums\Role;
use Closure;
use Illuminate\Http\Request;
use Route;
use Sentry\State\Scope;
use Throwable;
use function Sentry\configureScope;
class SentryContext
{
/**
* Handle an incoming request.
*
* @param Request $request
* @param Closure $next
*
* @return mixed
*/
public function handle(Request $request, Closure $next): mixed
{
if (env('IMAGE_VERSION')) {
configureScope(static function (Scope $scope): void {
$scope->setTag('docker', env('IMAGE_VERSION'));
});
}
if ($user = $request->user()) {
configureScope(static function (Scope $scope) use ($user): void {
$scope->setUser([
'id' => $user->id,
'email' => config('sentry.send_default_pii') ? $user->email : sha1($user->email),
'role' => is_int($user->role_id) ? Role::tryFrom($user->role_id)?->name :$user->role_id->name,
]);
});
}
configureScope(static function (Scope $scope) use ($request): void {
$scope->setTag('request.host', $request->host());
$scope->setTag('request.method', $request->method());
try {
$scope->setTag('request.route', Route::getRoutes()->match($request)->getName());
} catch (Throwable) {
}
});
return $next($request);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\TrimStrings as BaseTrimmer;
class TrimStrings extends BaseTrimmer
{
/**
* The names of the attributes that should not be trimmed.
*
* @var array
*/
protected $except = [
'password',
'password_confirmation',
];
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as BaseVerifier;
class VerifyCsrfToken extends BaseVerifier
{
/**
* The URIs that should be excluded from CSRF verification.
*
* @var array
*/
protected $except = [
'/api/*'
];
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Attachment;
use App\Helpers\AttachmentHelper;
use App\Http\Requests\AuthorizesAfterValidation;
use App\Http\Requests\CattrFormRequest;
class CreateAttachmentRequest extends CattrFormRequest
{
use AuthorizesAfterValidation;
public function authorizeValidated(): bool
{
return true;
}
public function _rules(): array
{
$maxFileSize = AttachmentHelper::getMaxAllowedFileSize();
return [
'attachment' => "file|required|max:$maxFileSize",
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Attachment;
use App\Http\Requests\AuthorizesAfterValidation;
use App\Http\Requests\CattrFormRequest;
class DownloadAttachmentRequest extends CattrFormRequest
{
use AuthorizesAfterValidation;
public function authorizeValidated(): bool
{
return $this->user()->can('view', request('attachment')->project);
}
public function _rules(): array
{
return [
'seconds' => 'sometimes|int'
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Auth;
use App\Http\Requests\CattrFormRequest;
class LoginRequest extends CattrFormRequest
{
public function _authorize(): bool
{
return true;
}
public function _rules(): array
{
return [
'email' => 'required',
'password' => 'required',
'recaptcha' => 'sometimes|nullable|string'
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests;
use App\Helpers\FilterDispatcher;
use Filter;
trait AuthorizesAfterValidation
{
/**
* @return bool
*/
public function _authorize(): bool
{
return true;
}
/**
* @param $validator
*/
public function withValidator($validator): void
{
$validator->after(function ($validator) {
if (! $validator->failed() && ! Filter::process(Filter::getAuthValidationFilterName(), $this->authorizeValidated())) {
$this->failedAuthorization();
}
});
}
/**
* @return mixed
*/
abstract public function authorizeValidated(): mixed;
}

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