This commit is contained in:
Saufi
2026-06-02 17:35:45 +08:00
commit 4ef99b1f81
148 changed files with 21134 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AuditLogController extends Controller
{
public function index(Request $request): View
{
$logs = AuditLog::with(['actor', 'targetUser'])
->when($request->action, fn ($q, $a) => $q->where('action', $a))
->when($request->actor_id, fn ($q, $id) => $q->where('actor_user_id', $id))
->when($request->target_id, fn ($q, $id) => $q->where('target_user_id', $id))
->when($request->date_from, fn ($q, $d) => $q->whereDate('created_at', '>=', $d))
->when($request->date_to, fn ($q, $d) => $q->whereDate('created_at', '<=', $d))
->when($request->search, fn ($q, $s) =>
$q->where('action', 'like', "%{$s}%")
->orWhere('justification', 'like', "%{$s}%")
->orWhere('ip_address', 'like', "%{$s}%")
)
->orderByDesc('created_at')
->paginate(30)
->withQueryString();
$actions = AuditLog::select('action')->distinct()->pluck('action')->sort()->values();
$admins = User::where('role', 'admin')->orderBy('name')->get(['id', 'name']);
return view('admin.audit-logs.index', compact('logs', 'actions', 'admins'));
}
public function export(Request $request): StreamedResponse
{
$query = AuditLog::with(['actor', 'targetUser'])
->when($request->action, fn ($q, $a) => $q->where('action', $a))
->when($request->actor_id, fn ($q, $id) => $q->where('actor_user_id', $id))
->when($request->target_id, fn ($q, $id) => $q->where('target_user_id', $id))
->when($request->date_from, fn ($q, $d) => $q->whereDate('created_at', '>=', $d))
->when($request->date_to, fn ($q, $d) => $q->whereDate('created_at', '<=', $d))
->when($request->search, fn ($q, $s) =>
$q->where('action', 'like', "%{$s}%")
->orWhere('justification', 'like', "%{$s}%")
->orWhere('ip_address', 'like', "%{$s}%")
)
->orderByDesc('created_at');
$filename = 'audit-log-' . now()->format('Ymd-His') . '.csv';
return response()->streamDownload(function () use ($query) {
$handle = fopen('php://output', 'w');
// UTF-8 BOM untuk Excel
fwrite($handle, "\xEF\xBB\xBF");
fputcsv($handle, [
'Masa', 'Oleh', 'Peranan', 'Tindakan',
'Sasaran', 'Projek ID', 'IP', 'Justifikasi',
]);
$query->chunk(500, function ($logs) use ($handle) {
foreach ($logs as $log) {
fputcsv($handle, [
$log->created_at?->format('d/m/Y H:i:s'),
$log->actor?->name ?? 'Sistem',
$log->actor_role ?? '',
$log->action,
$log->targetUser?->name ?? ($log->subject_type
? class_basename($log->subject_type) . '#' . $log->subject_id
: ''),
$log->project_id ?? '',
$log->ip_address ?? '',
$log->justification ?? '',
]);
}
});
fclose($handle);
}, $filename, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
]);
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Department;
use App\Models\TranscriptionProject;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class DashboardController extends Controller
{
public function index(): View
{
$stats = [
'users_active' => User::where('is_active', true)->where('role', 'user')->count(),
'users_inactive' => User::where('is_active', false)->where('role', 'user')->count(),
'users_total' => User::where('role', 'user')->count(),
'projects_total' => TranscriptionProject::count(),
'projects_pending' => TranscriptionProject::where('transcription_status', 'pending')->count(),
'projects_processing' => TranscriptionProject::where('transcription_status', 'processing')->count(),
'projects_completed' => TranscriptionProject::where('transcription_status', 'completed')->count(),
'projects_failed' => TranscriptionProject::where('transcription_status', 'failed')->count(),
'projects_deleted' => TranscriptionProject::onlyTrashed()->count(),
'projects_last_7' => TranscriptionProject::where('created_at', '>=', now()->subDays(7))->count(),
'projects_last_30' => TranscriptionProject::where('created_at', '>=', now()->subDays(30))->count(),
'storage_bytes' => (int) TranscriptionProject::sum('file_size'),
'storage_completed_bytes' => (int) TranscriptionProject::where('transcription_status', 'completed')->sum('file_size'),
'duration_total_seconds' => (int) TranscriptionProject::whereNotNull('duration_seconds')->sum('duration_seconds'),
];
// Top 5 pengguna paling aktif (by project count)
$topUsers = User::where('role', 'user')
->withCount('ownedProjects')
->orderByDesc('owned_projects_count')
->limit(5)
->get(['id', 'name', 'email']);
// Trend projek 30 hari lepas (group by date)
$trendRaw = TranscriptionProject::select(
DB::raw('DATE(created_at) as date'),
DB::raw('COUNT(*) as total'),
DB::raw('SUM(CASE WHEN transcription_status = "completed" THEN 1 ELSE 0 END) as completed'),
DB::raw('SUM(CASE WHEN transcription_status = "failed" THEN 1 ELSE 0 END) as failed')
)
->where('created_at', '>=', now()->subDays(29)->startOfDay())
->groupBy(DB::raw('DATE(created_at)'))
->orderBy('date')
->get();
// Projek mengikut jabatan (metadata sahaja)
$deptStats = Department::select('departments.name')
->selectRaw('COUNT(tp.id) as project_count')
->leftJoin('users', 'users.department_id', '=', 'departments.id')
->leftJoin('transcription_projects as tp', 'tp.owner_user_id', '=', 'users.id')
->where('departments.is_active', true)
->groupBy('departments.id', 'departments.name')
->orderByDesc('project_count')
->limit(8)
->get();
return view('admin.dashboard', compact('stats', 'topUsers', 'trendRaw', 'deptStats'));
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Actions\TransferOwnershipAction;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\TransferOwnershipRequest;
use App\Models\TranscriptionProject;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ProjectMetadataController extends Controller
{
public function index(Request $request): View
{
// Admin hanya mendapat metadata — tiada transcript_text, tiada stored_audio_path
$projects = TranscriptionProject::withTrashed()
->with(['owner:id,name,email', 'projectCollaborators'])
->select([
'id', 'uuid', 'title', 'owner_user_id',
'transcription_status', 'transcription_engine',
'file_size', 'duration_seconds',
'language',
'created_at', 'deleted_at',
// Sengaja TIDAK ambil: transcript_text, stored_audio_path
])
->when($request->status, fn ($q, $s) => $q->where('transcription_status', $s))
->when($request->search, fn ($q, $s) =>
$q->where('title', 'like', "%{$s}%")
)
->when($request->owner_id, fn ($q, $id) => $q->where('owner_user_id', $id))
->orderByDesc('created_at')
->paginate(25)
->withQueryString();
$owners = User::where('role', 'user')
->where('is_active', true)
->orderBy('name')
->get(['id', 'name']);
return view('admin.projects.index', compact('projects', 'owners'));
}
public function transferOwner(
TransferOwnershipRequest $request,
TranscriptionProject $project,
TransferOwnershipAction $action
): RedirectResponse {
$this->authorize('transferOwner', $project);
$newOwner = User::findOrFail($request->new_owner_id);
// Pastikan pemilik baru adalah pengguna aktif (bukan admin)
abort_if($newOwner->isAdmin(), 403, 'Admin tidak boleh menjadi pemilik projek.');
abort_if(! $newOwner->isActive(), 422, 'Pengguna tidak aktif tidak boleh menerima pemindahan projek.');
abort_if($project->owner_user_id === $newOwner->id, 422, 'Projek sudah dimiliki oleh pengguna ini.');
$action->execute($project, $newOwner, $request->justification);
return redirect()->route('admin.projects.index')
->with('success', "Pemilikan projek \"{$project->title}\" berjaya dipindahkan kepada {$newOwner->name}.");
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Actions\ActivateUserAction;
use App\Actions\ChangeUserEmailAction;
use App\Actions\CreateUserAction;
use App\Actions\DeactivateUserAction;
use App\Actions\DeleteUserAction;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\CreateUserRequest;
use App\Http\Requests\Admin\UpdateUserEmailRequest;
use App\Models\Department;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class UserController extends Controller
{
public function index(Request $request): View
{
$this->authorize('viewAny', User::class);
$users = User::with('department')
->where('role', 'user')
->when($request->search, fn ($q, $s) =>
$q->where('name', 'like', "%{$s}%")
->orWhere('email', 'like', "%{$s}%")
)
->when($request->status, fn ($q, $s) =>
$q->where('is_active', $s === 'active')
)
->orderBy('name')
->paginate(20)
->withQueryString();
return view('admin.users.index', compact('users'));
}
public function create(): View
{
$this->authorize('create', User::class);
$departments = Department::where('is_active', true)->orderBy('name')->get();
return view('admin.users.create', compact('departments'));
}
public function store(CreateUserRequest $request, CreateUserAction $action): RedirectResponse
{
$action->execute($request->validated());
return redirect()->route('admin.users.index')
->with('success', 'Pengguna berjaya didaftarkan.');
}
public function edit(User $user): View
{
$this->authorize('changeEmail', $user);
$departments = Department::where('is_active', true)->orderBy('name')->get();
return view('admin.users.edit', compact('user', 'departments'));
}
public function updateEmail(UpdateUserEmailRequest $request, User $user, ChangeUserEmailAction $action): RedirectResponse
{
$this->authorize('changeEmail', $user);
$action->execute($user, $request->email, $request->justification);
return redirect()->route('admin.users.index')
->with('success', "E-mel pengguna {$user->name} berjaya dikemaskini.");
}
public function activate(Request $request, User $user, ActivateUserAction $action): RedirectResponse
{
$this->authorize('activate', $user);
$action->execute($user);
return redirect()->route('admin.users.index')
->with('success', "Akaun {$user->name} berjaya diaktifkan.");
}
public function deactivate(Request $request, User $user, DeactivateUserAction $action): RedirectResponse
{
$this->authorize('deactivate', $user);
$action->execute($user, $request->justification);
return redirect()->route('admin.users.index')
->with('success', "Akaun {$user->name} berjaya dinyahaktifkan.");
}
public function destroy(Request $request, User $user, DeleteUserAction $action): RedirectResponse
{
$this->authorize('delete', $user);
$action->execute($user, $request->justification);
return redirect()->route('admin.users.index')
->with('success', "Pengguna {$user->name} berjaya dipadam.");
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AuthenticatedSessionController extends Controller
{
public function create(): View
{
return view('auth.login');
}
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
$user = Auth::user();
// Semak is_active selepas login berjaya
if (! $user->is_active) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return back()->withErrors([
'email' => 'Akaun anda telah dinyahaktifkan. Sila hubungi pentadbir.',
]);
}
// Update last login
$user->update(['last_login_at' => now()]);
// Redirect mengikut role
if ($user->isAdmin()) {
return redirect()->intended(route('admin.dashboard'));
}
return redirect()->intended(route('user.dashboard'));
}
public function destroy(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
abstract class Controller extends BaseController
{
use AuthorizesRequests, DispatchesJobs, ValidatesRequests;
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Models\TranscriptionProject;
use App\Services\StorageService;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AudioController extends Controller
{
public function __construct(private readonly StorageService $storage) {}
public function stream(TranscriptionProject $project): StreamedResponse
{
$this->authorize('viewAudio', $project);
$path = $project->stored_audio_path;
abort_unless($this->storage->exists($path), 404);
$size = $this->storage->size($path);
$mimeType = $project->mime_type;
return response()->stream(function () use ($path) {
$stream = $this->storage->readStream($path);
if ($stream) {
fpassthru($stream);
fclose($stream);
}
}, 200, [
'Content-Type' => $mimeType,
'Content-Length' => $size,
'Content-Disposition' => 'inline',
'Cache-Control' => 'no-store, no-cache, private, must-revalidate',
'X-Content-Type-Options' => 'nosniff',
'Accept-Ranges' => 'bytes',
]);
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Models\ProjectCollaborator;
use App\Models\TranscriptionProject;
use App\Models\User;
use App\Services\AuditLogService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class CollaboratorController extends Controller
{
public function store(Request $request, TranscriptionProject $project, AuditLogService $audit): RedirectResponse
{
$this->authorize('manageCollaborators', $project);
$data = $request->validate([
'email' => ['required', 'email', 'exists:users,email'],
'role' => ['nullable', 'in:editor,viewer'],
]);
$user = User::where('email', $data['email'])->where('is_active', true)->firstOrFail();
// Owner tidak boleh jadi collaborator
if ($project->isOwnedBy($user)) {
return back()->withErrors(['email' => 'Pengguna ini adalah pemilik projek.']);
}
// Elak duplicate
$existing = ProjectCollaborator::where('project_id', $project->id)
->where('user_id', $user->id)
->exists();
if ($existing) {
return back()->withErrors(['email' => 'Pengguna ini sudah menjadi collaborator.']);
}
ProjectCollaborator::create([
'project_id' => $project->id,
'user_id' => $user->id,
'role' => $data['role'] ?? 'editor',
'added_by' => auth()->id(),
]);
$audit->log('collaborator_added', [
'project_id' => $project->id,
'target_user_id' => $user->id,
'new_values' => ['email' => $user->email, 'role' => $data['role'] ?? 'editor'],
]);
return back()->with('success', "{$user->name} berjaya ditambah sebagai collaborator.");
}
public function destroy(TranscriptionProject $project, User $user, AuditLogService $audit): RedirectResponse
{
$this->authorize('manageCollaborators', $project);
ProjectCollaborator::where('project_id', $project->id)
->where('user_id', $user->id)
->delete();
$audit->log('collaborator_removed', [
'project_id' => $project->id,
'target_user_id' => $user->id,
'old_values' => ['email' => $user->email],
]);
return back()->with('success', "{$user->name} telah dibuang daripada collaborator.");
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Http\Requests\User\StoreCommentRequest;
use App\Models\ProjectComment;
use App\Models\TranscriptionProject;
use App\Services\AuditLogService;
use Illuminate\Http\RedirectResponse;
class CommentController extends Controller
{
public function store(
StoreCommentRequest $request,
TranscriptionProject $project,
AuditLogService $audit
): RedirectResponse {
$this->authorize('create', [ProjectComment::class, $project]);
$comment = $project->comments()->create([
'user_id' => auth()->id(),
'message' => $request->message,
]);
$audit->log('comment_added', [
'project_id' => $project->id,
'new_values' => ['comment_id' => $comment->id],
]);
return back()->with('success', 'Komen berjaya ditambah.');
}
public function destroy(
TranscriptionProject $project,
ProjectComment $comment,
AuditLogService $audit
): RedirectResponse {
abort_if($comment->project_id !== $project->id, 404);
$this->authorize('delete', $comment);
$audit->log('comment_deleted', [
'project_id' => $project->id,
'old_values' => ['comment_id' => $comment->id],
]);
$comment->delete();
return back()->with('success', 'Komen berjaya dipadam.');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Models\TranscriptionProject;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class DashboardController extends Controller
{
public function index(): View
{
$user = Auth::user();
$ownedProjects = TranscriptionProject::where('owner_user_id', $user->id)
->orderByDesc('created_at')
->paginate(10, ['*'], 'owned');
$sharedProjects = $user->collaboratingProjects()
->orderByDesc('transcription_projects.created_at')
->paginate(10, ['*'], 'shared');
return view('user.dashboard', compact('ownedProjects', 'sharedProjects'));
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Http\Requests\User\CreateProjectRequest;
use App\Jobs\TranscribeAudioJob;
use App\Models\TranscriptionProject;
use App\Services\AuditLogService;
use App\Services\StorageService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class ProjectController extends Controller
{
public function create(): View
{
$this->authorize('create', TranscriptionProject::class);
return view('user.projects.create');
}
public function store(CreateProjectRequest $request, StorageService $storage, AuditLogService $audit): RedirectResponse
{
$this->authorize('create', TranscriptionProject::class);
$file = $request->file('audio');
$uuid = (string) \Illuminate\Support\Str::uuid();
$path = $storage->storeAudio($file, $uuid);
$project = TranscriptionProject::create([
'uuid' => $uuid,
'title' => $request->title,
'description' => $request->description,
'owner_user_id' => Auth::id(),
'original_filename' => $file->getClientOriginalName(),
'stored_audio_path' => $path,
'mime_type' => $file->getMimeType(),
'file_size' => $file->getSize(),
'language' => $request->language ?? 'ms',
'transcription_status' => 'pending',
'transcription_engine' => config('speech2text.transcription.engine'),
]);
$audit->log('project_created', [
'project_id' => $project->id,
'new_values' => ['title' => $project->title, 'uuid' => $project->uuid],
]);
$audit->log('audio_uploaded', [
'project_id' => $project->id,
'new_values' => [
'original_filename' => $project->original_filename,
'file_size' => $project->file_size,
'mime_type' => $project->mime_type,
],
]);
TranscribeAudioJob::dispatch($project);
return redirect()->route('user.projects.show', $project)
->with('success', 'Projek berjaya dicipta. Audio sedang dalam barisan untuk ditranskripkan.');
}
public function show(TranscriptionProject $project): View
{
$this->authorize('view', $project);
$project->load(['owner', 'collaborators.department', 'transcriptVersions.editor', 'comments.user']);
return view('user.projects.show', compact('project'));
}
// Endpoint polling status — JSON, tidak mendedahkan transcript
public function status(TranscriptionProject $project): JsonResponse
{
$this->authorize('view', $project);
return response()->json([
'status' => $project->transcription_status,
'duration_seconds' => $project->duration_seconds,
'processed_at' => $project->processed_at?->format('d/m/Y H:i'),
'error_message' => $project->isFailed() ? $project->error_message : null,
]);
}
public function retry(TranscriptionProject $project, AuditLogService $audit): RedirectResponse
{
$this->authorize('retryTranscription', $project);
$project->update([
'transcription_status' => 'pending',
'error_message' => null,
]);
$audit->log('transcription_started', [
'project_id' => $project->id,
'new_values' => ['retry' => true],
]);
TranscribeAudioJob::dispatch($project);
return back()->with('success', 'Transkripsi sedang dicuba semula.');
}
public function destroy(TranscriptionProject $project, AuditLogService $audit): RedirectResponse
{
$this->authorize('delete', $project);
$audit->log('project_deleted', [
'project_id' => $project->id,
'old_values' => ['title' => $project->title, 'uuid' => $project->uuid],
]);
$project->delete();
return redirect()->route('user.dashboard')
->with('success', 'Projek berjaya dipadam.');
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Http\Requests\User\UpdateTranscriptRequest;
use App\Http\Requests\User\UploadExternalTranscriptRequest;
use App\Jobs\OllamaPostProcessJob;
use App\Models\TranscriptVersion;
use App\Models\TranscriptionProject;
use App\Services\AuditLogService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
class TranscriptController extends Controller
{
/**
* Simpan perubahan teks transkripsi dan cipta versi baru.
*/
public function update(
UpdateTranscriptRequest $request,
TranscriptionProject $project,
AuditLogService $audit
): RedirectResponse {
$this->authorize('editTranscript', $project);
$oldText = $project->transcript_text;
$nextVersion = ($project->transcriptVersions()->max('version_number') ?? 0) + 1;
TranscriptVersion::create([
'project_id' => $project->id,
'edited_by' => Auth::id(),
'version_number' => $nextVersion,
'old_text' => $oldText,
'new_text' => $request->transcript_text,
'change_summary' => $request->change_summary ?? 'Dikemaskini oleh pengguna',
'created_at' => now(),
]);
$project->update([
'transcript_text' => $request->transcript_text,
'transcription_status' => 'completed',
]);
$audit->log('transcript_edited', [
'project_id' => $project->id,
'new_values' => ['version_number' => $nextVersion],
]);
return back()->with('success', 'Transkripsi berjaya disimpan.');
}
/**
* Muat naik fail transkripsi .txt dari luar sistem.
*/
public function uploadExternal(
UploadExternalTranscriptRequest $request,
TranscriptionProject $project,
AuditLogService $audit
): RedirectResponse {
$this->authorize('editTranscript', $project);
$text = file_get_contents($request->file('transcript_file')->getRealPath());
// Bersihkan BOM dan normalkan line endings
$text = preg_replace('/^\xef\xbb\xbf/', '', $text);
$text = str_replace(["\r\n", "\r"], "\n", $text);
$text = trim($text);
if ($text === '') {
return back()->withErrors(['transcript_file' => 'Fail transkripsi kosong.']);
}
$oldText = $project->transcript_text;
$nextVersion = ($project->transcriptVersions()->max('version_number') ?? 0) + 1;
TranscriptVersion::create([
'project_id' => $project->id,
'edited_by' => Auth::id(),
'version_number' => $nextVersion,
'old_text' => $oldText,
'new_text' => $text,
'change_summary' => 'Transkripsi dimuat naik secara manual',
'created_at' => now(),
]);
$project->update([
'transcript_text' => $text,
'transcription_status' => 'completed',
'transcription_engine' => 'manual',
'processed_at' => $project->processed_at ?? now(),
]);
$audit->log('transcript_uploaded_external', [
'project_id' => $project->id,
'new_values' => ['version_number' => $nextVersion],
]);
// Hantar ke Ollama jika pengguna minta dan Ollama dikonfigurasi
if ($request->boolean('clean_with_ollama') && config('speech2text.ollama.enabled')) {
OllamaPostProcessJob::dispatch($project);
}
$msg = 'Transkripsi berjaya dimuat naik.';
if ($request->boolean('clean_with_ollama') && config('speech2text.ollama.enabled')) {
$msg .= ' Pembersihan Ollama sedang diproses.';
}
return back()->with('success', $msg);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Models\TranscriptVersion;
use App\Models\TranscriptionProject;
use App\Services\AuditLogService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class TranscriptVersionController extends Controller
{
public function index(TranscriptionProject $project): View
{
$this->authorize('viewVersionHistory', $project);
$versions = $project->transcriptVersions()
->with('editor')
->orderByDesc('version_number')
->paginate(20);
return view('user.projects.versions', compact('project', 'versions'));
}
public function restore(
TranscriptionProject $project,
TranscriptVersion $version,
AuditLogService $audit
): RedirectResponse {
$this->authorize('restoreVersion', $project);
// Pastikan versi ini milik projek ini
abort_if($version->project_id !== $project->id, 404);
$oldText = $project->transcript_text;
$nextVersion = ($project->transcriptVersions()->max('version_number') ?? 0) + 1;
TranscriptVersion::create([
'project_id' => $project->id,
'edited_by' => Auth::id(),
'version_number' => $nextVersion,
'old_text' => $oldText,
'new_text' => $version->new_text,
'change_summary' => "Dipulihkan dari versi {$version->version_number}",
'created_at' => now(),
]);
$project->update([
'transcript_text' => $version->new_text,
'transcription_status' => 'completed',
]);
$audit->log('transcript_version_restored', [
'project_id' => $project->id,
'new_values' => [
'restored_from_version' => $version->version_number,
'new_version' => $nextVersion,
],
]);
return redirect()
->route('user.projects.show', $project)
->with('success', "Versi {$version->version_number} berjaya dipulihkan.");
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->user() || ! $request->user()->isAdmin()) {
abort(403, 'Akses tidak dibenarkan.');
}
return $next($request);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureRegularUser
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->user() || ! $request->user()->isUser()) {
abort(403, 'Akses tidak dibenarkan.');
}
return $next($request);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsActive
{
public function handle(Request $request, Closure $next): Response
{
if (Auth::check() && ! Auth::user()->is_active) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')
->withErrors(['email' => 'Akaun anda telah dinyahaktifkan. Sila hubungi pentadbir.']);
}
return $next($request);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
$response->headers->set(
'Content-Security-Policy',
"default-src 'self'; " .
"script-src 'self' 'unsafe-inline'; " .
"style-src 'self' 'unsafe-inline'; " .
"img-src 'self' data: blob:; " .
"font-src 'self'; " .
"connect-src 'self'; " .
"media-src 'self' blob:; " .
"object-src 'none'; " .
"base-uri 'self'; " .
"form-action 'self'; " .
"frame-ancestors 'self';"
);
if ($request->isSecure()) {
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
return $response;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class CreateUserRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->isAdmin();
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'confirmed', Password::min(8)->mixedCase()->numbers()],
'role' => ['required', 'in:admin,user'],
'department_id' => ['nullable', 'exists:departments,id'],
];
}
public function attributes(): array
{
return [
'name' => 'Nama',
'email' => 'E-mel',
'password' => 'Kata laluan',
'role' => 'Peranan',
'department_id' => 'Jabatan',
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
class TransferOwnershipRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->isAdmin();
}
public function rules(): array
{
return [
'new_owner_id' => ['required', 'integer', 'exists:users,id'],
'justification' => ['required', 'string', 'min:10', 'max:1000'],
];
}
public function attributes(): array
{
return [
'new_owner_id' => 'Pemilik baru',
'justification' => 'Justifikasi',
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateUserEmailRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->isAdmin();
}
public function rules(): array
{
$userId = $this->route('user')->id;
return [
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($userId)],
'justification'=> ['nullable', 'string', 'max:500'],
];
}
public function attributes(): array
{
return [
'email' => 'E-mel baru',
'justification' => 'Justifikasi',
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')) . '|' . $this->ip());
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Requests\User;
use App\Rules\ValidAudioMagicBytes;
use Illuminate\Foundation\Http\FormRequest;
class CreateProjectRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->isActive();
}
public function rules(): array
{
$maxKb = config('speech2text.upload.max_mb', 200) * 1024;
$extensions = implode(',', config('speech2text.upload.allowed_extensions'));
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'audio' => [
'required',
'file',
"mimes:{$extensions}",
"max:{$maxKb}",
new ValidAudioMagicBytes(),
],
'language' => ['nullable', 'string', 'in:ms,en'],
];
}
public function attributes(): array
{
return [
'title' => 'Tajuk projek',
'description' => 'Penerangan',
'audio' => 'Fail audio',
'language' => 'Bahasa',
];
}
public function messages(): array
{
$maxMb = config('speech2text.upload.max_mb', 200);
return [
'audio.max' => "Saiz fail audio melebihi had maksimum {$maxMb}MB.",
'audio.mimes' => 'Format fail tidak disokong. Guna: mp3, wav, m4a, mp4, aac, ogg, flac, webm.',
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\User;
use Illuminate\Foundation\Http\FormRequest;
class StoreCommentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'message' => ['required', 'string', 'max:2000'],
];
}
public function messages(): array
{
return [
'message.required' => 'Mesej komen tidak boleh kosong.',
'message.max' => 'Mesej terlalu panjang (maks 2,000 aksara).',
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\User;
use Illuminate\Foundation\Http\FormRequest;
class UpdateTranscriptRequest extends FormRequest
{
public function authorize(): bool
{
return true; // Policy diperiksa dalam controller
}
public function rules(): array
{
return [
'transcript_text' => ['required', 'string', 'max:500000'],
'change_summary' => ['nullable', 'string', 'max:500'],
];
}
public function messages(): array
{
return [
'transcript_text.required' => 'Teks transkripsi tidak boleh kosong.',
'transcript_text.max' => 'Teks transkripsi terlalu panjang (maks 500,000 aksara).',
'change_summary.max' => 'Ringkasan perubahan terlalu panjang (maks 500 aksara).',
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\User;
use Illuminate\Foundation\Http\FormRequest;
class UploadExternalTranscriptRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'transcript_file' => ['required', 'file', 'mimetypes:text/plain', 'extensions:txt', 'max:10240'],
'clean_with_ollama' => ['nullable', 'boolean'],
'confirmed' => ['nullable', 'boolean'],
];
}
public function messages(): array
{
return [
'transcript_file.required' => 'Sila pilih fail transkripsi (.txt).',
'transcript_file.mimetypes' => 'Fail mesti berformat teks biasa (.txt).',
'transcript_file.extensions'=> 'Hanya fail .txt dibenarkan.',
'transcript_file.max' => 'Saiz fail terlalu besar (maks 10 MB).',
];
}
}