diff --git a/app/Http/Controllers/PreviewController.php b/app/Http/Controllers/PreviewController.php index 0f94c8e9ac1a..596024f30368 100644 --- a/app/Http/Controllers/PreviewController.php +++ b/app/Http/Controllers/PreviewController.php @@ -11,42 +11,43 @@ namespace App\Http\Controllers; -use App\DataMapper\Analytics\LivePreview; -use App\Factory\CreditFactory; -use App\Factory\InvoiceFactory; -use App\Factory\QuoteFactory; -use App\Factory\RecurringInvoiceFactory; -use App\Http\Requests\Preview\DesignPreviewRequest; -use App\Http\Requests\Preview\PreviewInvoiceRequest; -use App\Jobs\Util\PreviewPdf; -use App\Libraries\MultiDB; +use App\Utils\Ninja; +use App\Models\Quote; use App\Models\Client; -use App\Models\ClientContact; use App\Models\Credit; use App\Models\Invoice; -use App\Models\InvoiceInvitation; -use App\Models\Quote; -use App\Models\RecurringInvoice; -use App\Repositories\CreditRepository; -use App\Repositories\InvoiceRepository; -use App\Repositories\QuoteRepository; -use App\Repositories\RecurringInvoiceRepository; +use App\Utils\HtmlEngine; +use App\Libraries\MultiDB; +use App\Factory\QuoteFactory; +use App\Jobs\Util\PreviewPdf; +use App\Models\ClientContact; use App\Services\Pdf\PdfMock; +use App\Factory\CreditFactory; +use App\Factory\InvoiceFactory; +use App\Utils\Traits\MakesHash; +use App\Models\RecurringInvoice; +use App\Utils\PhantomJS\Phantom; +use App\Models\InvoiceInvitation; use App\Services\PdfMaker\Design; +use App\Utils\HostedPDF\NinjaPdf; +use Illuminate\Support\Facades\DB; +use App\Services\PdfMaker\PdfMaker; +use Illuminate\Support\Facades\App; +use App\Repositories\QuoteRepository; +use Illuminate\Support\Facades\Cache; +use App\Repositories\CreditRepository; +use App\Utils\Traits\MakesInvoiceHtml; +use Turbo124\Beacon\Facades\LightLogs; +use App\Repositories\InvoiceRepository; +use App\Utils\Traits\Pdf\PageNumbering; +use App\Factory\RecurringInvoiceFactory; +use Illuminate\Support\Facades\Response; +use App\DataMapper\Analytics\LivePreview; +use App\Repositories\RecurringInvoiceRepository; +use App\Http\Requests\Preview\DesignPreviewRequest; use App\Services\PdfMaker\Design as PdfDesignModel; use App\Services\PdfMaker\Design as PdfMakerDesign; -use App\Services\PdfMaker\PdfMaker; -use App\Utils\HostedPDF\NinjaPdf; -use App\Utils\HtmlEngine; -use App\Utils\Ninja; -use App\Utils\PhantomJS\Phantom; -use App\Utils\Traits\MakesHash; -use App\Utils\Traits\MakesInvoiceHtml; -use App\Utils\Traits\Pdf\PageNumbering; -use Illuminate\Support\Facades\App; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Response; -use Turbo124\Beacon\Facades\LightLogs; +use App\Http\Requests\Preview\PreviewInvoiceRequest; class PreviewController extends BaseController { @@ -56,15 +57,177 @@ class PreviewController extends BaseController public function __construct() { - parent::__construct(); + parent::__construct(); + } + + private function purgeCache() + { + Cache::pull("preview_".auth()->user()->id); + } + + /** + * Refactor - 2023-10-19 + * + * New method does not require Transactions. + * + * @param PreviewInvoiceRequest $request + * @return mixed + */ + public function live(PreviewInvoiceRequest $request): mixed + { + + if (Ninja::isHosted() && !in_array($request->getHost(), ['preview.invoicing.co','staging.invoicing.co'])) { + return response()->json(['message' => 'This server cannot handle this request.'], 400); + } + + $start = microtime(true); + + /** Build models */ + $invitation = $request->resolveInvitation(); + $client = $request->getClient(); + $settings = $client->getMergedSettings(); + + /** Set translations */ + App::forgetInstance('translator'); + $t = app('translator'); + App::setLocale($invitation->contact->preferredLocale()); + $t->replace(Ninja::transformTranslations($settings)); + + $entity_prop = str_replace("recurring_", "", $request->entity); + $entity_obj = $invitation->{$request->entity}; + + /** Update necessary objecty props */ + if(!$entity_obj->id) { + $entity_obj->design_id = intval($this->decodePrimaryKey($settings->{$entity_prop."_design_id"})); + $entity_obj->footer = $settings->{$entity_prop."_footer"}; + $entity_obj->terms = $settings->{$entity_prop."_terms"}; + $entity_obj->public_notes = $request->getClient()->public_notes; + $invitation->{$request->entity} = $entity_obj; + } + + /** Generate variables */ + $html = new HtmlEngine($invitation); + $html->settings = $settings; + $variables = $html->generateLabelsAndValues(); + + $design = \App\Models\Design::withTrashed()->find($entity_obj->design_id ?? 2); + + /* Catch all in case migration doesn't pass back a valid design */ + if (! $design) { + $design = \App\Models\Design::find(2); + } + + if ($design->is_custom) { + $options = [ + 'custom_partials' => json_decode(json_encode($design->design), true), + ]; + $template = new PdfMakerDesign(PdfDesignModel::CUSTOM, $options); + } else { + $template = new PdfMakerDesign(strtolower($design->name)); + } + + $state = [ + 'template' => $template->elements([ + 'client' => $client, + 'entity' => $entity_obj, + 'pdf_variables' => (array) $settings->pdf_variables, + '$product' => $design->design->product, + 'variables' => $variables, + ]), + 'variables' => $variables, + 'options' => [ + 'all_pages_header' => $client->getSetting('all_pages_header'), + 'all_pages_footer' => $client->getSetting('all_pages_footer'), + ], + 'process_markdown' => $client->company->markdown_enabled, + ]; + + $maker = new PdfMaker($state); + + $maker + ->design($template) + ->build(); + + /** Generate HTML */ + $html = $maker->getCompiledHTML(true); + + if (request()->query('html') == 'true') + return $html; + + //if phantom js...... inject here.. + if (config('ninja.phantomjs_pdf_generation') || config('ninja.pdf_generator') == 'phantom') { + return (new Phantom)->convertHtmlToPdf($html); + } + + /** @var \App\Models\User $user */ + $user = auth()->user(); + $company = $user->company(); + + if (config('ninja.invoiceninja_hosted_pdf_generation') || config('ninja.pdf_generator') == 'hosted_ninja') { + + $pdf = (new NinjaPdf())->build($html); + $numbered_pdf = $this->pageNumbering($pdf, $company); + + if ($numbered_pdf) + $pdf = $numbered_pdf; + + return $pdf; + } + + $pdf = (new PreviewPdf($html, $company))->handle(); + + if (Ninja::isHosted()) { + LightLogs::create(new LivePreview()) + ->increment() + ->batch(); + } + + /** Return PDF */ + return response()->streamDownload(function () use ($pdf) { + echo $pdf; + }, 'preview.pdf', [ + 'Content-Disposition' => 'inline', + 'Content-Type' => 'application/pdf', + 'Cache-Control:' => 'no-cache', + 'Server-Timing' => microtime(true)-$start + ]); + + } + + + /** + * Returns the mocked PDF for the invoice design preview. + * + * Only used in Settings > Invoice Design as a general overview + * + * @param DesignPreviewRequest $request + * @return mixed + */ + public function design(DesignPreviewRequest $request): mixed + { + $start = microtime(true); + + /** @var \App\Models\User $user */ + $user = auth()->user(); + + /** @var \App\Models\Company $company */ + $company = $user->company(); + + $pdf = (new PdfMock($request->all(), $company))->build()->getPdf(); + + $response = Response::make($pdf, 200); + $response->header('Content-Type', 'application/pdf'); + $response->header('Server-Timing', microtime(true)-$start); + + return $response; } /** * Returns a template filled with entity variables. - * + * + * Used in the Custom Designer to preview design changes * @return mixed */ - public function show() { if (request()->has('entity') && @@ -72,6 +235,7 @@ class PreviewController extends BaseController ! empty(request()->input('entity')) && ! empty(request()->input('entity_id')) && request()->has('body')) { + $design_object = json_decode(json_encode(request()->input('design'))); if (! is_object($design_object)) { @@ -128,55 +292,57 @@ class PreviewController extends BaseController return (new Phantom)->convertHtmlToPdf($maker->getCompiledHTML(true)); } - /** @var \App\Models\User $user */ $user = auth()->user(); - $company = $user->company(); if (config('ninja.invoiceninja_hosted_pdf_generation') || config('ninja.pdf_generator') == 'hosted_ninja') { $pdf = (new NinjaPdf())->build($maker->getCompiledHTML(true)); - $numbered_pdf = $this->pageNumbering($pdf, $company); - - if ($numbered_pdf) { + if ($numbered_pdf) $pdf = $numbered_pdf; - } return $pdf; + } - $file_path = (new PreviewPdf($maker->getCompiledHTML(true), $company))->handle(); + $pdf = (new PreviewPdf($maker->getCompiledHTML(true), $company))->handle(); + + return response()->streamDownload(function () use ($pdf) { + echo $pdf; + }, 'preview.pdf', [ + 'Content-Disposition' => 'inline', + 'Content-Type' => 'application/pdf', + 'Cache-Control:' => 'no-cache', + ]); + - return response()->download($file_path, basename($file_path), ['Cache-Control:' => 'no-cache'])->deleteFileAfterSend(true); } return $this->blankEntity(); } - public function design(DesignPreviewRequest $request) + + + /** + * @deprecated due to usage of transactions + * + * @param mixed $request + * @return void + */ + public function livex(PreviewInvoiceRequest $request) { - /** @var \App\Models\User $user */ - $user = auth()->user(); - /** @var \App\Models\Company $company */ - $company = $user->company(); + if(Cache::has("preview_".auth()->user()->id)) + return response()->json(['message' => 'Please wait a few seconds before trying again, this many requests are not good.'], 400); - $pdf = (new PdfMock($request->all(), $company))->build()->getPdf(); - - $response = Response::make($pdf, 200); - $response->header('Content-Type', 'application/pdf'); - - return $response; - } - - public function live(PreviewInvoiceRequest $request) - { if (Ninja::isHosted() && !in_array($request->getHost(), ['preview.invoicing.co','staging.invoicing.co'])) { return response()->json(['message' => 'This server cannot handle this request.'], 400); } + Cache::put("preview_".auth()->user()->id, 60); + $start = microtime(true); /** @var \App\Models\User $user */ @@ -287,9 +453,13 @@ class PreviewController extends BaseController DB::connection(config('database.default'))->rollBack(); if (request()->query('html') == 'true') { + $this->purgeCache(); return $maker->getCompiledHTML(); } + } catch(\Exception $e) { + + $this->purgeCache(); DB::connection(config('database.default'))->rollBack(); @@ -302,6 +472,7 @@ class PreviewController extends BaseController //if phantom js...... inject here.. if (config('ninja.phantomjs_pdf_generation') || config('ninja.pdf_generator') == 'phantom') { + $this->purgeCache(); return (new Phantom)->convertHtmlToPdf($maker->getCompiledHTML(true)); } @@ -319,7 +490,7 @@ class PreviewController extends BaseController if ($numbered_pdf) { $pdf = $numbered_pdf; } - + $this->purgeCache(); return $pdf; } @@ -335,7 +506,7 @@ class PreviewController extends BaseController $response->header('Content-Type', 'application/pdf'); $response->header('Server-Timing', microtime(true)-$start); - + $this->purgeCache(); return $response; } @@ -523,7 +694,6 @@ class PreviewController extends BaseController $response = Response::make($file_path, 200); $response->header('Content-Type', 'application/pdf'); - return $response; } } diff --git a/app/Http/Controllers/ProtectedDownloadController.php b/app/Http/Controllers/ProtectedDownloadController.php index b8f598a11021..014527bace14 100644 --- a/app/Http/Controllers/ProtectedDownloadController.php +++ b/app/Http/Controllers/ProtectedDownloadController.php @@ -23,7 +23,7 @@ class ProtectedDownloadController extends BaseController public function index(Request $request) { - + /** @var string $hashed_path */ $hashed_path = Cache::pull($request->hash); if (!$hashed_path) { diff --git a/app/Http/Requests/Preview/DesignPreviewRequest.php b/app/Http/Requests/Preview/DesignPreviewRequest.php index f3706c9f5b20..ac2b72fb1ad9 100644 --- a/app/Http/Requests/Preview/DesignPreviewRequest.php +++ b/app/Http/Requests/Preview/DesignPreviewRequest.php @@ -32,11 +32,14 @@ class DesignPreviewRequest extends Request */ public function authorize() : bool { - return auth()->user()->can('create', Invoice::class) || - auth()->user()->can('create', Quote::class) || - auth()->user()->can('create', RecurringInvoice::class) || - auth()->user()->can('create', Credit::class) || - auth()->user()->can('create', PurchaseOrder::class); + /** @var \App\Models\User $user */ + $user = auth()->user(); + + return $user->can('create', Invoice::class) || + $user->can('create', Quote::class) || + $user->can('create', RecurringInvoice::class) || + $user->can('create', Credit::class) || + $user->can('create', PurchaseOrder::class); } public function rules() diff --git a/app/Http/Requests/Preview/PreviewInvoiceRequest.php b/app/Http/Requests/Preview/PreviewInvoiceRequest.php index bfa4db546470..57bd0cf22ef0 100644 --- a/app/Http/Requests/Preview/PreviewInvoiceRequest.php +++ b/app/Http/Requests/Preview/PreviewInvoiceRequest.php @@ -11,15 +11,29 @@ namespace App\Http\Requests\Preview; +use App\Models\Quote; +use App\Models\Client; +use App\Models\Credit; +use App\Models\Invoice; use App\Http\Requests\Request; -use App\Utils\Traits\CleanLineItems; +use App\Models\QuoteInvitation; use App\Utils\Traits\MakesHash; +use Illuminate\Validation\Rule; +use App\Models\CreditInvitation; +use App\Models\RecurringInvoice; +use App\Models\InvoiceInvitation; +use App\Utils\Traits\CleanLineItems; +use App\Models\RecurringInvoiceInvitation; class PreviewInvoiceRequest extends Request { use MakesHash; use CleanLineItems; + private string $entity_plural = ''; + + private ?Client $client = null; + /** * Determine if the user is authorized to make this request. * @@ -27,20 +41,32 @@ class PreviewInvoiceRequest extends Request */ public function authorize() : bool { - return auth()->user()->hasIntersectPermissionsOrAdmin(['view_invoice', 'view_quote', 'view_recurring_invoice', 'view_credit', 'create_invoice', 'create_quote', 'create_recurring_invoice', 'create_credit','edit_invoice', 'edit_quote', 'edit_recurring_invoice', 'edit_credit']); + /** @var \App\Models\User $user */ + $user = auth()->user(); + + return $user->hasIntersectPermissionsOrAdmin(['view_invoice', 'view_quote', 'view_recurring_invoice', 'view_credit', 'create_invoice', 'create_quote', 'create_recurring_invoice', 'create_credit','edit_invoice', 'edit_quote', 'edit_recurring_invoice', 'edit_credit']); } public function rules() { - $rules = []; + /** @var \App\Models\User $user */ + $user = auth()->user(); - $rules['number'] = ['nullable']; + return [ + 'number' => 'nullable', + 'entity' => 'bail|sometimes|in:invoice,quote,credit,recurring_invoice', + 'entity_id' => ['bail','sometimes','integer',Rule::exists($this->entity_plural, 'id')->where('is_deleted',0)->where('company_id', $user->company()->id)], + 'client_id' => ['required', Rule::exists(Client::class, 'id')->where('is_deleted', 0)->where('company_id', $user->company()->id)], + ]; - return $rules; } public function prepareForValidation() { + + /** @var \App\Models\User $user */ + $user = auth()->user(); + $input = $this->all(); $input = $this->decodePrimaryKeys($input); @@ -50,6 +76,112 @@ class PreviewInvoiceRequest extends Request $input['balance'] = 0; $input['number'] = isset($input['number']) ? $input['number'] : ctrans('texts.live_preview').' #'.rand(0, 1000); + if($input['entity_id'] ?? false) + $input['entity_id'] = $this->decodePrimaryKey($input['entity_id'], true); + + $this->convertEntityPlural($input['entity'] ?? 'invoice'); + $this->replace($input); } + + public function resolveInvitation() + { + $invitation = false; + + if(! $this->entity_id ?? false) + return $this->stubInvitation(); + + match($this->entity){ + 'invoice' => $invitation = InvoiceInvitation::withTrashed()->where('invoice_id', $this->entity_id)->first(), + 'quote' => $invitation = QuoteInvitation::withTrashed()->where('quote_id', $this->entity_id)->first(), + 'credit' => $invitation = CreditInvitation::withTrashed()->where('credit_id', $this->entity_id)->first(), + 'recurring_invoice' => $invitation = RecurringInvoiceInvitation::withTrashed()->where('recurring_invoice_id', $this->entity_id)->first(), + }; + + if($invitation) + return $invitation; + + $invitation = $this->stubInvitation(); + } + + public function getClient(): ?Client + { + if(!$this->client) + $this->client = Client::query()->with('contacts', 'company', 'user')->withTrashed()->find($this->client_id); + + return $this->client; + } + + public function setClient(Client $client): self + { + $this->client = $client; + + return $this; + } + + public function stubInvitation() + { + $client = Client::query()->with('contacts', 'company', 'user')->withTrashed()->find($this->client_id); + $this->setClient($client); + $invitation = false; + + match($this->entity) { + 'invoice' => $invitation = InvoiceInvitation::factory()->make(), + 'quote' => $invitation = QuoteInvitation::factory()->make(), + 'credit' => $invitation = CreditInvitation::factory()->make(), + 'recurring_invoice' => $invitation = RecurringInvoiceInvitation::factory()->make(), + default => $invitation = InvoiceInvitation::factory()->make(), + }; + + $entity = $this->stubEntity($client); + + $invitation->make(); + $invitation->setRelation($this->entity, $entity); + $invitation->setRelation('contact', $client->contacts->first()->load('client.company')); + $invitation->setRelation('company', $client->company); + + return $invitation; + } + + private function stubEntity(Client $client) + { + $entity = false; + + match($this->entity){ + 'invoice' => $entity = Invoice::factory()->make(['client_id' => $client->id,'user_id' => $client->user_id, 'company_id' => $client->company_id]), + 'quote' => $entity = Quote::factory()->make(['client_id' => $client->id,'user_id' => $client->user_id, 'company_id' => $client->company_id]), + 'credit' => $entity = Credit::factory()->make(['client_id' => $client->id,'user_id' => $client->user_id, 'company_id' => $client->company_id]), + 'recurring_invoice' => $entity = RecurringInvoice::factory()->make(['client_id' => $client->id,'user_id' => $client->user_id, 'company_id' => $client->company_id]), + default => $entity = Invoice::factory()->make(['client_id' => $client->id,'user_id' => $client->user_id, 'company_id' => $client->company_id]), + }; + + $entity->setRelation('client', $client); + $entity->setRelation('company', $client->company); + $entity->setRelation('user', $client->user); + $entity->fill($this->all()); + + return $entity; + } + + private function convertEntityPlural(string $entity) :self + { + switch ($entity) { + case 'invoice': + $this->entity_plural = 'invoices'; + return $this; + case 'quote': + $this->entity_plural = 'quotes'; + return $this; + case 'credit': + $this->entity_plural = 'credits'; + return $this; + case 'recurring_invoice': + $this->entity_plural = 'invoices'; + return $this; + default: + $this->entity_plural = 'invoices'; + return $this; + } + } + } diff --git a/app/Jobs/Util/PreviewPdf.php b/app/Jobs/Util/PreviewPdf.php index 214201f1a660..0838379e9428 100644 --- a/app/Jobs/Util/PreviewPdf.php +++ b/app/Jobs/Util/PreviewPdf.php @@ -24,25 +24,14 @@ class PreviewPdf implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, PdfMaker, PageNumbering; - public $company; - - private $disk; - - public $design_string; - /** * Create a new job instance. * * @param $design_string * @param Company $company */ - public function __construct($design_string, Company $company) + public function __construct(public string $design_string, public Company $company) { - $this->company = $company; - - $this->design_string = $design_string; - - $this->disk = $disk ?? config('filesystems.default'); } public function handle() diff --git a/database/factories/RecurringInvoiceInvitationFactory.php b/database/factories/RecurringInvoiceInvitationFactory.php new file mode 100644 index 000000000000..fe7db151f11d --- /dev/null +++ b/database/factories/RecurringInvoiceInvitationFactory.php @@ -0,0 +1,30 @@ + Str::random(40), + ]; + } +} diff --git a/tests/Feature/LiveDesignTest.php b/tests/Feature/LiveDesignTest.php index 03ce04551e54..7dc750f10902 100644 --- a/tests/Feature/LiveDesignTest.php +++ b/tests/Feature/LiveDesignTest.php @@ -13,7 +13,9 @@ namespace Tests\Feature; use Tests\TestCase; use App\Models\Design; +use App\Utils\HtmlEngine; use Tests\MockAccountData; +use App\Models\InvoiceInvitation; use Illuminate\Routing\Middleware\ThrottleRequests; use Illuminate\Foundation\Testing\DatabaseTransactions; @@ -41,6 +43,30 @@ class LiveDesignTest extends TestCase } } + public function testSyntheticInvitations() + { + $this->assertGreaterThanOrEqual(1, $this->client->contacts->count()); + + $ii = InvoiceInvitation::factory() + ->for($this->invoice) + ->for($this->client->contacts->first(), 'contact') + ->for($this->company) + ->for($this->user) + ->make(); + + $this->assertInstanceOf(InvoiceInvitation::class, $ii); + + $engine = new HtmlEngine($ii); + + $this->assertNotNull($engine); + + $data = $engine->generateLabelsAndValues(); + + $this->assertIsArray($data); + + nlog($data); + } + public function testDesignRoute200() { $data = [