DONE: feedback module, use permission name for notification

This commit is contained in:
ISMAIL MASSERAN
2026-07-14 12:02:37 +08:00
parent 324b7facf1
commit 52ffd3393a
60 changed files with 3083 additions and 26 deletions
@@ -0,0 +1,339 @@
<?php
namespace Modules\Feedback\Http\Controllers;
use App\Http\Controllers\BaseCrudController;
use App\Models\Document;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Modules\Feedback\Entities\Feedback;
use Modules\Feedback\Http\Requests\FeedbackRequest;
use Modules\Feedback\Repositories\Contracts\FeedbackRepositoryInterface;
use Modules\Feedback\Transformers\FeedbackResource;
use Modules\Feedback\Notifications\FeedbackNotification;
use Modules\Auth\Entities\User;
use Laravel\Sanctum\PersonalAccessToken;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class FeedbackController extends BaseCrudController
{
protected $modelClass = Feedback::class;
protected $resourceClass = FeedbackResource::class;
protected $requestClass = FeedbackRequest::class;
protected $resourceName = 'maklum balas';
protected $resourceNamePlural = 'maklum balas';
public function __construct(FeedbackRepositoryInterface $repository)
{
parent::__construct($repository);
}
/**
* Override getIndexData to handle filters
*/
protected function getIndexData(Request $request, int $perPage = 10, string $search = '', string $sortBy = 'id', string $sortOrder = 'asc')
{
// Extract filter parameters from request
$filters = [
'type' => $request->get('type'),
'status' => $request->get('status'),
'priority' => $request->get('priority'),
];
// Always try paginated methods first when perPage is specified
if ($perPage > 0) {
if (method_exists($this->repository, 'getAllWithRelationsPaginated')) {
return $this->repository->getAllWithRelationsPaginated($perPage, $search, $sortBy, $sortOrder, $filters);
}
if (method_exists($this->repository, 'getAllPaginated')) {
return $this->repository->getAllPaginated($perPage, $search, $sortBy, $sortOrder, $filters);
}
}
// Fallback to non-paginated methods
if (method_exists($this->repository, 'getAllWithRelations')) {
return $this->repository->getAllWithRelations($search, $sortBy, $sortOrder);
}
return $this->repository->all($search, $sortBy, $sortOrder);
}
/**
* Store a newly created resource in storage (public access)
*/
public function store(Request $request): JsonResponse
{
// Skip authorization for public feedback submission
try {
$validated = $this->validateRequest($request);
$data = $this->prepareStoreData($validated);
$item = $this->repository->create($data);
$this->uploadAttachments($item, $request);
// Load relationships
$item->load(['user', 'documents']);
// Send notification to admins
$this->notifyAdminsForNewFeedback($item);
return response()->json([
'success' => true,
'data' => new $this->resourceClass($item),
'message' => 'Maklum balas berjaya dihantar. Terima kasih!',
], 201);
} catch (Exception $e) {
Log::error("Error creating {$this->resourceName}: ".$e->getMessage());
return $this->errorResponse($this->getErrorMessage('store').': '.$e->getMessage(), 500);
}
}
/**
* Prepare data for store method
*/
protected function prepareStoreData(array $validated): array
{
$data = $validated;
// Check if user is authenticated by looking for the Authorization header
$authHeader = request()->header('Authorization');
if ($authHeader && str_starts_with($authHeader, 'Bearer ')) {
// Try to authenticate the user manually
$token = str_replace('Bearer ', '', $authHeader);
$personalAccessToken = PersonalAccessToken::findToken($token);
if ($personalAccessToken) {
$data['user_id'] = $personalAccessToken->tokenable_id;
} else {
$data['user_id'] = null;
}
} else {
// User is not authenticated
$data['user_id'] = null;
}
$data['browser_info'] = $this->getBrowserInfo(request());
unset($data['images'], $data['videos']);
return $data;
}
/**
* Upload image and video attachments via HasDocuments.
*/
protected function uploadAttachments(Feedback $feedback, Request $request): void
{
if ($request->hasFile('images')) {
foreach ($request->file('images') as $image) {
$feedback->uploadDocument($image, Feedback::IMAGE_DOCUMENT_TYPE);
}
}
if ($request->hasFile('videos')) {
foreach ($request->file('videos') as $video) {
$feedback->uploadDocument($video, Feedback::VIDEO_DOCUMENT_TYPE);
}
}
}
/**
* Prepare data for update method
*/
protected function prepareUpdateData(array $validated, $feedback): array
{
$data = $validated;
unset($data['images'], $data['videos']);
// Set resolved_at timestamp when status changes to resolved
if (isset($data['status']) && $data['status'] === 'resolved') {
$data['resolved_at'] = now();
} elseif (isset($data['status']) && $data['status'] !== 'resolved') {
$data['resolved_at'] = null;
}
return $data;
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, string $id): JsonResponse
{
$this->authorize('update', $this->modelClass);
try {
$item = $this->repository->findById($id);
if (! $item) {
return $this->errorResponse($this->getNotFoundMessage(), 404);
}
$validated = $this->validateRequest($request);
$data = $this->prepareUpdateData($validated, $item);
$item->update($data);
$item->load(['user', 'assignedUser', 'documents']);
return response()->json([
'success' => true,
'data' => new $this->resourceClass($item),
'message' => $this->getSuccessMessage('update'),
]);
} catch (Exception $e) {
Log::error("Error updating {$this->resourceName}: ".$e->getMessage());
return $this->errorResponse($this->getErrorMessage('update').': '.$e->getMessage(), 500);
}
}
/**
* Load relations for show method
*/
protected function loadShowRelations($item)
{
return $item->load(['user', 'assignedUser', 'documents']);
}
/**
* Check dependencies before deletion
*/
protected function checkDependencies($feedback): ?\Illuminate\Http\JsonResponse
{
foreach ($feedback->documents as $document) {
$feedback->deleteDocument($document->id);
}
return null;
}
/**
* Stream a feedback attachment inline (image/video preview).
*/
public function serveDocument(string $id, string $documentId): BinaryFileResponse
{
$this->authorize('view', $this->modelClass);
$feedback = Feedback::findOrFail($id);
$document = $feedback->documents()->findOrFail($documentId);
$disk = Storage::disk(Document::STORAGE_DISK);
if (! $disk->exists($document->path)) {
abort(404, 'File not found');
}
return response()->file($disk->path($document->path), [
'Content-Type' => $document->mime_type ?? 'application/octet-stream',
'Content-Disposition' => 'inline; filename="'.$document->name.'"',
]);
}
/**
* Get feedback statistics
*/
public function statistics(): JsonResponse
{
$stats = [
'total' => Feedback::count(),
'open' => Feedback::open()->count(),
'resolved' => Feedback::resolved()->count(),
'by_type' => Feedback::selectRaw('type, COUNT(*) as count')
->groupBy('type')
->pluck('count', 'type'),
'by_priority' => Feedback::selectRaw('priority, COUNT(*) as count')
->groupBy('priority')
->pluck('count', 'priority'),
'by_status' => Feedback::selectRaw('status, COUNT(*) as count')
->groupBy('status')
->pluck('count', 'status'),
];
return response()->json([
'success' => true,
'data' => $stats
]);
}
/**
* Get user's own feedback
*/
public function myFeedback(Request $request): JsonResponse
{
$query = Feedback::where('user_id', auth()->id())
->with(['assignedUser', 'documents'])
->orderBy('created_at', 'desc');
// Apply filters
if ($request->has('status') && $request->status) {
$query->ofStatus($request->status);
}
if ($request->has('type') && $request->type) {
$query->ofType($request->type);
}
$perPage = $request->get('per_page', 15);
$feedback = $query->paginate($perPage);
return response()->json([
'success' => true,
'data' => FeedbackResource::collection($feedback->items()),
'meta' => [
'current_page' => $feedback->currentPage(),
'last_page' => $feedback->lastPage(),
'per_page' => $feedback->perPage(),
'total' => $feedback->total(),
]
]);
}
/**
* Get browser information from request
*/
private function getBrowserInfo(Request $request): array
{
return [
'user_agent' => $request->userAgent(),
'ip_address' => $request->ip(),
'referer' => $request->header('referer'),
'accept_language' => $request->header('accept-language'),
'screen_resolution' => $request->input('screen_resolution'),
'viewport_size' => $request->input('viewport_size'),
'timezone' => $request->input('timezone'),
];
}
/**
* Notify admins about new feedback submission
*/
private function notifyAdminsForNewFeedback(Feedback $feedback): void
{
try {
$adminRoles = ['PENTADBIR', 'PS 2 KJC', 'PS 2 ALAT'];
// Get users with specific roles plus admins (PENTADBIR and DEVELOPER)
$adminUsers = $this->getUsersWithRolesAndAdmins($adminRoles);
$sender = auth()->user() ?? $feedback->user; // Use current user as sender, or feedback submitter if no auth
foreach ($adminUsers as $admin) {
try {
$admin->notify(new FeedbackNotification($feedback, $sender));
} catch (Exception $e) {
Log::error('Failed to send feedback notification: '.$e->getMessage());
}
}
} catch (Exception $e) {
Log::error('Failed to notify admins for new feedback: '.$e->getMessage());
}
}
}