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,24 @@
<?php
namespace App\Actions;
use App\Models\User;
use App\Services\AuditLogService;
class ActivateUserAction
{
public function __construct(private readonly AuditLogService $audit) {}
public function execute(User $user): void
{
$user->update(['is_active' => true]);
$this->audit->log('user_reactivated', [
'subject_type' => User::class,
'subject_id' => $user->id,
'target_user_id' => $user->id,
'old_values' => ['is_active' => false],
'new_values' => ['is_active' => true],
]);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Actions;
use App\Models\User;
use App\Services\AuditLogService;
class ChangeUserEmailAction
{
public function __construct(private readonly AuditLogService $audit) {}
public function execute(User $user, string $newEmail, ?string $justification = null): void
{
$oldEmail = $user->email;
$user->update([
'email' => $newEmail,
'email_verified_at' => null,
]);
$this->audit->log('user_email_changed', [
'subject_type' => User::class,
'subject_id' => $user->id,
'target_user_id' => $user->id,
'old_values' => ['email' => $oldEmail],
'new_values' => ['email' => $newEmail],
'justification' => $justification,
]);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Actions;
use App\Models\User;
use App\Services\AuditLogService;
class CreateUserAction
{
public function __construct(private readonly AuditLogService $audit) {}
public function execute(array $data): User
{
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => $data['password'],
'role' => $data['role'] ?? 'user',
'department_id' => $data['department_id'] ?? null,
'is_active' => true,
]);
$this->audit->log('user_created', [
'subject_type' => User::class,
'subject_id' => $user->id,
'target_user_id' => $user->id,
'new_values' => [
'name' => $user->name,
'email' => $user->email,
'role' => $user->role,
'department_id' => $user->department_id,
],
]);
return $user;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Actions;
use App\Models\User;
use App\Services\AuditLogService;
class DeactivateUserAction
{
public function __construct(private readonly AuditLogService $audit) {}
public function execute(User $user, ?string $justification = null): void
{
$user->update(['is_active' => false]);
// Invalidate all sessions for this user
\DB::table('sessions')->where('user_id', $user->id)->delete();
$this->audit->log('user_deactivated', [
'subject_type' => User::class,
'subject_id' => $user->id,
'target_user_id' => $user->id,
'old_values' => ['is_active' => true],
'new_values' => ['is_active' => false],
'justification' => $justification,
]);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Actions;
use App\Models\User;
use App\Services\AuditLogService;
use Illuminate\Validation\ValidationException;
class DeleteUserAction
{
public function __construct(private readonly AuditLogService $audit) {}
public function execute(User $user, ?string $justification = null): void
{
if ($user->hasUsage()) {
throw ValidationException::withMessages([
'user' => 'Pengguna ini tidak boleh dipadam kerana telah menggunakan sistem. Gunakan deactivate.',
]);
}
$userData = [
'name' => $user->name,
'email' => $user->email,
'role' => $user->role,
];
$this->audit->log('user_deleted', [
'subject_type' => User::class,
'subject_id' => $user->id,
'target_user_id' => $user->id,
'old_values' => $userData,
'justification' => $justification,
]);
$user->forceDelete();
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Actions;
use App\Models\TranscriptionProject;
use App\Models\User;
use App\Services\AuditLogService;
class TransferOwnershipAction
{
public function __construct(private readonly AuditLogService $audit) {}
public function execute(TranscriptionProject $project, User $newOwner, string $justification): void
{
$oldOwner = $project->owner;
$project->update(['owner_user_id' => $newOwner->id]);
$this->audit->log('project_ownership_transferred', [
'subject_type' => TranscriptionProject::class,
'subject_id' => $project->id,
'project_id' => $project->id,
'target_user_id' => $newOwner->id,
'old_values' => [
'owner_user_id' => $oldOwner?->id,
'owner_name' => $oldOwner?->name,
],
'new_values' => [
'owner_user_id' => $newOwner->id,
'owner_name' => $newOwner->name,
],
'justification' => $justification,
]);
}
}

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).',
];
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Jobs;
use App\Models\AuditLog;
use App\Models\TranscriptVersion;
use App\Models\TranscriptionProject;
use App\Services\OllamaService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class OllamaPostProcessJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 180;
public int $tries = 2;
public int $backoff = 15;
public function __construct(private readonly TranscriptionProject $project) {}
public function handle(OllamaService $ollama): void
{
$project = $this->project;
// Reload fresh — status may have changed between dispatch and execution
$project->refresh();
if (! $project->isCompleted() || ! $project->transcript_text) {
return;
}
if (! $ollama->isAvailable()) {
Log::info("OllamaPostProcessJob: Ollama tidak tersedia, skip projek #{$project->id}");
return;
}
$cleaned = $ollama->cleanTranscript($project->transcript_text, $project->language);
if ($cleaned === null) {
Log::warning("OllamaPostProcessJob: hasil null untuk projek #{$project->id}");
return;
}
$oldText = $project->transcript_text;
$nextVersion = ($project->transcriptVersions()->max('version_number') ?? 0) + 1;
TranscriptVersion::create([
'project_id' => $project->id,
'edited_by' => $project->owner_user_id,
'version_number' => $nextVersion,
'old_text' => $oldText,
'new_text' => $cleaned,
'change_summary' => 'Dibersihkan oleh Ollama',
'created_at' => now(),
]);
$project->update(['transcript_text' => $cleaned]);
AuditLog::create([
'actor_user_id' => null,
'actor_role' => 'system',
'action' => 'transcript_postprocessed',
'project_id' => $project->id,
'new_values' => [
'project_uuid' => $project->uuid,
'model' => config('speech2text.ollama.model'),
],
'created_at' => now(),
]);
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Jobs;
use App\Models\AuditLog;
use App\Models\TranscriptionProject;
use App\Services\StorageService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TranscribeAudioJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 600;
public int $tries = 3;
public int $backoff = 30;
public function __construct(private readonly TranscriptionProject $project) {}
public function handle(StorageService $storage): void
{
$project = $this->project;
$project->update(['transcription_status' => 'processing']);
$this->auditLog('transcription_started');
$tmpPath = null;
try {
$path = $project->stored_audio_path;
abort_unless($storage->exists($path), 500, 'Fail audio tidak dijumpai.');
// Stream storage → temp file to avoid loading large files into memory
$tmpPath = tempnam(sys_get_temp_dir(), 'whisper_');
$src = $storage->readStream($path);
$dst = fopen($tmpPath, 'wb');
stream_copy_to_stream($src, $dst);
fclose($src);
fclose($dst);
$workerUrl = rtrim(config('speech2text.transcription.worker_url'), '/');
$response = Http::timeout(540)
->attach(
'audio',
fopen($tmpPath, 'rb'),
basename($path),
['Content-Type' => $project->mime_type]
)
->post("{$workerUrl}/transcribe");
if (! $response->successful()) {
throw new \RuntimeException("Transcription worker mengembalikan HTTP {$response->status()}");
}
$result = $response->json();
if (! ($result['success'] ?? false)) {
throw new \RuntimeException($result['error'] ?? 'Transcription gagal tanpa mesej ralat.');
}
$project->update([
'transcription_status' => 'completed',
'transcript_text' => $result['transcript'] ?? '',
'transcript_confidence' => $result['confidence'] ?? null,
'duration_seconds' => $result['duration_seconds'] ?? null,
'error_message' => null,
'processed_at' => now(),
]);
$this->auditLog('transcription_completed', [
'duration_seconds' => $result['duration_seconds'] ?? null,
'engine' => config('speech2text.transcription.engine'),
]);
// Optional Ollama post-processing
if (config('speech2text.ollama.enabled') && $project->transcript_text) {
OllamaPostProcessJob::dispatch($project);
}
} catch (\Throwable $e) {
Log::error("TranscribeAudioJob gagal untuk project #{$project->id}: {$e->getMessage()}");
$project->update([
'transcription_status' => 'failed',
'error_message' => $e->getMessage(),
]);
$this->auditLog('transcription_failed', ['error' => $e->getMessage()]);
$this->fail($e);
} finally {
if ($tmpPath && file_exists($tmpPath)) {
@unlink($tmpPath);
}
}
}
private function auditLog(string $action, array $extra = []): void
{
AuditLog::create([
'actor_user_id' => null,
'actor_role' => 'system',
'action' => $action,
'project_id' => $this->project->id,
'new_values' => array_merge(['project_uuid' => $this->project->uuid], $extra),
'created_at' => now(),
]);
}
}

46
app/Models/AuditLog.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AuditLog extends Model
{
public $timestamps = false;
protected $fillable = [
'actor_user_id',
'actor_role',
'action',
'subject_type',
'subject_id',
'target_user_id',
'project_id',
'old_values',
'new_values',
'justification',
'ip_address',
'user_agent',
'created_at',
];
protected function casts(): array
{
return [
'old_values' => 'array',
'new_values' => 'array',
'created_at' => 'datetime',
];
}
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_user_id');
}
public function targetUser(): BelongsTo
{
return $this->belongsTo(User::class, 'target_user_id');
}
}

24
app/Models/Department.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Department extends Model
{
use HasFactory;
protected $fillable = ['name', 'code', 'is_active'];
protected function casts(): array
{
return ['is_active' => 'boolean'];
}
public function users(): HasMany
{
return $this->hasMany(User::class);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProjectCollaborator extends Model
{
protected $fillable = ['project_id', 'user_id', 'role', 'added_by'];
public function project(): BelongsTo
{
return $this->belongsTo(TranscriptionProject::class, 'project_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function addedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'added_by');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class ProjectComment extends Model
{
use SoftDeletes;
protected $fillable = ['project_id', 'user_id', 'message'];
public function project(): BelongsTo
{
return $this->belongsTo(TranscriptionProject::class, 'project_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TranscriptVersion extends Model
{
public $timestamps = false;
protected $fillable = [
'project_id',
'edited_by',
'version_number',
'old_text',
'new_text',
'change_summary',
'created_at',
];
protected function casts(): array
{
return ['created_at' => 'datetime'];
}
public function project(): BelongsTo
{
return $this->belongsTo(TranscriptionProject::class, 'project_id');
}
public function editor(): BelongsTo
{
return $this->belongsTo(User::class, 'edited_by');
}
}

View File

@@ -0,0 +1,153 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class TranscriptionProject extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'uuid',
'title',
'description',
'owner_user_id',
'original_filename',
'stored_audio_path',
'mime_type',
'file_size',
'duration_seconds',
'language',
'transcription_status',
'transcription_engine',
'transcript_text',
'transcript_confidence',
'error_message',
'processed_at',
];
protected function casts(): array
{
return [
'transcript_text' => 'encrypted', // enkripsi kandungan sensitif
'transcript_confidence' => 'float',
'processed_at' => 'datetime',
'file_size' => 'integer',
'duration_seconds' => 'integer',
];
}
protected static function booted(): void
{
static::creating(function (self $project) {
if (empty($project->uuid)) {
$project->uuid = (string) Str::uuid();
}
});
}
// Route model binding gunakan uuid
public function getRouteKeyName(): string
{
return 'uuid';
}
// -------------------------------------------------------
// Relationships
// -------------------------------------------------------
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_user_id');
}
public function collaborators(): BelongsToMany
{
return $this->belongsToMany(User::class, 'project_collaborators', 'project_id', 'user_id')
->withPivot('role', 'added_by')
->withTimestamps();
}
public function projectCollaborators(): HasMany
{
return $this->hasMany(ProjectCollaborator::class, 'project_id');
}
public function transcriptVersions(): HasMany
{
return $this->hasMany(TranscriptVersion::class, 'project_id')->orderByDesc('version_number');
}
public function comments(): HasMany
{
return $this->hasMany(ProjectComment::class, 'project_id')->latest();
}
// -------------------------------------------------------
// Helpers
// -------------------------------------------------------
public function isOwnedBy(User $user): bool
{
return $this->owner_user_id === $user->id;
}
public function hasCollaborator(User $user): bool
{
return $this->collaborators()->where('user_id', $user->id)->exists();
}
public function isAccessibleBy(User $user): bool
{
return $this->isOwnedBy($user) || $this->hasCollaborator($user);
}
public function isPending(): bool
{
return $this->transcription_status === 'pending';
}
public function isProcessing(): bool
{
return $this->transcription_status === 'processing';
}
public function isCompleted(): bool
{
return $this->transcription_status === 'completed';
}
public function isFailed(): bool
{
return $this->transcription_status === 'failed';
}
public function fileSizeForHumans(): string
{
$bytes = $this->file_size;
if ($bytes >= 1073741824) {
return number_format($bytes / 1073741824, 2) . ' GB';
}
if ($bytes >= 1048576) {
return number_format($bytes / 1048576, 2) . ' MB';
}
return number_format($bytes / 1024, 2) . ' KB';
}
public function durationForHumans(): ?string
{
if (! $this->duration_seconds) {
return null;
}
$m = intdiv($this->duration_seconds, 60);
$s = $this->duration_seconds % 60;
return sprintf('%d:%02d', $m, $s);
}
}

106
app/Models/User.php Normal file
View File

@@ -0,0 +1,106 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use HasFactory, Notifiable, SoftDeletes;
protected $fillable = [
'name',
'email',
'password',
'role',
'department_id',
'is_active',
'last_login_at',
'email_verified_at',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'last_login_at' => 'datetime',
'password' => 'hashed',
'is_active' => 'boolean',
];
}
// -------------------------------------------------------
// Role helpers
// -------------------------------------------------------
public function isAdmin(): bool
{
return $this->role === 'admin';
}
public function isUser(): bool
{
return $this->role === 'user';
}
public function isActive(): bool
{
return (bool) $this->is_active;
}
// -------------------------------------------------------
// Relationships
// -------------------------------------------------------
public function department(): BelongsTo
{
return $this->belongsTo(Department::class);
}
public function ownedProjects(): HasMany
{
return $this->hasMany(TranscriptionProject::class, 'owner_user_id');
}
public function collaboratingProjects(): BelongsToMany
{
return $this->belongsToMany(
TranscriptionProject::class,
'project_collaborators',
'user_id',
'project_id'
)->withPivot('role', 'added_by')->withTimestamps();
}
public function auditActionsPerformed(): HasMany
{
return $this->hasMany(AuditLog::class, 'actor_user_id');
}
public function auditActionsTargeted(): HasMany
{
return $this->hasMany(AuditLog::class, 'target_user_id');
}
// -------------------------------------------------------
// Usage check (for safe delete)
// -------------------------------------------------------
public function hasUsage(): bool
{
return $this->ownedProjects()->withTrashed()->exists()
|| $this->collaboratingProjects()->exists()
|| AuditLog::where('actor_user_id', $this->id)->exists();
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Policies;
use App\Models\ProjectComment;
use App\Models\TranscriptionProject;
use App\Models\User;
class CommentPolicy
{
public function view(User $user, TranscriptionProject $project): bool
{
return $project->isAccessibleBy($user);
}
public function create(User $user, TranscriptionProject $project): bool
{
return $project->isAccessibleBy($user);
}
public function delete(User $user, ProjectComment $comment): bool
{
$project = $comment->project;
return $comment->user_id === $user->id || $project->isOwnedBy($user);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Policies;
use App\Models\TranscriptVersion;
use App\Models\User;
class TranscriptVersionPolicy
{
public function view(User $user, TranscriptVersion $version): bool
{
return $version->project->isAccessibleBy($user);
}
public function restore(User $user, TranscriptVersion $version): bool
{
$project = $version->project;
if ($project->isOwnedBy($user)) {
return true;
}
return $project->collaborators()
->where('user_id', $user->id)
->wherePivot('role', 'editor')
->exists();
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Policies;
use App\Models\TranscriptionProject;
use App\Models\User;
class TranscriptionProjectPolicy
{
// Admin TIDAK boleh view detail (kandungan)
public function view(User $user, TranscriptionProject $project): bool
{
return $project->isAccessibleBy($user);
}
// Admin TIDAK boleh view metadata content — hanya metadata minimum via viewMetadata
public function viewMetadata(User $user, TranscriptionProject $project): bool
{
return $user->isAdmin() || $project->isAccessibleBy($user);
}
public function create(User $user): bool
{
// Admin juga kakitangan — boleh cipta projek sendiri
return $user->isActive();
}
public function update(User $user, TranscriptionProject $project): bool
{
return $project->isAccessibleBy($user);
}
public function delete(User $user, TranscriptionProject $project): bool
{
return $project->isOwnedBy($user);
}
public function viewAudio(User $user, TranscriptionProject $project): bool
{
return $project->isAccessibleBy($user);
}
public function viewTranscript(User $user, TranscriptionProject $project): bool
{
return $project->isAccessibleBy($user);
}
public function editTranscript(User $user, TranscriptionProject $project): bool
{
if ($project->isOwnedBy($user)) {
return true;
}
// Viewer collaborator tidak boleh edit — editor sahaja
return $project->collaborators()
->where('user_id', $user->id)
->wherePivot('role', 'editor')
->exists();
}
public function manageCollaborators(User $user, TranscriptionProject $project): bool
{
return $project->isOwnedBy($user);
}
public function retryTranscription(User $user, TranscriptionProject $project): bool
{
return $project->isOwnedBy($user) && $project->isFailed();
}
public function transferOwner(User $user, TranscriptionProject $project): bool
{
return $user->isAdmin();
}
public function viewVersionHistory(User $user, TranscriptionProject $project): bool
{
return $project->isAccessibleBy($user);
}
public function restoreVersion(User $user, TranscriptionProject $project): bool
{
if ($project->isOwnedBy($user)) {
return true;
}
return $project->collaborators()
->where('user_id', $user->id)
->wherePivot('role', 'editor')
->exists();
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Policies;
use App\Models\User;
class UserPolicy
{
public function viewAny(User $actor): bool
{
return $actor->isAdmin();
}
public function create(User $actor): bool
{
return $actor->isAdmin();
}
public function update(User $actor, User $target): bool
{
return $actor->isAdmin() && $actor->id !== $target->id;
}
public function delete(User $actor, User $target): bool
{
return $actor->isAdmin()
&& $actor->id !== $target->id
&& ! $target->hasUsage();
}
public function activate(User $actor, User $target): bool
{
return $actor->isAdmin() && $actor->id !== $target->id;
}
public function deactivate(User $actor, User $target): bool
{
return $actor->isAdmin() && $actor->id !== $target->id;
}
public function changeEmail(User $actor, User $target): bool
{
return $actor->isAdmin() && $actor->id !== $target->id;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Providers;
use App\Models\ProjectComment;
use App\Models\TranscriptVersion;
use App\Models\TranscriptionProject;
use App\Models\User;
use App\Policies\CommentPolicy;
use App\Policies\TranscriptVersionPolicy;
use App\Policies\TranscriptionProjectPolicy;
use App\Policies\UserPolicy;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void {}
public function boot(): void
{
// Register Policies
Gate::policy(User::class, UserPolicy::class);
Gate::policy(TranscriptionProject::class, TranscriptionProjectPolicy::class);
Gate::policy(TranscriptVersion::class, TranscriptVersionPolicy::class);
Gate::policy(ProjectComment::class, CommentPolicy::class);
// Rate Limiters
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->input('email') . '|' . $request->ip());
});
RateLimiter::for('upload', function (Request $request) {
return Limit::perHour(10)->by($request->user()?->id ?: $request->ip());
});
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Http\UploadedFile;
class ValidAudioMagicBytes implements ValidationRule
{
// Signatures checked at byte offset 0
private const SIGNATURES = [
[0xFF, 0xFB], // MP3 MPEG-1 Layer 3
[0xFF, 0xF3], // MP3 MPEG-2 Layer 3
[0xFF, 0xF2], // MP3 MPEG-2.5 Layer 3
[0xFF, 0xF1], // AAC ADTS MPEG-4
[0xFF, 0xF9], // AAC ADTS MPEG-2
[0x49, 0x44, 0x33], // MP3 with ID3v2 tag
[0x52, 0x49, 0x46, 0x46], // WAV (RIFF header)
[0x4F, 0x67, 0x67, 0x53], // OGG (OggS)
[0x66, 0x4C, 0x61, 0x43], // FLAC (fLaC)
[0x1A, 0x45, 0xDF, 0xA3], // WebM / MKV
];
// MP4/M4A: ISO Base Media file — 'ftyp' box starts at byte offset 4
private const MP4_SIGNATURE = [0x66, 0x74, 0x79, 0x70]; // 'ftyp'
private const MP4_OFFSET = 4;
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! ($value instanceof UploadedFile) || ! $value->isValid()) {
$fail('Fail audio tidak sah.');
return;
}
$handle = @fopen($value->getRealPath(), 'rb');
if ($handle === false) {
$fail('Fail audio tidak dapat dibaca.');
return;
}
$header = fread($handle, 12);
fclose($handle);
if (strlen($header) < 4) {
$fail('Fail audio terlalu kecil atau rosak.');
return;
}
$bytes = array_values(unpack('C*', $header));
foreach (self::SIGNATURES as $sig) {
if ($this->matchesAt($bytes, $sig, 0)) {
return;
}
}
// Check MP4/M4A: need at least 8 bytes
if (count($bytes) >= self::MP4_OFFSET + count(self::MP4_SIGNATURE)) {
if ($this->matchesAt($bytes, self::MP4_SIGNATURE, self::MP4_OFFSET)) {
return;
}
}
$fail('Format fail audio tidak sah. Kandungan fail tidak menepati format audio yang dibenarkan.');
}
private function matchesAt(array $bytes, array $signature, int $offset): bool
{
foreach ($signature as $i => $expected) {
if (($bytes[$offset + $i] ?? -1) !== $expected) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Services;
use App\Models\AuditLog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AuditLogService
{
public function __construct(private readonly Request $request) {}
public function log(
string $action,
array $options = []
): AuditLog {
$actor = Auth::user();
return AuditLog::create([
'actor_user_id' => $actor?->id ?? $options['actor_user_id'] ?? null,
'actor_role' => $actor?->role ?? $options['actor_role'] ?? 'system',
'action' => $action,
'subject_type' => $options['subject_type'] ?? null,
'subject_id' => $options['subject_id'] ?? null,
'target_user_id'=> $options['target_user_id'] ?? null,
'project_id' => $options['project_id'] ?? null,
'old_values' => $options['old_values'] ?? null,
'new_values' => $options['new_values'] ?? null,
'justification' => $options['justification'] ?? null,
'ip_address' => $this->request->ip(),
'user_agent' => $this->request->userAgent(),
'created_at' => now(),
]);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class OllamaService
{
private string $baseUrl;
private string $model;
public function __construct()
{
$this->baseUrl = rtrim(config('speech2text.ollama.base_url'), '/');
$this->model = config('speech2text.ollama.model');
}
/**
* Clean and improve raw transcript text using a local Ollama model.
* Returns corrected text, or null if Ollama is unavailable.
*/
public function cleanTranscript(string $rawText, string $language = 'ms'): ?string
{
$langLabel = $language === 'ms' ? 'Bahasa Melayu' : 'English';
$prompt = <<<PROMPT
Anda adalah pembantu penyuntingan teks transkripsi automatik dalam {$langLabel}.
Tugas anda:
1. Betulkan ejaan dan tanda baca
2. Pisahkan ayat dengan betul
3. Kekalkan maksud asal JANGAN tukar kandungan atau tambah maklumat baru
4. Kembalikan hanya teks yang telah diperbetulkan, tiada penjelasan tambahan
Teks transkripsi:
{$rawText}
PROMPT;
try {
$response = Http::timeout(120)
->post("{$this->baseUrl}/api/generate", [
'model' => $this->model,
'prompt' => $prompt,
'stream' => false,
]);
if (! $response->successful()) {
Log::warning("OllamaService: HTTP {$response->status()} dari {$this->baseUrl}");
return null;
}
$text = trim($response->json('response') ?? '');
return $text !== '' ? $text : null;
} catch (\Throwable $e) {
Log::warning("OllamaService tidak tersedia: {$e->getMessage()}");
return null;
}
}
public function isAvailable(): bool
{
try {
return Http::timeout(5)->get("{$this->baseUrl}/api/tags")->successful();
} catch (\Throwable) {
return false;
}
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class StorageService
{
private const DISK = 'private';
public function storeAudio(UploadedFile $file, string $uuid): string
{
$ext = $file->getClientOriginalExtension();
$filename = Str::random(40) . '.' . strtolower($ext);
$dir = "transcriptions/{$uuid}/audio";
Storage::disk(self::DISK)->putFileAs($dir, $file, $filename);
return "{$dir}/{$filename}";
}
public function delete(string $path): void
{
if (Storage::disk(self::DISK)->exists($path)) {
Storage::disk(self::DISK)->delete($path);
}
}
public function exists(string $path): bool
{
return Storage::disk(self::DISK)->exists($path);
}
public function size(string $path): int
{
return Storage::disk(self::DISK)->size($path);
}
public function readStream(string $path)
{
return Storage::disk(self::DISK)->readStream($path);
}
}