diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 4f14f22926d7..f77695f6b35b 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -20,6 +20,7 @@ use App\Jobs\Ninja\AdjustEmailQuota; use App\Jobs\Ninja\CompanySizeCheck; use App\Jobs\Ninja\QueueSize; use App\Jobs\Ninja\SystemMaintenance; +use App\Jobs\Ninja\TaskScheduler; use App\Jobs\Util\DiskCleanup; use App\Jobs\Util\ReminderJob; use App\Jobs\Util\SchedulerCheck; @@ -69,19 +70,21 @@ class Kernel extends ConsoleKernel $schedule->job(new RecurringExpensesCron)->dailyAt('00:10')->withoutOverlapping(); - $schedule->job(new AutoBillCron)->dailyAt('06:00')->withoutOverlapping(); + $schedule->job(new AutoBillCron)->dailyAt('06:00')->withoutOverlapping(); $schedule->job(new SchedulerCheck)->daily()->withoutOverlapping(); - $schedule->job(new SystemMaintenance)->weekly()->withoutOverlapping(); + $schedule->job(new TaskScheduler())->daily()->withoutOverlapping(); + $schedule->job(new SystemMaintenance)->weekly()->withoutOverlapping(); if(Ninja::isSelfHost()) { + $schedule->call(function () { Account::whereNotNull('id')->update(['is_scheduler_running' => true]); - })->everyFiveMinutes(); - + })->everyFiveMinutes(); + } /* Run hosted specific jobs */ @@ -99,12 +102,12 @@ class Kernel extends ConsoleKernel } - if(config('queue.default') == 'database' && Ninja::isSelfHost() && config('ninja.internal_queue_enabled') && !config('ninja.is_docker')) { + if (config('queue.default') == 'database' && Ninja::isSelfHost() && config('ninja.internal_queue_enabled') && !config('ninja.is_docker')) { $schedule->command('queue:work database --stop-when-empty')->everyMinute()->withoutOverlapping(); - $schedule->command('queue:restart')->everyFiveMinutes()->withoutOverlapping(); - + $schedule->command('queue:restart')->everyFiveMinutes()->withoutOverlapping(); + } } @@ -116,7 +119,7 @@ class Kernel extends ConsoleKernel */ protected function commands() { - $this->load(__DIR__.'/Commands'); + $this->load(__DIR__ . '/Commands'); require base_path('routes/console.php'); } diff --git a/app/Http/Controllers/OpenAPI/TaskSchedulerSchema.php b/app/Http/Controllers/OpenAPI/TaskSchedulerSchema.php new file mode 100644 index 000000000000..7976fd475544 --- /dev/null +++ b/app/Http/Controllers/OpenAPI/TaskSchedulerSchema.php @@ -0,0 +1,51 @@ +scheduler_repository = $scheduler_repository; + } + + /** + * @OA\GET( + * path="/api/v1/task_scheduler/", + * operationId="getTaskSchedulers", + * tags={"task_scheduler"}, + * summary="Task Scheduler Index", + * description="Get all schedulers with associated jobs", + * @OA\Parameter(ref="#/components/parameters/X-Api-Secret"), + * @OA\Parameter(ref="#/components/parameters/X-Requested-With"), + * @OA\Response( + * response=200, + * description="success", + * @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"), + * @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"), + * @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"), + * ), + * @OA\Response( + * response="default", + * description="Unexpected Error", + * @OA\JsonContent(ref="#/components/schemas/Error"), + * ), + * ) + */ + + public function index() + { + set_time_limit(45); + + $schedulers = Scheduler::where('company_id', auth()->user()->company()->id); + + return $this->listResponse($schedulers); + + } + + /** + * @OA\Post( + * path="/api/v1/task_scheduler/", + * operationId="createTaskScheduler", + * tags={"task_scheduler"}, + * summary="Create task scheduler with job ", + * description="Create task scheduler with a job (action(job) request should be sent via request also. Example: We want client report to be job which will be run + * multiple times, we should send the same parameters in the request as we would send if we wanted to get report, see example", + * @OA\Parameter(ref="#/components/parameters/X-Api-Secret"), + * @OA\Parameter(ref="#/components/parameters/X-Requested-With"), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent(ref="#/components/schemas/TaskSchedulerSchema") + * ), + * @OA\Response( + * response=200, + * description="success", + * @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"), + * @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"), + * @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"), + * ), + * @OA\Response( + * response=422, + * description="Validation error", + * @OA\JsonContent(ref="#/components/schemas/ValidationError"), + * ), + * @OA\Response( + * response="default", + * description="Unexpected Error", + * @OA\JsonContent(ref="#/components/schemas/Error"), + * ), + * ) + */ + + public function store(CreateScheduledTaskRequest $request) + { + $scheduler = new Scheduler(); + $scheduler->service()->store($scheduler, $request); + return $this->itemResponse($scheduler); + } + + /** + * @OA\GET( + * path="/api/v1/task_scheduler/{scheduler}", + * operationId="showTaskScheduler", + * tags={"task_scheduler"}, + * summary="Show given scheduler", + * description="Get scheduler with associated job", + * @OA\Parameter(ref="#/components/parameters/X-Api-Secret"), + * @OA\Parameter(ref="#/components/parameters/X-Requested-With"), + * @OA\Response( + * response=200, + * description="success", + * @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"), + * @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"), + * @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"), + * ), + * @OA\Response( + * response="default", + * description="Unexpected Error", + * @OA\JsonContent(ref="#/components/schemas/Error"), + * ), + * ) + */ + + public function show(Scheduler $scheduler) + { + return $this->itemResponse($scheduler); + } + + /** + * @OA\PUT( + * path="/api/v1/task_scheduler/{scheduler}", + * operationId="updateTaskScheduler", + * tags={"task_scheduler"}, + * summary="Update task scheduler ", + * description="Update task scheduler", + * @OA\Parameter(ref="#/components/parameters/X-Api-Secret"), + * @OA\Parameter(ref="#/components/parameters/X-Requested-With"), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent(ref="#/components/schemas/UpdateTaskSchedulerSchema") + * ), + * @OA\Response( + * response=200, + * description="success", + * @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"), + * @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"), + * @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"), + * ), + * @OA\Response( + * response=422, + * description="Validation error", + * @OA\JsonContent(ref="#/components/schemas/ValidationError"), + * ), + * @OA\Response( + * response="default", + * description="Unexpected Error", + * @OA\JsonContent(ref="#/components/schemas/Error"), + * ), + * ) + */ + public function update(Scheduler $scheduler, UpdateScheduleRequest $request) + { + $scheduler->service()->update($scheduler, $request); + return $this->itemResponse($scheduler); + } + + /** + * @OA\PUT( + * path="/api/v1/task_scheduler/{scheduler}/update_job/", + * operationId="updateTaskSchedulerJob", + * tags={"task_scheduler"}, + * summary="Update job for a task scheduler ", + * description="Update job for a task scheduler | if we are changing action for a job, we should send the request for a new job same as we are creating new scheduler", + * @OA\Parameter(ref="#/components/parameters/X-Api-Secret"), + * @OA\Parameter(ref="#/components/parameters/X-Requested-With"), + * @OA\RequestBody( + * required=true, + * @OA\JsonContent(ref="#/components/schemas/UpdateJobForASchedulerSchema") + * ), + * @OA\Response( + * response=200, + * description="success", + * @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"), + * @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"), + * @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"), + * ), + * @OA\Response( + * response=422, + * description="Validation error", + * @OA\JsonContent(ref="#/components/schemas/ValidationError"), + * ), + * @OA\Response( + * response="default", + * description="Unexpected Error", + * @OA\JsonContent(ref="#/components/schemas/Error"), + * ), + * ) + */ + public function updateJob(Scheduler $scheduler, UpdateScheduledJobRequest $request) + { + $scheduler->service()->updateJob($scheduler, $request); + return $this->itemResponse($scheduler); + } + + /** + * @OA\DELETE( + * path="/api/v1/task_scheduler/{scheduler}", + * operationId="destroyTaskScheduler", + * tags={"task_scheduler"}, + * summary="Destroy Task Scheduler", + * description="Destroy task scheduler and its associated job", + * @OA\Parameter(ref="#/components/parameters/X-Api-Secret"), + * @OA\Parameter(ref="#/components/parameters/X-Requested-With"), + * @OA\Response( + * response=200, + * description="success", + * @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"), + * @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"), + * @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"), + * ), + * @OA\Response( + * response="default", + * description="Unexpected Error", + * @OA\JsonContent(ref="#/components/schemas/Error"), + * ), + * ) + */ + public function destroy(Scheduler $scheduler) + { + $this->scheduler_repository->delete($scheduler); + return $this->itemResponse($scheduler->fresh()); + } + + +} diff --git a/app/Http/Requests/TaskScheduler/CreateScheduledTaskRequest.php b/app/Http/Requests/TaskScheduler/CreateScheduledTaskRequest.php new file mode 100644 index 000000000000..bde04e199ced --- /dev/null +++ b/app/Http/Requests/TaskScheduler/CreateScheduledTaskRequest.php @@ -0,0 +1,30 @@ +user()->isAdmin(); + } + + public function rules() + { + return [ + 'paused' => 'sometimes|bool', + 'repeat_every' => 'required|string|in:DAY,WEEK,MONTH,3MONTHS,YEAR', + 'start_from' => 'sometimes|string', + 'job' => 'required', + ]; + } +} diff --git a/app/Http/Requests/TaskScheduler/UpdateScheduleRequest.php b/app/Http/Requests/TaskScheduler/UpdateScheduleRequest.php new file mode 100644 index 000000000000..643edbadee32 --- /dev/null +++ b/app/Http/Requests/TaskScheduler/UpdateScheduleRequest.php @@ -0,0 +1,53 @@ +user()->isAdmin(); + } + + public function rules(): array + { + return [ + 'paused' => 'sometimes|bool', + 'repeat_every' => 'sometimes|string|in:DAY,WEEK,BIWEEKLY,MONTH,3MONTHS,YEAR', + 'start_from' => 'sometimes', + 'scheduled_run'=>'sometimes' + ]; + } + + public function prepareForValidation() + { + $request = $this->all(); + + if (isset($request['start_from'])) { + $request['scheduled_run'] = Carbon::parse((int)$request['start_from']); + $request['start_from'] = Carbon::parse((int)$request['start_from']); + } + + $this->replace($request); + } +} diff --git a/app/Http/Requests/TaskScheduler/UpdateScheduledJobRequest.php b/app/Http/Requests/TaskScheduler/UpdateScheduledJobRequest.php new file mode 100644 index 000000000000..96e3ec47bbfd --- /dev/null +++ b/app/Http/Requests/TaskScheduler/UpdateScheduledJobRequest.php @@ -0,0 +1,28 @@ +user()->isAdmin(); + } + + public function rules(): array + { + return [ + 'action_name' => 'sometimes|string', + ]; + } +} diff --git a/app/Jobs/Ninja/TaskScheduler.php b/app/Jobs/Ninja/TaskScheduler.php new file mode 100644 index 000000000000..af7f74efaf17 --- /dev/null +++ b/app/Jobs/Ninja/TaskScheduler.php @@ -0,0 +1,129 @@ +fetchJobs(); + foreach ($pending_schedulers as $scheduler) { + $this->doJob($scheduler); + } + } + } + + private function doJob(Scheduler $scheduler) + { + $job = $scheduler->job; + + $company = Company::find($job->company_id); + if (!$job || !$company) { + return; + } + $parameters = $job->parameters; + + + switch ($job->action_name) { + case ScheduledJob::CREATE_CLIENT_REPORT: + SendToAdmin::dispatch($company, $parameters, $job->action_class, 'contacts.csv'); + break; + case ScheduledJob::CREATE_CLIENT_CONTACT_REPORT: + SendToAdmin::dispatch($company, $parameters, $job->action_class, 'clients.csv'); + break; + case ScheduledJob::CREATE_CREDIT_REPORT: + SendToAdmin::dispatch($company, $parameters, $job->action_class, 'credits.csv'); + break; + case ScheduledJob::CREATE_DOCUMENT_REPORT: + SendToAdmin::dispatch($company, $parameters, $job->action_class, 'documents.csv'); + break; + case ScheduledJob::CREATE_EXPENSE_REPORT: + SendToAdmin::dispatch($company, $parameters, $job->action_class, 'expense.csv'); + break; + case ScheduledJob::CREATE_INVOICE_ITEM_REPORT: + SendToAdmin::dispatch($company, $parameters, $job->action_class, 'invoice_items.csv'); + break; + case ScheduledJob::CREATE_INVOICE_REPORT: + SendToAdmin::dispatch($company, $parameters, $job->action_class, 'invoices.csv'); + break; + case ScheduledJob::CREATE_PAYMENT_REPORT: + SendToAdmin::dispatch($company, $parameters, $job->action_class, 'payments.csv'); + break; + case ScheduledJob::CREATE_PRODUCT_REPORT: + SendToAdmin::dispatch($company, $parameters, $job->action_class, 'products.csv'); + break; + case ScheduledJob::CREATE_PROFIT_AND_LOSS_REPORT: + SendToAdmin::dispatch($company, $parameters, $job->action_class, 'profit_and_loss.csv'); + break; + case ScheduledJob::CREATE_QUOTE_ITEM_REPORT: + SendToAdmin::dispatch($company, $parameters, $job->action_class, 'quote_items.csv'); + break; + case ScheduledJob::CREATE_QUOTE_REPORT: + SendToAdmin::dispatch($company, $parameters, $job->action_class, 'quotes.csv'); + break; + case ScheduledJob::CREATE_RECURRING_INVOICE_REPORT: + SendToAdmin::dispatch($company, $parameters, $job->action_class, 'recurring_invoices.csv'); + break; + case ScheduledJob::CREATE_TASK_REPORT: + SendToAdmin::dispatch($company, $parameters, $job->action_class, 'tasks.csv'); + break; + + } + + $scheduler->scheduled_run = $scheduler->nextScheduledDate(); + $scheduler->save(); + } + + + private function fetchJobs() + { + return Scheduler::where('paused', false) + ->where('is_deleted', false) + ->whereDate('scheduled_run', '<=', Carbon::now()) + ->cursor(); + } + +} diff --git a/app/Models/ScheduledJob.php b/app/Models/ScheduledJob.php new file mode 100644 index 000000000000..a2baedebb226 --- /dev/null +++ b/app/Models/ScheduledJob.php @@ -0,0 +1,49 @@ + 'array' + ]; +} diff --git a/app/Models/Scheduler.php b/app/Models/Scheduler.php new file mode 100644 index 000000000000..38943cb709b3 --- /dev/null +++ b/app/Models/Scheduler.php @@ -0,0 +1,123 @@ + 'timestamp', + 'scheduled_run' => 'timestamp', + 'created_at' => 'timestamp', + 'updated_at' => 'timestamp', + 'deleted_at' => 'timestamp', + 'paused' => 'boolean', + 'is_deleted' => 'boolean', + ]; + protected $appends = ['linked_job']; + + const DAILY = 'DAY'; + const WEEKLY = 'WEEK'; + const BIWEEKLY = 'BIWEEKLY'; + const MONTHLY = 'MONTH'; + const QUARTERLY = '3MONTHS'; + const ANNUALLY = 'YEAR'; + + public function getLinkedJobAttribute() + { + return $this->job ?? []; + } + + /** + * Service entry points. + */ + public function service(): TaskSchedulerService + { + return new TaskSchedulerService($this); + } + + public function job(): \Illuminate\Database\Eloquent\Relations\HasOne + { + return $this->hasOne(ScheduledJob::class, 'scheduler_id', 'id'); + } + + public function company(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(Company::class); + } + + + public function nextScheduledDate(): ?Carbon + { + + $offset = 0; + + $entity_send_time = $this->company->settings->entity_send_time; + + if ($entity_send_time != 0) { + $timezone = $this->company->timezone(); + + $offset -= $timezone->utc_offset; + $offset += ($entity_send_time * 3600); + } + + /* + As we are firing at UTC+0 if our offset is negative it is technically firing the day before so we always need + to add ON a day - a day = 86400 seconds + */ + + if ($offset < 0) + $offset += 86400; + + switch ($this->repeat_every) { + case self::DAILY: + return Carbon::parse($this->scheduled_run)->startOfDay()->addDay()->addSeconds($offset); + case self::WEEKLY: + return Carbon::parse($this->scheduled_run)->startOfDay()->addWeek()->addSeconds($offset); + case self::BIWEEKLY: + return Carbon::parse($this->scheduled_run)->startOfDay()->addWeeks(2)->addSeconds($offset); + case self::MONTHLY: + return Carbon::parse($this->scheduled_run)->startOfDay()->addMonthNoOverflow()->addSeconds($offset); + case self::QUARTERLY: + return Carbon::parse($this->scheduled_run)->startOfDay()->addMonthsNoOverflow(3)->addSeconds($offset); + case self::ANNUALLY: + return Carbon::parse($this->scheduled_run)->startOfDay()->addYearNoOverflow()->addSeconds($offset); + default: + return null; + } + } +} diff --git a/app/Repositories/TaskSchedulerRepository.php b/app/Repositories/TaskSchedulerRepository.php new file mode 100644 index 000000000000..5c5b3145f9cf --- /dev/null +++ b/app/Repositories/TaskSchedulerRepository.php @@ -0,0 +1,18 @@ +scheduler = $scheduler; + } + + public function store(Scheduler $scheduler, CreateScheduledTaskRequest $request) + { + $scheduler->paused = $request->get('paused', false); + $scheduler->start_from = $request->get('start_from') ? Carbon::parse((int)$request->get('start_from')) : Carbon::now(); + $scheduler->repeat_every = $request->get('repeat_every'); + $scheduler->scheduled_run = $request->get('start_from') ? Carbon::parse((int)$request->get('start_from')) : Carbon::now();; + $scheduler->company_id = auth()->user()->company()->id; + $scheduler->save(); + $this->createJob($request, $scheduler); + + } + + public function update(Scheduler $scheduler, UpdateScheduleRequest $request) + { + + $data = $request->validated(); + + $update = $this->scheduler->update($data); + if ($update) { + return response(['successfully_updated_scheduler'], 200); + } + return response(['failed_to_update_scheduler'], 400); + } + + public function createJob(CreateScheduledTaskRequest $request, Scheduler $scheduler): bool + { + $job = new ScheduledJob(); + $job = $this->setJobParameters($job, $request); + $job->scheduler_id = $scheduler->id; + $job->company_id = auth()->user()->company()->id; + return $job->save(); + + } + + public function setJobParameters(ScheduledJob $job, $request): ScheduledJob + { + switch ($request->job) { + case ScheduledJob::CREATE_CLIENT_REPORT: + $rules = (new GenericReportRequest)->rules(); + //custom rules for example here we require date_range but in genericRequest class we don't + $rules['date_range'] = 'string|required'; + $validatedJobData = $request->validate($rules); + $job->action_name = ScheduledJob::CREATE_CLIENT_REPORT; + $job->action_class = $this->getClassPath(ClientExport::class); + $job->parameters = $validatedJobData; + break; + case ScheduledJob::CREATE_CLIENT_CONTACT_REPORT: + $rules = (new GenericReportRequest)->rules(); + $validatedJobData = $request->validate($rules); + $job->action_name = ScheduledJob::CREATE_CLIENT_CONTACT_REPORT; + $job->action_class = $this->getClassPath(ContactExport::class); + $job->parameters = $validatedJobData; + break; + case ScheduledJob::CREATE_CREDIT_REPORT: + $rules = (new GenericReportRequest)->rules(); + $validatedJobData = $request->validate($rules); + $job->action_name = ScheduledJob::CREATE_CREDIT_REPORT; + $job->action_class = $this->getClassPath(CreditExport::class); + $job->parameters = $validatedJobData; + break; + case ScheduledJob::CREATE_DOCUMENT_REPORT: + $rules = (new GenericReportRequest)->rules(); + $validatedJobData = $request->validate($rules); + $job->action_name = ScheduledJob::CREATE_DOCUMENT_REPORT; + $job->action_class = $this->getClassPath(DocumentExport::class); + $job->parameters = $validatedJobData; + break; + case ScheduledJob::CREATE_EXPENSE_REPORT: + $rules = (new GenericReportRequest)->rules(); + $validatedJobData = $request->validate($rules); + $job->action_name = ScheduledJob::CREATE_EXPENSE_REPORT; + $job->action_class = $this->getClassPath(ExpenseExport::class); + $job->parameters = $validatedJobData; + break; + case ScheduledJob::CREATE_INVOICE_ITEM_REPORT: + $rules = (new GenericReportRequest)->rules(); + $validatedJobData = $request->validate($rules); + $job->action_name = ScheduledJob::CREATE_INVOICE_ITEM_REPORT; + $job->action_class = $this->getClassPath(InvoiceItemExport::class); + $job->parameters = $validatedJobData; + break; + case ScheduledJob::CREATE_INVOICE_REPORT: + $rules = (new GenericReportRequest)->rules(); + $validatedJobData = $request->validate($rules); + $job->action_name = ScheduledJob::CREATE_INVOICE_REPORT; + $job->action_class = $this->getClassPath(InvoiceExport::class); + $job->parameters = $validatedJobData; + break; + case ScheduledJob::CREATE_PAYMENT_REPORT: + $rules = (new GenericReportRequest)->rules(); + $validatedJobData = $request->validate($rules); + $job->action_name = ScheduledJob::CREATE_PAYMENT_REPORT; + $job->action_class = $this->getClassPath(PaymentExport::class); + $job->parameters = $validatedJobData; + break; + case ScheduledJob::CREATE_PRODUCT_REPORT: + $rules = (new GenericReportRequest)->rules(); + $validatedJobData = $request->validate($rules); + $job->action_name = ScheduledJob::CREATE_PRODUCT_REPORT; + $job->action_class = $this->getClassPath(ProductExport::class); + $job->parameters = $validatedJobData; + break; + case ScheduledJob::CREATE_PROFIT_AND_LOSS_REPORT: + $rules = (new ProfitLossRequest())->rules(); + $validatedJobData = $request->validate($rules); + $job->action_name = ScheduledJob::CREATE_PROFIT_AND_LOSS_REPORT; + $job->action_class = $this->getClassPath(ProfitAndLoss::class); + $job->parameters = $validatedJobData; + break; + case ScheduledJob::CREATE_QUOTE_ITEM_REPORT: + $rules = (new GenericReportRequest)->rules(); + $validatedJobData = $request->validate($rules); + $job->action_name = ScheduledJob::CREATE_QUOTE_ITEM_REPORT; + $job->action_class = $this->getClassPath(QuoteItemExport::class); + $job->parameters = $validatedJobData; + break; + case ScheduledJob::CREATE_QUOTE_REPORT: + $rules = (new GenericReportRequest)->rules(); + $validatedJobData = $request->validate($rules); + $job->action_name = ScheduledJob::CREATE_QUOTE_REPORT; + $job->action_class = $this->getClassPath(QuoteExport::class); + $job->parameters = $validatedJobData; + break; + case ScheduledJob::CREATE_RECURRING_INVOICE_REPORT: + $rules = (new GenericReportRequest)->rules(); + $validatedJobData = $request->validate($rules); + $job->action_name = ScheduledJob::CREATE_RECURRING_INVOICE_REPORT; + $job->action_class = $this->getClassPath(RecurringInvoiceExport::class); + $job->parameters = $validatedJobData; + break; + case ScheduledJob::CREATE_TASK_REPORT: + $rules = (new GenericReportRequest)->rules(); + $validatedJobData = $request->validate($rules); + $job->action_name = ScheduledJob::CREATE_TASK_REPORT; + $job->action_class = $this->getClassPath(TaskExport::class); + $job->parameters = $validatedJobData; + break; + + } + return $job; + } + + public function getClassPath($class): string + { + return $class = is_object($class) ? get_class($class) : $class; + } + + + public function updateJob(Scheduler $scheduler, UpdateScheduledJobRequest $request) + { + $job = $scheduler->job; + if (!$job) { + return abort(404); + } + $job = $this->setJobParameters($job, $request); + $job->save(); + + } +} diff --git a/app/Transformers/ScheduledJobTransformer.php b/app/Transformers/ScheduledJobTransformer.php new file mode 100644 index 000000000000..2028fd2e0dcd --- /dev/null +++ b/app/Transformers/ScheduledJobTransformer.php @@ -0,0 +1,30 @@ + $this->encodePrimaryKey($job->id), + 'action_name' => (string)$job->action_name, + 'parameters' => (array)$job->parameters + ]; + } +} diff --git a/app/Transformers/TaskSchedulerTransformer.php b/app/Transformers/TaskSchedulerTransformer.php new file mode 100644 index 000000000000..bc36393e2db0 --- /dev/null +++ b/app/Transformers/TaskSchedulerTransformer.php @@ -0,0 +1,48 @@ +serializer); + + return $this->item($scheduler->job, $transformer, ScheduledJob::class); + } + + public function transform(Scheduler $scheduler) + { + return [ + 'id' => $this->encodePrimaryKey($scheduler->id), + 'paused' => (bool)$scheduler->paused, + 'repeat_every' => (string)$scheduler->repeat_every, + 'start_from' => (int)$scheduler->start_from, + 'scheduled_run' => (int)$scheduler->scheduled_run, + 'updated_at' => (int)$scheduler->updated_at, + 'created_at' => (int)$scheduler->created_at, + 'archived_at' => (int) $scheduler->deleted_at, + ]; + } + +} diff --git a/database/migrations/2022_05_18_162152_create_scheduled_jobs_table.php b/database/migrations/2022_05_18_162152_create_scheduled_jobs_table.php new file mode 100644 index 000000000000..80fcf410f499 --- /dev/null +++ b/database/migrations/2022_05_18_162152_create_scheduled_jobs_table.php @@ -0,0 +1,46 @@ +id(); + $table->string('action_name'); + $table->string('action_class'); + $table->json('parameters')->nullable(); + $table->foreignIdFor(\App\Models\Company::class); + $table->foreignIdFor(\App\Models\Scheduler::class); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('scheduled_jobs'); + } +} diff --git a/database/migrations/2022_05_18_162443_create_schedulers_table.php b/database/migrations/2022_05_18_162443_create_schedulers_table.php new file mode 100644 index 000000000000..72e3fe743a0c --- /dev/null +++ b/database/migrations/2022_05_18_162443_create_schedulers_table.php @@ -0,0 +1,47 @@ +id(); + $table->boolean('paused')->default(false); + $table->boolean('is_deleted')->default(false); + $table->string('repeat_every'); + $table->timestamp('start_from'); + $table->timestamp('scheduled_run'); + $table->foreignIdFor(\App\Models\Company::class); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('schedulers'); + } +} diff --git a/routes/api.php b/routes/api.php index 158fe34c2fa3..de8bbf546188 100644 --- a/routes/api.php +++ b/routes/api.php @@ -45,8 +45,8 @@ Route::group(['middleware' => ['throttle:100,1', 'api_db', 'token_auth', 'locale Route::post('filters/{entity}', 'FilterController@index')->name('filters'); - Route::resource('client_gateway_tokens', 'ClientGatewayTokenController'); - + Route::resource('client_gateway_tokens', 'ClientGatewayTokenController'); + Route::post('connected_account', 'ConnectedAccountController@index'); Route::post('connected_account/gmail', 'ConnectedAccountController@handleGmailOauth'); @@ -54,9 +54,9 @@ Route::group(['middleware' => ['throttle:100,1', 'api_db', 'token_auth', 'locale Route::post('companies/purge/{company}', 'MigrationController@purgeCompany')->middleware('password_protected'); Route::post('companies/purge_save_settings/{company}', 'MigrationController@purgeCompanySaveSettings')->middleware('password_protected'); - + Route::resource('companies', 'CompanyController'); // name = (companies. index / create / show / update / destroy / edit - + Route::put('companies/{company}/upload', 'CompanyController@upload'); Route::post('companies/{company}/default', 'CompanyController@default'); @@ -170,6 +170,10 @@ Route::group(['middleware' => ['throttle:100,1', 'api_db', 'token_auth', 'locale Route::post('reports/tasks', 'Reports\TaskReportController'); Route::post('reports/profitloss', 'Reports\ProfitAndLossController'); + + Route::resource('task_scheduler', 'TaskSchedulerController')->except('edit')->parameters(['task_scheduler' => 'scheduler']); + Route::put('task_scheduler/{scheduler}/update_job','TaskSchedulerController@updateJob'); + Route::get('scheduler', 'SchedulerController@index'); Route::post('support/messages/send', 'Support\Messages\SendingController'); diff --git a/tests/Feature/Scheduler/SchedulerTest.php b/tests/Feature/Scheduler/SchedulerTest.php new file mode 100644 index 000000000000..d9a616e564e5 --- /dev/null +++ b/tests/Feature/Scheduler/SchedulerTest.php @@ -0,0 +1,163 @@ +faker = \Faker\Factory::create(); + + Model::reguard(); + + $this->makeTestData(); + + + $this->withoutMiddleware( + ThrottleRequests::class + ); + } + + public function testSchedulerCantBeCreatedWithWrongData() + { + $data = [ + 'repeat_every' => Scheduler::DAILY, + 'job' => ScheduledJob::CREATE_CLIENT_REPORT, + 'date_key' => '123', + 'report_keys' => ['test'], + // 'date_range' => 'all', + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->post('/api/v1/task_scheduler/', $data); + + $response->assertSessionHasErrors(); + + } + + public function testSchedulerCanBeUpdated() + { + $this->createScheduler(); + + + $scheduler = Scheduler::first(); + $updateData = [ + 'start_from' => 1655934741 + ]; + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->put('/api/v1/task_scheduler/' . $this->encodePrimaryKey($scheduler->id), $updateData); + + $responseData = $response->json(); + $this->assertEquals($updateData['start_from'], $responseData['data']['start_from']); + } + + public function testSchedulerCanBeSeen() + { + $this->createScheduler(); + + + $scheduler = Scheduler::first(); + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->get('/api/v1/task_scheduler/' . $this->encodePrimaryKey($scheduler->id)); + + $arr = $response->json(); + $this->assertEquals('create_client_report', $arr['data']['job']['action_name']); + + + } + + public function testSchedulerCanBeDeleted() + { + $this->createScheduler(); + + $scheduler = Scheduler::first(); + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->delete('/api/v1/task_scheduler/' . $this->encodePrimaryKey($scheduler->id)); + + $this->assertEquals(0, Scheduler::count()); + + } + + public function testSchedulerJobCanBeUpdated() + { + $this->createScheduler(); + + $scheduler = Scheduler::first(); + $this->assertSame('create_client_report', $scheduler->job->action_name); + + $updateData = [ + 'job' => ScheduledJob::CREATE_CREDIT_REPORT, + 'date_range' => 'all', + 'report_keys' => ['test1'] + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->put('/api/v1/task_scheduler/' . $this->encodePrimaryKey($scheduler->id) . '/update_job', $updateData); + + $updatedSchedulerJob = Scheduler::first()->job->action_name; + $arr = $response->json(); + $this->assertSame('create_credit_report', $arr['data']['job']['action_name']); + } + + public function testSchedulerCanBeCreated() + { + $response = $this->createScheduler(); + + $all_schedulers = Scheduler::count(); + + $this->assertSame(1, $all_schedulers); + + $response->assertStatus(200); + + } + + public function createScheduler() + { + $data = [ + 'repeat_every' => Scheduler::DAILY, + 'job' => ScheduledJob::CREATE_CLIENT_REPORT, + 'date_key' => '123', + 'report_keys' => ['test'], + 'date_range' => 'all', + ]; + + return $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->post('/api/v1/task_scheduler/', $data); + } +}