first
This commit is contained in:
24
app/Actions/ActivateUserAction.php
Normal file
24
app/Actions/ActivateUserAction.php
Normal 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],
|
||||
]);
|
||||
}
|
||||
}
|
||||
30
app/Actions/ChangeUserEmailAction.php
Normal file
30
app/Actions/ChangeUserEmailAction.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
37
app/Actions/CreateUserAction.php
Normal file
37
app/Actions/CreateUserAction.php
Normal 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;
|
||||
}
|
||||
}
|
||||
28
app/Actions/DeactivateUserAction.php
Normal file
28
app/Actions/DeactivateUserAction.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
37
app/Actions/DeleteUserAction.php
Normal file
37
app/Actions/DeleteUserAction.php
Normal 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();
|
||||
}
|
||||
}
|
||||
35
app/Actions/TransferOwnershipAction.php
Normal file
35
app/Actions/TransferOwnershipAction.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
89
app/Http/Controllers/Admin/AuditLogController.php
Normal file
89
app/Http/Controllers/Admin/AuditLogController.php
Normal 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}\"",
|
||||
]);
|
||||
}
|
||||
}
|
||||
65
app/Http/Controllers/Admin/DashboardController.php
Normal file
65
app/Http/Controllers/Admin/DashboardController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
65
app/Http/Controllers/Admin/ProjectMetadataController.php
Normal file
65
app/Http/Controllers/Admin/ProjectMetadataController.php
Normal 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}.");
|
||||
}
|
||||
}
|
||||
106
app/Http/Controllers/Admin/UserController.php
Normal file
106
app/Http/Controllers/Admin/UserController.php
Normal 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.");
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
58
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
13
app/Http/Controllers/Controller.php
Normal file
13
app/Http/Controllers/Controller.php
Normal 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;
|
||||
}
|
||||
40
app/Http/Controllers/User/AudioController.php
Normal file
40
app/Http/Controllers/User/AudioController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
72
app/Http/Controllers/User/CollaboratorController.php
Normal file
72
app/Http/Controllers/User/CollaboratorController.php
Normal 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.");
|
||||
}
|
||||
}
|
||||
52
app/Http/Controllers/User/CommentController.php
Normal file
52
app/Http/Controllers/User/CommentController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
26
app/Http/Controllers/User/DashboardController.php
Normal file
26
app/Http/Controllers/User/DashboardController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
122
app/Http/Controllers/User/ProjectController.php
Normal file
122
app/Http/Controllers/User/ProjectController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
112
app/Http/Controllers/User/TranscriptController.php
Normal file
112
app/Http/Controllers/User/TranscriptController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
67
app/Http/Controllers/User/TranscriptVersionController.php
Normal file
67
app/Http/Controllers/User/TranscriptVersionController.php
Normal 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.");
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/EnsureAdmin.php
Normal file
19
app/Http/Middleware/EnsureAdmin.php
Normal 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);
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/EnsureRegularUser.php
Normal file
19
app/Http/Middleware/EnsureRegularUser.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
app/Http/Middleware/EnsureUserIsActive.php
Normal file
25
app/Http/Middleware/EnsureUserIsActive.php
Normal 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);
|
||||
}
|
||||
}
|
||||
41
app/Http/Middleware/SecurityHeaders.php
Normal file
41
app/Http/Middleware/SecurityHeaders.php
Normal 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;
|
||||
}
|
||||
}
|
||||
36
app/Http/Requests/Admin/CreateUserRequest.php
Normal file
36
app/Http/Requests/Admin/CreateUserRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
29
app/Http/Requests/Admin/TransferOwnershipRequest.php
Normal file
29
app/Http/Requests/Admin/TransferOwnershipRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/Admin/UpdateUserEmailRequest.php
Normal file
32
app/Http/Requests/Admin/UpdateUserEmailRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
64
app/Http/Requests/Auth/LoginRequest.php
Normal file
64
app/Http/Requests/Auth/LoginRequest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
52
app/Http/Requests/User/CreateProjectRequest.php
Normal file
52
app/Http/Requests/User/CreateProjectRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
28
app/Http/Requests/User/StoreCommentRequest.php
Normal file
28
app/Http/Requests/User/StoreCommentRequest.php
Normal 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).',
|
||||
];
|
||||
}
|
||||
}
|
||||
30
app/Http/Requests/User/UpdateTranscriptRequest.php
Normal file
30
app/Http/Requests/User/UpdateTranscriptRequest.php
Normal 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).',
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/User/UploadExternalTranscriptRequest.php
Normal file
32
app/Http/Requests/User/UploadExternalTranscriptRequest.php
Normal 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).',
|
||||
];
|
||||
}
|
||||
}
|
||||
76
app/Jobs/OllamaPostProcessJob.php
Normal file
76
app/Jobs/OllamaPostProcessJob.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
120
app/Jobs/TranscribeAudioJob.php
Normal file
120
app/Jobs/TranscribeAudioJob.php
Normal 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
46
app/Models/AuditLog.php
Normal 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
24
app/Models/Department.php
Normal 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);
|
||||
}
|
||||
}
|
||||
26
app/Models/ProjectCollaborator.php
Normal file
26
app/Models/ProjectCollaborator.php
Normal 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');
|
||||
}
|
||||
}
|
||||
24
app/Models/ProjectComment.php
Normal file
24
app/Models/ProjectComment.php
Normal 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);
|
||||
}
|
||||
}
|
||||
36
app/Models/TranscriptVersion.php
Normal file
36
app/Models/TranscriptVersion.php
Normal 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');
|
||||
}
|
||||
}
|
||||
153
app/Models/TranscriptionProject.php
Normal file
153
app/Models/TranscriptionProject.php
Normal 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
106
app/Models/User.php
Normal 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();
|
||||
}
|
||||
}
|
||||
26
app/Policies/CommentPolicy.php
Normal file
26
app/Policies/CommentPolicy.php
Normal 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);
|
||||
}
|
||||
}
|
||||
26
app/Policies/TranscriptVersionPolicy.php
Normal file
26
app/Policies/TranscriptVersionPolicy.php
Normal 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();
|
||||
}
|
||||
}
|
||||
90
app/Policies/TranscriptionProjectPolicy.php
Normal file
90
app/Policies/TranscriptionProjectPolicy.php
Normal 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();
|
||||
}
|
||||
}
|
||||
45
app/Policies/UserPolicy.php
Normal file
45
app/Policies/UserPolicy.php
Normal 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;
|
||||
}
|
||||
}
|
||||
40
app/Providers/AppServiceProvider.php
Normal file
40
app/Providers/AppServiceProvider.php
Normal 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());
|
||||
});
|
||||
}
|
||||
}
|
||||
77
app/Rules/ValidAudioMagicBytes.php
Normal file
77
app/Rules/ValidAudioMagicBytes.php
Normal 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;
|
||||
}
|
||||
}
|
||||
35
app/Services/AuditLogService.php
Normal file
35
app/Services/AuditLogService.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
71
app/Services/OllamaService.php
Normal file
71
app/Services/OllamaService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
app/Services/StorageService.php
Normal file
45
app/Services/StorageService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user