Actions

B.M. Restu Pandhu P.

/ 17

Actions

2

ResetPasswordAction

<?php

namespace App\Actions\Auth;

use App\Models\User;

class ResetPasswordAction
{
    /**
     * Set the user's new password and close out the reset flow: clear the
     * OTP so it can't be reused, and lift any pending forced-change flag
     * since they've just chosen a password themselves.
     */
    public function handle(User $user, string $password): void
    {
        $user->update([
            'password' => $password,
            'password_must_change' => false,
            'password_reset_code' => null,
            'password_reset_expires_at' => null,
        ]);
    }
}

SendPasswordResetCodeAction

<?php

namespace App\Actions\Auth;

use App\Mail\PasswordResetCode;
use App\Models\User;
use Illuminate\Support\Facades\Mail;

class SendPasswordResetCodeAction
{
    /**
     * Generate (or reuse a still-valid) OTP for the given user and email it.
     * Reusing an unexpired code avoids spamming a fresh email if the user
     * re-submits their email after losing the previous one. Pass
     * $forceNew when the user explicitly asked to resend.
     */
    public function handle(User $user, bool $forceNew = false): void
    {
        $reusable = ! $forceNew && $user->password_reset_code && ! $user->isPasswordResetCodeExpired();
        $code = $reusable ? $user->password_reset_code : $user->generatePasswordResetCode();

        Mail::to($user->email)->send(new PasswordResetCode($user, $code));
    }
}

3

AddProgressNoteAction

<?php

namespace App\Actions\Complaint;

use App\Models\Complaint;
use App\Models\ComplaintActivity;
use App\Models\Respons;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;

class AddProgressNoteAction
{
    /**
     * Record a progress update on a ticket the technician is still working
     * on. Unlike ResolveComplaintAction, this does not change the complaint's
     * status — a ticket can collect several progress notes across days
     * before the technician marks it resolved.
     *
     * @param  array<int, UploadedFile>  $photos
     */
    public function handle(Complaint $complaint, User $technician, string $note, array $photos = []): Complaint
    {
        if ($complaint->assigned_technician_id !== $technician->id) {
            throw new \DomainException('Tiket ini bukan tiket yang ditugaskan ke Anda.');
        }

        if ($complaint->status !== 'in_progress') {
            throw new \DomainException('Tiket ini tidak sedang dikerjakan.');
        }

        $photoPaths = array_map(
            fn ($photo) => $photo->store('response-photos', 'public'),
            $photos,
        );

        DB::transaction(function () use ($complaint, $technician, $note, $photoPaths) {
            Respons::create([
                'respons' => $note,
                'complaint_id' => $complaint->id,
                'technician_id' => $technician->id,
                'respons_date' => now(),
                'photo' => $photoPaths,
            ]);

            ComplaintActivity::create([
                'complaint_id' => $complaint->id,
                'causer_id' => $technician->id,
                'action' => 'in_progress',
                'note' => "Update progres oleh {$technician->name}.",
            ]);
        });

        return $complaint->fresh();
    }
}

ClaimComplaintAction

<?php

namespace App\Actions\Complaint;

use App\Models\Complaint;
use App\Models\ComplaintActivity;
use App\Models\User;
use Illuminate\Support\Facades\DB;

class ClaimComplaintAction
{
    /**
     * A technician claims an open ticket themselves — there is no admin
     * push-assign step anymore (§4). Tickets tied to an application that's
     * locked to one specialist can only be claimed by that technician.
     */
    public function handle(Complaint $complaint, User $technician): Complaint
    {
        if ($complaint->status !== 'open') {
            throw new \DomainException('Tiket ini sudah diambil teknisi lain.');
        }

        if ($complaint->application && ! $complaint->application->isClaimableBy($technician)) {
            throw new \DomainException('Tiket ini hanya boleh dikerjakan oleh teknisi khusus yang ditunjuk untuk aplikasi ini.');
        }

        DB::transaction(function () use ($complaint, $technician) {
            $complaint->update([
                'assigned_technician_id' => $technician->id,
                'status' => 'in_progress',
            ]);

            ComplaintActivity::create([
                'complaint_id' => $complaint->id,
                'causer_id' => $technician->id,
                'action' => 'assigned',
                'note' => "Tiket diambil oleh teknisi {$technician->name}.",
            ]);
        });

        return $complaint->fresh();
    }
}

CloseComplaintAction

<?php

namespace App\Actions\Complaint;

use App\Models\Complaint;
use App\Models\ComplaintActivity;
use App\Models\User;

