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