class CloseComplaintAction
{
    /**
     * Admin verification step: set the invoice price for this ticket and
     * close it. Price is decided per ticket by admin at this point — it's
     * no longer auto-copied from the client's rate when the ticket was
     * created (§14/§16).
     */
    public function handle(Complaint $complaint, User $admin, string|float $price): Complaint
    {
        if ($complaint->status !== 'resolved') {
            throw new \DomainException('Hanya tiket berstatus resolved yang bisa ditutup.');
        }

        $complaint->update([
            'status' => 'closed',
            'price' => $price,
        ]);

        ComplaintActivity::create([
            'complaint_id' => $complaint->id,
            'causer_id' => $admin->id,
            'action' => 'closed',
            'note' => "Tiket diverifikasi dan ditutup oleh {$admin->name} dengan tagihan Rp ".number_format((float) $price, 0, ',', '.').'.',
        ]);

        return $complaint->fresh();
    }
}

CreateComplaintAction

<?php

namespace App\Actions\Complaint;

use App\Models\Client;
use App\Models\Complaint;
use App\Models\ComplaintActivity;
use App\Models\User;
use App\Notifications\NewComplaintSubmitted;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;

class CreateComplaintAction
{
    /**
     * Store a guest-submitted complaint: generate its ticket number, log the
     * creation, and notify every admin. Price is intentionally left unset —
     * admin decides the invoice amount per ticket once it's resolved (§14/§16),
     * not auto-copied from the client's rate at submission time.
     *
     * @param  array{client_id: string, application_id?: string|null, subject: string, description: string, report_by: string, priority?: string}  $data
     * @param  array<int, UploadedFile>  $photos
     */
    public function handle(array $data, array $photos = []): Complaint
    {
        $client = Client::findOrFail($data['client_id']);

        $photoPaths = array_map(
            fn ($photo) => $photo->store('complaint-photos', 'public'),
            $photos,
        );

        $complaint = DB::transaction(function () use ($data, $client, $photoPaths) {
            $complaint = Complaint::create([
                'ticket_number' => $this->generateTicketNumber(),
                'subject' => $data['subject'],
                'description' => $data['description'],
                'report_by' => $data['report_by'],
                'client_id' => $client->id,
                'application_id' => $data['application_id'] ?? null,
                'priority' => $data['priority'] ?? 'normal',
                'photo' => $photoPaths,
                'report_date' => now(),
                'status' => 'open',
            ]);

            ComplaintActivity::create([
                'complaint_id' => $complaint->id,
                'causer_id' => null,
                'action' => 'created',
                'note' => "Tiket dibuat oleh {$data['report_by']} untuk instansi {$client->name}.",
            ]);

            return $complaint;
        });

        $admins = User::query()->where('role', 'admin')->where('is_active', true)->get();
        Notification::send($admins, new NewComplaintSubmitted($complaint));

        return $complaint;
    }

    /**
     * Build a daily-sequential ticket number, e.g. TCK-20260922-0001.
     */
    private function generateTicketNumber(): string
    {
        $prefix = 'TCK-'.now()->format('Ymd').'-';

        $todayCount = Complaint::query()
            ->where('ticket_number', 'like', $prefix.'%')
            ->count();

        return $prefix.str_pad((string) ($todayCount + 1), 4, '0', STR_PAD_LEFT);
    }
}

ReleaseComplaintAction

<?php

namespace App\Actions\Complaint;

use App\Models\Complaint;
use App\Models\ComplaintActivity;
use App\Models\User;
use Illuminate\Support\Facades\DB;

class ReleaseComplaintAction
{
    /**
     * A technician releases a ticket they can't handle back to the open
     * pool, so another technician can pick it up (§4) — "kalau ga ya udah
     * biar teknisi lain".
     */
    public function handle(Complaint $complaint, User $technician): Complaint
    {
        if ($complaint->assigned_technician_id !== $technician->id) {
            throw new \DomainException('Tiket ini bukan tiket yang Anda ambil.');
        }

        if ($complaint->status !== 'in_progress') {
            throw new \DomainException('Hanya tiket yang sedang dikerjakan yang bisa dilepas.');
        }

        DB::transaction(function () use ($complaint, $technician) {
            $complaint->update([
                'assigned_technician_id' => null,
                'status' => 'open',
            ]);

            ComplaintActivity::create([
                'complaint_id' => $complaint->id,
                'causer_id' => $technician->id,
                'action' => 'released',
                'note' => "Tiket dilepas oleh {$technician->name}, kembali ke antrian.",
            ]);
        });

        return $complaint->fresh();
    }
}

ResolveComplaintAction

<?php

namespace App\Actions\Complaint;

use App\Models\Complaint;
use App\Models\ComplaintActivity;
use App\Models\Respons;
use App\Models\User;
use App\Notifications\ComplaintReadyForInvoice;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;

class ResolveComplaintAction
{
    /**
     * Record a technician's response and mark the complaint as resolved.
     * Resolving notifies every admin that the ticket is ready to be
     * invoiced — price is set by admin per ticket, not auto-applied (§14).
     *
     * @param  array<int, UploadedFile>  $photos
     */
    public function handle(Complaint $complaint, User $technician, string $note, array $photos = []): Complaint
    {
        if ($complaint->assigned_technician_id !== $technician->id) {
            throw new \DomainException('Tiket ini bukan tiket yang ditugaskan ke Anda.');
        }

        $photoPaths = array_map(
            fn ($photo) => $photo->store('response-photos', 'public'),
            $photos,
        );

        DB::transaction(function () use ($complaint, $technician, $note, $photoPaths) {
            Respons::create([
                'respons' => $note,
                'complaint_id' => $complaint->id,
                'technician_id' => $technician->id,
                'respons_date' => now(),
                'photo' => $photoPaths,
            ]);

            $complaint->update(['status' => 'resolved']);

            ComplaintActivity::create([
                'complaint_id' => $complaint->id,
                'causer_id' => $technician->id,
                'action' => 'resolved',
                'note' => "Tiket diselesaikan oleh {$technician->name}.",
            ]);
        });

        $admins = User::query()->where('role', 'admin')->where('is_active', true)->get();
        Notification::send($admins, new ComplaintReadyForInvoice($complaint));

        return $complaint->fresh();
    }
}

4

BuildReferenceNumber

<?php

namespace App\Actions\Recap;

use App\Models\Client;

class BuildReferenceNumber
{
    /**
     * A deterministic-looking letter reference number (no registered
     * numbering system exists yet) — client + month (roman) + year, so the
     * same period always regenerates the same number. Kept out of the QR
     * verification URL (§ ReportSignature) and recomputed here instead, so
     * both the PDF and the verify page stay in sync without carrying it as
     * payload.
     */
    public function handle(Client $client, int $month, int $year): string
    {
        $roman = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII'][$month - 1];
        $clientCode = strtoupper(substr(preg_replace('/[^A-Za-z]/', '', $client->name), 0, 3)) ?: 'CLT';

        return "01/LAP/{$clientCode}/{$roman}/{$year}";
    }
}

GenerateVerificationQrCode

<?php

namespace App\Actions\Recap;

use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Color\Color;
use Endroid\QrCode\ErrorCorrectionLevel;
use Endroid\QrCode\RoundBlockSizeMode;

class GenerateVerificationQrCode
{
    /**
     * A styled QR code (rounded modules, brand navy) encoding the signed
     * verification URL, returned as a base64 data URI so it can be embedded
     * directly in the dompdf letter.
     */
    public function handle(string $url): string
    {
        $result = (new Builder)->build(
            data: $url,
            errorCorrectionLevel: ErrorCorrectionLevel::Medium,
            size: 240,
            margin: 8,
            roundBlockSizeMode: RoundBlockSizeMode::Margin,
            foregroundColor: new Color(28, 58, 94),
            backgroundColor: new Color(255, 255, 255),
        );

        return $result->getDataUri();
    }
}

StoreRecapReport

<?php

namespace App\Actions\Recap;

use App\Models\Client;
use App\Models\Config;
use App\Models\RecapReport;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;

class StoreRecapReport
{
    /**
     * The first time a period's letter is generated, snapshot the letter
     * number, company, and signer details into a permanent record and hand
     * back a short token for its QR verification URL. Regenerating the same
     * client+period later reuses that same snapshot — the letter's official
     * details don't silently drift if Pengaturan changes afterward (§ RecapReport).
     */
    public function __construct(private BuildReferenceNumber $referenceNumber) {}

    public function handle(Client $client, int $month, int $year, Collection $complaints): RecapReport
    {
        $existing = RecapReport::query()
            ->where('client_id', $client->id)
            ->where('month', $month)
            ->where('year', $year)
            ->first();

        if ($existing) {
            return $existing;
        }

        $company = Config::query()->first();
        $billable = $complaints->where('status', 'closed');

        return RecapReport::create([
            'client_id' => $client->id,
            'month' => $month,
            'year' => $year,
            'token' => Str::random(12),
            'reference_number' => $this->referenceNumber->handle($client, $month, $year),
            'client_name' => $client->name,
            'company_name' => $company?->company_name ?? config('app.name'),
            'signer_name' => $company?->signer_name,
            'signer_title' => $company?->signer_title,
            'complaint_count' => $complaints->count(),
            'billable_count' => $billable->count(),
            'billable_total' => $billable->sum('price'),
        ]);
    }
}

5

CreateStaffAccountAction

<?php

namespace App\Actions\User;

use App\Mail\StaffAccountCreated;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;

class CreateStaffAccountAction
{
    /**
     * Create an admin or technician account with a system-generated password
     * and email the credentials to the new user. Shared by both the "Kelola
     * Teknisi" and "Kelola Admin" flows — only the role differs.
     *
     * @param  array{name: string, username: string, email: string}  $data
     * @param  'admin'|'technician'  $role
     */
    public function handle(array $data, string $role): User
    {
        $plainPassword = Str::password(12);

        $user = User::create([
            'name' => $data['name'],
            'username' => $data['username'],
            'email' => $data['email'],
            'password' => $plainPassword,
            'role' => $role,
            'is_active' => true,
            'password_must_change' => true,
        ]);

        Mail::to($user->email)->send(new StaffAccountCreated($user, $plainPassword));

        return $user;
    }
}

Komentar

Belum ada komentar.