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

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
.git
.github
node_modules
npm-debug.log
storage/app/private
storage/logs
storage/framework/cache
storage/framework/sessions
storage/framework/views
.env
.env.*
!.env.example
*.md
docker/transcription-worker
tests

18
.editorconfig Normal file
View File

@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4

90
.env.example Normal file
View File

@@ -0,0 +1,90 @@
APP_NAME="Speech2Text MBIP"
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=https://speech2text.mbip.my
APP_LOCALE=ms
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=ms_MY
APP_MAINTENANCE_DRIVER=file
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=error
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=speech2text
DB_USERNAME=speech2text
DB_PASSWORD=
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=true
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
CACHE_STORE=redis
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="noreply@mbip.my"
MAIL_FROM_NAME="${APP_NAME}"
# ============================================================
# Storage
# ============================================================
PRIVATE_AUDIO_MAX_MB=200
# ============================================================
# Transcription Engine
# ============================================================
TRANSCRIPTION_ENGINE=faster-whisper
WHISPER_MODEL=small
WHISPER_LANGUAGE=ms
WHISPER_DEVICE=cpu
WHISPER_COMPUTE_TYPE=int8
TRANSCRIPTION_WORKER_URL=http://transcription-worker:8000
# ============================================================
# Ollama (optional — local LLM post-processing)
# Default: disabled. Jangan aktifkan jika tidak diperlukan.
# ============================================================
OLLAMA_ENABLED=false
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_MODEL=llama3.1
# ============================================================
# Admin Default (untuk seeder pertama kali)
# Tukar password selepas login pertama!
# ============================================================
ADMIN_DEFAULT_NAME=
ADMIN_DEFAULT_EMAIL=
ADMIN_DEFAULT_PASSWORD=
# ============================================================
# MySQL Root (untuk Docker)
# ============================================================
MYSQL_ROOT_PASSWORD=
MYSQL_DATABASE=speech2text
MYSQL_USER=speech2text
MYSQL_PASSWORD=

11
.gitattributes vendored Normal file
View File

@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
*.log
.DS_Store
.env
.env.*
!.env.example
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.codex
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
ignore-scripts=true
audit=true

245
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,245 @@
# ARCHITECTURE.md — Speech2Text MBIP
## Gambaran Keseluruhan
Sistem Speech-to-Text MBIP ialah aplikasi web dalaman yang membolehkan pengguna jabatan muat naik fail audio dan mendapatkan transkripsi teks Bahasa Melayu secara automatik. Semua pemprosesan berlaku dalam persekitaran Docker yang self-hosted, tanpa hantar data ke cloud luaran.
---
## Stack Teknologi
| Lapisan | Teknologi |
|---|---|
| Web Framework | Laravel 11 (PHP 8.3+) |
| Database | MySQL 8 |
| Cache / Queue | Redis |
| Frontend | Bootstrap 5 + jQuery |
| Web Server | Nginx + PHP-FPM |
| Queue Worker | Laravel Queue (Redis driver) |
| Transcription Engine | faster-whisper (Python) via HTTP API |
| Post-processing (optional) | Ollama (local LLM) |
| Container | Docker Compose |
| Storage | Laravel Private Disk (local filesystem) |
---
## Seni Bina Perkhidmatan (Docker Compose)
```
┌─────────────────────────────────────────────────────────────┐
│ DOCKER NETWORK │
│ │
│ ┌───────────┐ ┌───────────┐ ┌──────────────────────┐ │
│ │ nginx │───▶│ app │───▶│ mysql │ │
│ │ :80/443 │ │ (php-fpm) │ │ :3306 │ │
│ └───────────┘ └─────┬─────┘ └──────────────────────┘ │
│ │ │
│ ┌────▼────┐ ┌──────────────────────┐ │
│ │ redis │ │ transcription-worker│ │
│ │ :6379 │ │ (Python FastAPI) │ │
│ └─────────┘ │ :8000 │ │
│ │ └──────────────────────┘ │
│ ┌──────────┴───┐ ┌──────────────────────┐ │
│ │queue-worker │ │ ollama (optional) │ │
│ │(Laravel) │ │ :11434 │ │
│ └──────────────┘ └──────────────────────┘ │
│ ┌──────────────┐ │
│ │ scheduler │ │
│ │(Laravel cron)│ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### Perkhidmatan Docker
#### `app` — Laravel PHP-FPM
- Image: php:8.3-fpm (custom Dockerfile)
- Mount: `/svr/speech2text/app``/var/www/html`
- Tanggungjawab: serve HTTP request, jalankan Artisan commands
#### `nginx`
- Image: nginx:alpine
- Port: 80, 443
- Proxy pass ke `app:9000`
#### `mysql`
- Image: mysql:8
- Volume: data persistent
- Database: `speech2text`
#### `redis`
- Image: redis:alpine
- Digunakan untuk: queue, cache, session
#### `queue-worker`
- Build dari image `app` yang sama
- Command: `php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600`
- Proses job transcription, notification, audit
#### `scheduler`
- Build dari image `app` yang sama
- Command: `php artisan schedule:work`
- Proses scheduled tasks (cleanup, retention policy)
#### `transcription-worker`
- Image: Python 3.11 + faster-whisper (custom Dockerfile)
- Port: 8000 (internal sahaja)
- Expose endpoint `/transcribe` (POST)
- Tidak accessible dari luar Docker network
#### `ollama` (optional)
- Image: ollama/ollama
- Hanya diaktifkan jika `OLLAMA_ENABLED=true`
- Digunakan untuk post-processing teks selepas transcription
---
## Aliran Data Transcription
```
User Upload Audio
[Laravel Controller]
- Validate file (type, size)
- Simpan ke storage/app/private/transcriptions/{uuid}/audio/
- Cipta rekod TranscriptionProject (status: pending)
- Dispatch TranscribeAudioJob ke Redis queue
[Queue Worker — TranscribeAudioJob]
- Update status: processing
- Baca fail audio dari private storage
- Hantar ke transcription-worker via HTTP POST
[transcription-worker — Python FastAPI]
- Terima audio (base64 atau multipart)
- Jalankan faster-whisper dengan model yang dikonfig
- Return transcript JSON
[Queue Worker — callback]
- Simpan transcript ke database
- Update status: completed / failed
- Log audit
- (Optional) Dispatch OllamaPostProcessJob
[User melihat hasil transcript]
```
---
## Struktur Direktori Laravel
```
app/
├── Actions/ # Single-purpose action classes
│ ├── CreateUserAction.php
│ ├── DeactivateUserAction.php
│ ├── TransferProjectOwnerAction.php
│ └── ...
├── Http/
│ ├── Controllers/
│ │ ├── Admin/
│ │ │ ├── DashboardController.php
│ │ │ ├── UserController.php
│ │ │ ├── ProjectMetadataController.php
│ │ │ ├── TransferOwnershipController.php
│ │ │ └── AuditLogController.php
│ │ └── User/
│ │ ├── DashboardController.php
│ │ ├── ProjectController.php
│ │ ├── AudioController.php
│ │ ├── TranscriptController.php
│ │ ├── CollaboratorController.php
│ │ ├── CommentController.php
│ │ └── TranscriptVersionController.php
│ ├── Requests/ # Form Request validation
│ └── Middleware/
│ ├── EnsureUserIsActive.php
│ └── EnsureAdminCannotAccessContent.php
├── Jobs/
│ ├── TranscribeAudioJob.php
│ └── OllamaPostProcessJob.php
├── Models/
│ ├── User.php
│ ├── Department.php
│ ├── TranscriptionProject.php
│ ├── ProjectCollaborator.php
│ ├── TranscriptVersion.php
│ ├── ProjectComment.php
│ └── AuditLog.php
├── Policies/
│ ├── TranscriptionProjectPolicy.php
│ ├── CommentPolicy.php
│ ├── TranscriptVersionPolicy.php
│ └── UserPolicy.php
├── Services/
│ ├── TranscriptionService.php
│ ├── OllamaService.php
│ ├── AuditLogService.php
│ └── StorageService.php
└── ...
resources/
├── views/
│ ├── layouts/
│ │ ├── app.blade.php # Layout pengguna
│ │ └── admin.blade.php # Layout admin
│ ├── auth/
│ ├── admin/
│ │ ├── dashboard.blade.php
│ │ ├── users/
│ │ ├── projects/
│ │ └── audit-logs/
│ └── user/
│ ├── dashboard.blade.php
│ ├── projects/
│ └── ...
```
---
## Authorization Model (Ringkas)
```
Admin:
✓ Urus pengguna (CRUD metadata)
✓ Lihat statistik
✓ Lihat metadata projek sahaja
✓ Transfer ownership dengan justifikasi
✓ Lihat audit log pentadbiran
✗ Dengar / download audio
✗ Baca transcript
✗ Lihat komen
✗ Masuk detail projek (kandungan)
Owner:
✓ Semua akses ke projek sendiri
✓ Urus collaborators
✓ Delete projek / audio / teks
✓ Retry transcription
Collaborator:
✓ Lihat / edit transcript
✓ Dengar audio
✓ Buat komen
✗ Delete projek
✗ Transfer ownership
✗ Urus collaborators lain
```
---
## Prinsip Reka Bentuk
1. **Thin Controller** — logic dalam Action class atau Service, bukan dalam controller.
2. **Policy Enforcement** — setiap action semak Policy, bukan hanya UI.
3. **Private Storage** — tiada fail audio/teks dalam `public/` atau `storage/app/public/`.
4. **UUID Routes** — gunakan UUID bukan ID integer dalam URL projek.
5. **Audit Everything** — semua tindakan sensitif direkod dalam `audit_logs`.
6. **Queue All Transcription** — proses transcription tidak boleh block HTTP request.
7. **No Cloud Dependency** — semua AI/ML processing dalam Docker network sahaja.
8. **Local-only Default** — Ollama disabled by default; hanya aktif jika eksplisit dikonfig.

272
DATABASE_DESIGN.md Normal file
View File

@@ -0,0 +1,272 @@
# DATABASE_DESIGN.md — Speech2Text MBIP
## Senarai Jadual
1. `users`
2. `departments`
3. `transcription_projects`
4. `project_collaborators`
5. `transcript_versions`
6. `project_comments`
7. `audit_logs`
8. `sessions`
9. `jobs` / `failed_jobs`
10. `cache`
---
## Skema Jadual
### 1. `users`
```sql
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
role ENUM('admin', 'user') NOT NULL DEFAULT 'user',
department_id BIGINT UNSIGNED NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
last_login_at TIMESTAMP NULL,
email_verified_at TIMESTAMP NULL,
remember_token VARCHAR(100) NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL, -- soft delete
FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL
);
```
**Catatan:**
- `deleted_at` hanya digunakan jika pengguna belum pernah guna aplikasi (tiada projek, tiada audit).
- Jika pernah guna, hanya `is_active = 0` (deactivate), jangan hard delete.
- `role` enum mudah diurus; boleh upgrade ke `spatie/laravel-permission` kemudian jika perlu.
---
### 2. `departments`
```sql
CREATE TABLE departments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
code VARCHAR(50) NULL UNIQUE,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL
);
```
---
### 3. `transcription_projects`
```sql
CREATE TABLE transcription_projects (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE, -- digunakan dalam URL
title VARCHAR(255) NOT NULL,
description TEXT NULL,
owner_user_id BIGINT UNSIGNED NOT NULL,
original_filename VARCHAR(500) NOT NULL,
stored_audio_path VARCHAR(1000) NOT NULL, -- path relatif dalam private disk
mime_type VARCHAR(100) NOT NULL,
file_size BIGINT UNSIGNED NOT NULL, -- bytes
duration_seconds INT UNSIGNED NULL,
language VARCHAR(10) NOT NULL DEFAULT 'ms',
transcription_status ENUM('pending','processing','completed','failed') NOT NULL DEFAULT 'pending',
transcription_engine VARCHAR(50) NULL, -- e.g. 'faster-whisper'
transcript_text LONGTEXT NULL, -- kandungan sensitif
transcript_confidence DECIMAL(5,4) NULL, -- 0.0000 - 1.0000
error_message TEXT NULL,
processed_at TIMESTAMP NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL, -- soft delete
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE RESTRICT
);
```
**Catatan keselamatan:**
- `stored_audio_path` adalah path dalam private storage, bukan URL awam.
- `transcript_text` disimpan dalam database. Untuk keselamatan lanjut, boleh encrypt menggunakan Laravel `encrypted` cast.
- Admin **tidak** boleh SELECT `transcript_text`, `stored_audio_path` melalui policy/query scope.
---
### 4. `project_collaborators`
```sql
CREATE TABLE project_collaborators (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
role ENUM('editor', 'viewer') NOT NULL DEFAULT 'editor',
added_by BIGINT UNSIGNED NOT NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
UNIQUE KEY unique_project_user (project_id, user_id),
FOREIGN KEY (project_id) REFERENCES transcription_projects(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE RESTRICT
);
```
---
### 5. `transcript_versions`
```sql
CREATE TABLE transcript_versions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT UNSIGNED NOT NULL,
edited_by BIGINT UNSIGNED NOT NULL,
version_number INT UNSIGNED NOT NULL,
old_text LONGTEXT NULL,
new_text LONGTEXT NOT NULL,
change_summary VARCHAR(500) NULL,
created_at TIMESTAMP NULL,
FOREIGN KEY (project_id) REFERENCES transcription_projects(id) ON DELETE CASCADE,
FOREIGN KEY (edited_by) REFERENCES users(id) ON DELETE RESTRICT
);
```
**Catatan:**
- `old_text` dan `new_text` adalah snapshot penuh, bukan diff, untuk kemudahan restore.
- Jangan masukkan `transcript_text` dalam `audit_logs`; gunakan jadual ini sebagai ganti.
- Admin tidak boleh akses jadual ini.
---
### 6. `project_comments`
```sql
CREATE TABLE project_comments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
message TEXT NOT NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
deleted_at TIMESTAMP NULL, -- soft delete
FOREIGN KEY (project_id) REFERENCES transcription_projects(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT
);
```
---
### 7. `audit_logs`
```sql
CREATE TABLE audit_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
actor_user_id BIGINT UNSIGNED NULL, -- NULL jika sistem/background job
actor_role VARCHAR(50) NULL,
action VARCHAR(100) NOT NULL, -- e.g. 'user_deactivated'
subject_type VARCHAR(100) NULL, -- e.g. 'App\Models\User'
subject_id BIGINT UNSIGNED NULL,
target_user_id BIGINT UNSIGNED NULL,
project_id BIGINT UNSIGNED NULL,
old_values JSON NULL, -- JANGAN masukkan transcript content
new_values JSON NULL, -- JANGAN masukkan transcript content
justification TEXT NULL,
ip_address VARCHAR(45) NULL, -- support IPv6
user_agent TEXT NULL,
created_at TIMESTAMP NULL,
INDEX idx_actor (actor_user_id),
INDEX idx_action (action),
INDEX idx_project (project_id),
INDEX idx_created (created_at)
);
```
**Tindakan yang diaudit:**
| action | Penerangan |
|---|---|
| `user_created` | Admin daftar pengguna baru |
| `user_deactivated` | Admin deactivate pengguna |
| `user_reactivated` | Admin aktifkan semula pengguna |
| `user_deleted` | Admin delete pengguna (hanya jika belum guna) |
| `user_email_changed` | Admin tukar emel pengguna |
| `project_created` | Pengguna cipta projek |
| `audio_uploaded` | Pengguna muat naik audio |
| `transcription_started` | Queue worker mula proses |
| `transcription_completed` | Queue worker selesai |
| `transcription_failed` | Queue worker gagal |
| `transcript_updated` | Owner/collaborator edit teks |
| `transcript_version_restored` | Restore versi lama |
| `collaborator_added` | Owner tambah collaborator |
| `collaborator_removed` | Owner buang collaborator |
| `comment_created` | Pengguna buat komen |
| `project_deleted` | Owner delete projek |
| `project_owner_transferred` | Admin transfer ownership |
---
## Hubungan Model (Eloquent Relationships)
```
User
├── hasMany: TranscriptionProject (as owner)
├── belongsToMany: TranscriptionProject (through ProjectCollaborator)
├── hasMany: TranscriptVersion (as editor)
├── hasMany: ProjectComment
├── belongsTo: Department
└── hasMany: AuditLog (as actor)
TranscriptionProject
├── belongsTo: User (owner)
├── hasMany: ProjectCollaborator
├── hasMany: TranscriptVersion
├── hasMany: ProjectComment
└── belongsToMany: User (collaborators)
Department
└── hasMany: User
```
---
## Indeks Penting
```sql
-- Cari projek mengikut status (untuk admin dashboard)
ALTER TABLE transcription_projects ADD INDEX idx_status (transcription_status);
-- Cari projek mengikut owner
ALTER TABLE transcription_projects ADD INDEX idx_owner (owner_user_id);
-- Cari versi mengikut projek (timeline)
ALTER TABLE transcript_versions ADD INDEX idx_project_version (project_id, version_number);
-- Audit log search
ALTER TABLE audit_logs ADD INDEX idx_target_user (target_user_id);
ALTER TABLE audit_logs ADD INDEX idx_subject (subject_type, subject_id);
```
---
## Nota Keselamatan Data
1. **`transcript_text`** — Kolum sensitif. Boleh encrypt menggunakan Laravel cast `encrypted`:
```php
protected $casts = [
'transcript_text' => 'encrypted',
];
```
Ini encrypt menggunakan `APP_KEY`. Pastikan `APP_KEY` disimpan dengan selamat.
2. **`stored_audio_path`** — Simpan path relatif sahaja, bukan absolute path. Contoh: `transcriptions/abc-uuid/audio/recording.mp3`.
3. **Audit log** — Jangan masukkan `transcript_text` dalam `old_values` atau `new_values`. Gunakan `transcript_versions` untuk simpan snapshot teks.
4. **Soft delete**`transcription_projects` dan `project_comments` menggunakan soft delete. Fail audio fizikal dikekalkan dalam private storage sehingga admin jalankan retention cleanup.

318
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,318 @@
# DEPLOYMENT.md — Speech2Text MBIP
## Keperluan Server
| Komponen | Minimum | Disyorkan |
|---|---|---|
| OS | Ubuntu 22.04 LTS | Ubuntu 24.04 LTS |
| CPU | 4 core | 8 core |
| RAM | 8 GB | 16 GB |
| Storage | 100 GB | 500 GB+ (untuk audio files) |
| Docker | 24.x | Latest stable |
| Docker Compose | 2.x | Latest stable |
**Nota:** RAM tambahan diperlukan jika menggunakan Whisper model yang lebih besar (medium/large).
---
## Struktur Direktori Server
```
/svr/speech2text/
├── .env # environment variables (JANGAN commit ke git)
├── .env.example # template (commit ke git)
├── docker-compose.yml # development / staging
├── docker-compose.prod.yml # production override
├── Dockerfile # Laravel app image
├── docker/
│ ├── nginx/
│ │ └── default.conf
│ ├── php/
│ │ └── php.ini
│ ├── supervisor/
│ │ └── supervisord.conf
│ └── transcription-worker/
│ └── Dockerfile
├── app/ # Laravel source code
│ ├── storage/
│ │ └── app/
│ │ └── private/ # fail audio (tidak accessible web)
│ └── ...
└── mysql-data/ # MySQL data volume (auto-created)
```
---
## Langkah Deployment Pertama Kali
### 1. Persediaan Server
```bash
# Update server
sudo apt update && sudo apt upgrade -y
# Install Docker
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
newgrp docker
# Verify
docker --version
docker compose version
```
### 2. Clone Repository
```bash
sudo mkdir -p /svr/speech2text
sudo chown $USER:$USER /svr/speech2text
cd /svr/speech2text
git clone <repo-url> .
```
### 3. Konfigurasi Environment
```bash
cp .env.example .env
nano .env # atau gunakan editor pilihan anda
```
Nilai wajib diisi dalam `.env`:
```
APP_KEY= # akan dijanakan kemudian
DB_PASSWORD=<password-kuat>
ADMIN_DEFAULT_NAME=<nama-admin>
ADMIN_DEFAULT_EMAIL=<email-admin>
ADMIN_DEFAULT_PASSWORD=<password-admin-kuat>
```
### 4. Build dan Jalankan Container
```bash
cd /svr/speech2text
docker compose up -d --build
```
### 5. Setup Laravel
```bash
# Jana APP_KEY
docker compose exec app php artisan key:generate
# Jalankan migration dan seeder
docker compose exec app php artisan migrate --seed
# Set permission storage
docker compose exec app chmod -R 775 storage bootstrap/cache
docker compose exec app chown -R www-data:www-data storage bootstrap/cache
```
### 6. Verify Services
```bash
# Semak semua container berjalan
docker compose ps
# Semak logs
docker compose logs -f app
docker compose logs -f queue-worker
docker compose logs -f transcription-worker
# Semak queue worker
docker compose exec app php artisan queue:monitor
```
---
## Deployment Update (Selepas Push Kod Baru)
```bash
cd /svr/speech2text
# Pull kod terbaru
git pull origin main
# Rebuild container jika ada perubahan Dockerfile
docker compose up -d --build app
# Atau restart tanpa rebuild jika hanya kod PHP berubah
docker compose restart app queue-worker scheduler
# Jalankan migration baru (jika ada)
docker compose exec app php artisan migrate --force
# Clear cache
docker compose exec app php artisan config:cache
docker compose exec app php artisan route:cache
docker compose exec app php artisan view:cache
# Restart queue worker untuk ambil kod baru
docker compose restart queue-worker
```
---
## Production Override (`docker-compose.prod.yml`)
```bash
# Jalankan dengan production config
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
```
---
## Konfigurasi Nginx (Luar Docker — jika ada reverse proxy)
Jika server ada Nginx luar Docker sebagai reverse proxy:
```nginx
server {
listen 443 ssl;
server_name speech2text.mbip.my;
ssl_certificate /etc/ssl/certs/mbip.crt;
ssl_certificate_key /etc/ssl/private/mbip.key;
location / {
proxy_pass http://127.0.0.1:8080; # port Docker nginx
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
client_max_body_size 210M; # PRIVATE_AUDIO_MAX_MB + buffer
}
}
server {
listen 80;
server_name speech2text.mbip.my;
return 301 https://$host$request_uri;
}
```
---
## Backup
### Database Backup
```bash
# Backup manual
docker compose exec mysql mysqldump \
-u root -p$DB_PASSWORD \
speech2text > backup_$(date +%Y%m%d_%H%M%S).sql
# Compress
gzip backup_*.sql
```
### Storage Backup
```bash
# Backup fail audio (private storage)
tar -czf audio_backup_$(date +%Y%m%d).tar.gz \
/svr/speech2text/app/storage/app/private/
```
### Automated Backup (crontab)
```bash
# Edit crontab
crontab -e
# Tambah:
0 2 * * * cd /svr/speech2text && docker compose exec -T mysql \
mysqldump -u root -p$DB_PASSWORD speech2text | \
gzip > /backup/db/speech2text_$(date +\%Y\%m\%d).sql.gz
```
---
## Monitoring
```bash
# Semak status container
docker compose ps
# Lihat penggunaan resource
docker stats
# Lihat logs real-time
docker compose logs -f
# Logs spesifik service
docker compose logs -f queue-worker
docker compose logs -f transcription-worker
# Semak queue jobs pending
docker compose exec app php artisan queue:monitor
# Semak failed jobs
docker compose exec app php artisan queue:failed
```
---
## Troubleshooting
### Container tidak start
```bash
docker compose logs <service-name>
docker compose config # semak syntax docker-compose.yml
```
### Transcription gagal
```bash
# Semak transcription worker
docker compose logs transcription-worker
# Test transcription worker secara manual
docker compose exec app curl http://transcription-worker:8000/health
# Retry failed jobs
docker compose exec app php artisan queue:retry all
```
### Storage permission error
```bash
docker compose exec app chmod -R 775 storage bootstrap/cache
docker compose exec app chown -R www-data:www-data storage
```
### Database migration error
```bash
# Semak status migration
docker compose exec app php artisan migrate:status
# Rollback jika perlu
docker compose exec app php artisan migrate:rollback
```
---
## Checklist Pre-Production
- [ ] `APP_ENV=production`
- [ ] `APP_DEBUG=false`
- [ ] `APP_KEY` telah dijanakan dan disimpan dengan selamat
- [ ] Database password kuat (minimum 16 karakter, alphanumeric + simbol)
- [ ] HTTPS/TLS dikonfig
- [ ] Firewall: hanya port 80/443 terbuka dari internet
- [ ] Port 3306 (MySQL), 6379 (Redis), 8000 (transcription-worker) TIDAK terbuka dari internet
- [ ] `.env` dalam `.gitignore`
- [ ] `storage/app/private/` tidak accessible dari web
- [ ] Backup berjalan secara automatik
- [ ] Log rotation dikonfig
- [ ] Admin default password ditukar selepas login pertama
- [ ] Rate limiting aktif
- [ ] Queue worker berjalan
- [ ] Scheduler berjalan
- [ ] Test upload audio berjaya
- [ ] Test transcription berjaya
- [ ] Test authorization (admin tidak dapat akses kandungan) lulus

52
Dockerfile Normal file
View File

@@ -0,0 +1,52 @@
FROM php:8.4-fpm
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
curl \
libpng-dev \
libonig-dev \
libxml2-dev \
libzip-dev \
zip \
unzip \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Install PHP extensions
RUN docker-php-ext-install \
pdo_mysql \
mbstring \
exif \
pcntl \
bcmath \
gd \
zip \
opcache
# Install Redis extension
RUN pecl install redis && docker-php-ext-enable redis
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Set working directory
WORKDIR /var/www/html
# Copy application files
COPY . .
# Install PHP dependencies (production only)
RUN composer install --optimize-autoloader --no-dev --no-interaction
# Set permissions
RUN chown -R www-data:www-data /var/www/html \
&& chmod -R 755 /var/www/html/storage \
&& chmod -R 755 /var/www/html/bootstrap/cache
# Copy custom php.ini
COPY docker/php/php.ini /usr/local/etc/php/conf.d/app.ini
EXPOSE 9000
CMD ["php-fpm"]

58
README.md Normal file
View File

@@ -0,0 +1,58 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
## Agentic Development
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
```bash
composer require laravel/boost --dev
php artisan boost:install
```
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

277
SECURITY_MODEL.md Normal file
View File

@@ -0,0 +1,277 @@
# SECURITY_MODEL.md — Speech2Text MBIP
## Prinsip Asas Keselamatan
Sistem ini direka untuk menyimpan data sensitif kerajaan/PBT. Prinsip berikut mesti dipatuhi di setiap lapisan:
1. **Privacy by Design** — Admin ialah pentadbir akaun, bukan pentadbir kandungan.
2. **Defense in Depth** — Authorization dikuatkuasakan di Policy, Controller, Query, dan UI.
3. **Least Privilege** — Setiap role hanya mendapat akses minimum yang diperlukan.
4. **Audit Everything** — Semua tindakan sensitif direkodkan dengan lengkap.
5. **No Cloud Egress** — Data tidak keluar dari Docker network.
---
## Model Kebenaran (Authorization)
### Admin — APA yang BOLEH dan TIDAK BOLEH
| Tindakan | Admin |
|---|---|
| Daftar pengguna baru | ✓ |
| Lihat senarai pengguna | ✓ |
| Activate/deactivate pengguna | ✓ |
| Delete pengguna (belum guna) | ✓ |
| Tukar emel pengguna | ✓ |
| Lihat statistik penggunaan (tanpa kandungan) | ✓ |
| Lihat metadata projek (tajuk, owner, status, saiz, tarikh) | ✓ |
| Transfer ownership projek (dengan justifikasi) | ✓ |
| Lihat audit log pentadbiran | ✓ |
| **Dengar / download audio** | ✗ |
| **Baca transcript** | ✗ |
| **Lihat komen dalam projek** | ✗ |
| **Masuk halaman detail projek (kandungan)** | ✗ |
| **Edit transcript** | ✗ |
### Owner (Pengguna Jabatan — pemilik projek)
| Tindakan | Owner |
|---|---|
| Cipta projek | ✓ |
| Upload audio | ✓ |
| Lihat / dengar audio sendiri | ✓ |
| Lihat / edit transcript sendiri | ✓ |
| Tambah / buang collaborator | ✓ |
| Delete projek / audio / teks | ✓ |
| Retry transcription | ✓ |
| Lihat versi history | ✓ |
| Restore versi lama | ✓ |
| Buat komen | ✓ |
### Collaborator (Pengguna yang dikongsi projek)
| Tindakan | Collaborator |
|---|---|
| Lihat projek yang dikongsi | ✓ |
| Dengar audio asal | ✓ |
| Lihat / edit transcript | ✓ |
| Buat komen | ✓ |
| Lihat versi history | ✓ |
| Restore versi lama | ✓ |
| **Delete projek / audio / teks** | ✗ |
| **Transfer ownership** | ✗ |
| **Urus collaborator lain** | ✗ |
---
## Laravel Policies
### `TranscriptionProjectPolicy`
```php
// Setiap method semak sama ada user adalah owner ATAU collaborator
// Admin sengaja TIDAK diberikan akses ke content
view() → owner ATAU collaborator (bukan admin)
create() → user aktif (bukan admin)
update() → owner ATAU collaborator
delete() → owner sahaja
viewAudio() → owner ATAU collaborator
downloadAudio()→ owner ATAU collaborator
retryTranscription() → owner sahaja
manageCollaborators() → owner sahaja
transferOwner() → admin sahaja (metadata action, bukan content)
viewMetadata() → admin ATAU owner (metadata minimum sahaja untuk admin)
```
### `CommentPolicy`
```php
view() → owner ATAU collaborator (bukan admin)
create() → owner ATAU collaborator
delete() → owner komen sendiri ATAU owner projek
```
### `TranscriptVersionPolicy`
```php
view() → owner ATAU collaborator
restore() → owner ATAU collaborator
```
### `UserPolicy`
```php
create() → admin sahaja
update() → admin sahaja
delete() → admin sahaja (dengan semak tiada usage)
activate() → admin sahaja
deactivate() → admin sahaja
changeEmail()→ admin sahaja
```
---
## Lapisan Keselamatan
### L1: Authentication
- Laravel Breeze/custom auth dengan session
- `is_active` check di middleware `EnsureUserIsActive`
- Rate limit login: 5 percubaan per minit per IP
- Session expire: dikonfig dalam `.env`
- CSRF protection aktif untuk semua form
### L2: Authorization (Policy)
- Setiap controller action wajib panggil `$this->authorize()`
- Jangan rely pada UI hide/show sahaja
- Gunakan `abort(403)` jika policy gagal
### L3: Input Validation
- Semua input melalui Form Request class
- File upload: validate MIME type dari magic bytes (bukan extension sahaja)
- Allowed audio types: `audio/mpeg`, `audio/wav`, `audio/mp4`, `audio/x-m4a`, `video/mp4`
- Max file size: dikonfig `PRIVATE_AUDIO_MAX_MB` dalam `.env`
- Rate limit upload: 10 upload per jam per user
### L4: Storage Security
- Fail audio **tidak** disimpan dalam `public/` atau `storage/app/public/`
- Path simpan: `storage/app/private/transcriptions/{project_uuid}/audio/{filename}`
- Semua akses audio melalui controller yang semak policy
- Guna `Storage::disk('private')->get()` untuk stream audio
- Response header `Content-Disposition: inline` untuk audio player
- Response header `X-Content-Type-Options: nosniff`
### L5: IDOR Prevention
- Gunakan UUID dalam URL projek, bukan integer ID
- Route: `/projects/{project:uuid}` bukan `/projects/{id}`
- Policy semak ownership/collaborator, bukan hanya URL
- Semua query pakai `where('owner_user_id', auth()->id())` atau policy scope
### L6: Output Sanitization
- Escape semua output Blade dengan `{{ }}` (bukan `{!! !!}`)
- Sanitize comment sebelum display dengan `htmlspecialchars()`
- Transcript display menggunakan `{{ }}` escape
- Content-Security-Policy header via middleware
### L7: Audit Trail
- Semua tindakan sensitif log ke `audit_logs`
- Log IP address dan user agent
- Log nilai lama dan nilai baru (kecuali transcript content)
- Audit log adalah append-only (tiada update/delete pada audit_logs)
### L8: Admin Content Isolation
```php
// Middleware atau scope yang enforce admin tidak dapat content
class AdminProjectScope {
public function apply($builder, $model) {
if (auth()->user()->role === 'admin') {
// Admin hanya dapat metadata sahaja
$builder->select([
'id', 'uuid', 'title', 'owner_user_id',
'transcription_status', 'created_at',
'file_size', 'duration_seconds', 'deleted_at'
// TIADA transcript_text, stored_audio_path
]);
}
}
}
```
---
## Keselamatan Fail Audio
### Upload Flow
```
1. Validate MIME type (magic bytes, bukan extension)
2. Generate nama fail baru yang random (jangan guna nama asal)
3. Simpan ke path private: transcriptions/{uuid}/audio/{random_name}.{ext}
4. Simpan nama asal dalam database (original_filename)
5. Jangan simpan path dalam session atau cookie
```
### Serve/Stream Audio
```php
// AudioController@stream
public function stream(TranscriptionProject $project)
{
$this->authorize('viewAudio', $project);
$path = $project->stored_audio_path;
// Verify file exists
abort_unless(Storage::disk('private')->exists($path), 404);
return response()->stream(function () use ($path) {
$stream = Storage::disk('private')->readStream($path);
fpassthru($stream);
}, 200, [
'Content-Type' => $project->mime_type,
'Content-Length' => Storage::disk('private')->size($path),
'Content-Disposition' => 'inline',
'Cache-Control' => 'no-store, no-cache, private',
'X-Content-Type-Options' => 'nosniff',
]);
}
```
---
## Konfigurasi Security Header
```php
// middleware/SecurityHeaders.php
'X-Frame-Options' => 'DENY',
'X-XSS-Protection' => '1; mode=block',
'X-Content-Type-Options' => 'nosniff',
'Referrer-Policy' => 'strict-origin-when-cross-origin',
'Content-Security-Policy' => "default-src 'self'; ..."
```
---
## Enkripsi Data Sensitif
### Transcript Text Encryption
Gunakan Laravel `encrypted` cast untuk `transcript_text`:
```php
// Model TranscriptionProject
protected $casts = [
'transcript_text' => 'encrypted',
];
```
- Data dienkripsi menggunakan `APP_KEY` sebelum simpan dalam database.
- Decrypt berlaku secara automatik apabila diakses melalui Eloquent.
- Tanpa `APP_KEY`, data tidak boleh dibaca walaupun database dicuri.
- **Penting:** Simpan `APP_KEY` dalam `.env` dan jangan commit ke git.
---
## Semakan Keselamatan Deployment
Sebelum production, semak:
- [ ] `APP_DEBUG=false`
- [ ] `APP_ENV=production`
- [ ] `.env` tidak dalam git repository (ada dalam `.gitignore`)
- [ ] `APP_KEY` telah dijanakan (`php artisan key:generate`)
- [ ] Database password kuat dan berbeza dari default
- [ ] `storage/app/private/` tidak accessible dari web
- [ ] Nginx tidak serve `storage/` secara direct
- [ ] HTTPS/TLS aktif
- [ ] Rate limiting dikonfig
- [ ] Fail upload di luar `public_html`
- [ ] `storage:link` hanya untuk assets (bukan audio/transcript)
- [ ] Queue worker berjalan dengan user yang restricted
- [ ] Transcription worker tidak accessible dari internet (internal only)
- [ ] Ollama tidak accessible dari internet (internal only)
---
## Nota untuk Pentadbir Sistem
1. **Backup `APP_KEY`** — Jika `APP_KEY` hilang, semua `transcript_text` yang dienkripsi tidak boleh dibaca.
2. **Backup database secara berkala** — Termasuk `transcript_versions` yang menyimpan history.
3. **Log rotation** — Pastikan `storage/logs/` dirotate supaya tidak penuh.
4. **Retention policy** — Tentukan berapa lama fail audio disimpan selepas projek deleted.
5. **Monitor disk space** — Audio files boleh membesar. Tetapkan alert.

342
TASK_PLAN.md Normal file
View File

@@ -0,0 +1,342 @@
# TASK_PLAN.md — Speech2Text MBIP
## Ringkasan Fasa
| Fasa | Fokus | Anggaran |
|---|---|---|
| Fasa 1 | Auth, Role, User Management, Docker | Asas wajib |
| Fasa 2 | Project CRUD, Upload Audio, Collaborator, Policies | Storage + RBAC |
| Fasa 3 | Queue, Transcription Worker, Status, Retry | AI Integration |
| Fasa 4 | Transcript Editor, Version History, Comments | Content Features |
| Fasa 5 | Admin Dashboard, Audit Log, Transfer Ownership | Admin Tools |
| Fasa 6 | Security Hardening, Tests, README, Production Checklist | Quality |
---
## FASA 1: Asas — Auth, Role, User Management, Docker
### Objektif
Sistem boleh login, admin boleh urus pengguna, Docker berjalan.
### Checklist Fasa 1
#### 1.1 Struktur Projek Laravel
- [ ] `laravel new speech2text-mbip` dengan PHP 8.3+
- [ ] Konfigurasi `.env.example`
- [ ] Setup `config/filesystems.php` — tambah private disk
- [ ] Setup `config/auth.php`
- [ ] Konfigurasi Laravel untuk UUID
#### 1.2 Docker Setup
- [ ] `Dockerfile` untuk Laravel app (php:8.3-fpm)
- [ ] `docker-compose.yml` dengan services: app, nginx, mysql, redis, queue-worker, scheduler
- [ ] `docker/nginx/default.conf`
- [ ] `docker/php/php.ini`
- [ ] `.dockerignore`
- [ ] Test: `docker compose up -d --build`
#### 1.3 Database — Migration Awal
- [ ] Migration: `departments`
- [ ] Migration: `users` (dengan `role`, `is_active`, `department_id`, `deleted_at`)
- [ ] Migration: `audit_logs`
- [ ] Migration: `sessions` (jika guna database session)
#### 1.4 Models
- [ ] `User` model (dengan SoftDeletes, role check methods)
- [ ] `Department` model
- [ ] `AuditLog` model
#### 1.5 Authentication
- [ ] Login form (Bootstrap 5)
- [ ] Logout
- [ ] Middleware `EnsureUserIsActive` — check `is_active` selepas login
- [ ] Rate limit login (5 attempts/minute)
- [ ] Redirect admin ke `/admin/dashboard`, user ke `/dashboard`
#### 1.6 Role Middleware
- [ ] Middleware `EnsureAdmin` — untuk route `/admin/*`
- [ ] Middleware `EnsureUser` — untuk route `/dashboard/*`, `/projects/*`
- [ ] Register middleware dalam `bootstrap/app.php`
#### 1.7 Admin — User Management
- [ ] `UserPolicy` — CRUD user oleh admin sahaja
- [ ] `UserController` (Admin) dengan actions:
- `index` — senarai semua pengguna
- `create` / `store` — daftar pengguna baru
- `edit` / `update` — tukar emel pengguna
- `activate` — aktifkan pengguna
- `deactivate` — deactivate pengguna
- `destroy` — delete pengguna (hanya jika tiada usage)
- [ ] Form Request: `CreateUserRequest`, `UpdateUserEmailRequest`
- [ ] Views (Bootstrap 5): senarai pengguna, form cipta, form edit emel
- [ ] Action classes: `CreateUserAction`, `DeactivateUserAction`, `ActivateUserAction`, `DeleteUserAction`, `ChangeUserEmailAction`
#### 1.8 Audit Log Service
- [ ] `AuditLogService` dengan method `log()`
- [ ] Panggil AuditLogService dalam setiap action yang relevan
- [ ] Rekodkan: actor, action, target, old_values, new_values, ip, user_agent, justification
#### 1.9 Seeder
- [ ] `DepartmentSeeder` — beberapa jabatan contoh
- [ ] `AdminUserSeeder` — admin dari `.env` variables
- [ ] `DatabaseSeeder` — panggil semua seeders
#### 1.10 Admin Dashboard (Asas)
- [ ] Dashboard route `/admin/dashboard`
- [ ] Layout admin (Bootstrap 5 sidebar)
- [ ] Papar: jumlah pengguna aktif, jumlah pengguna deactive
### Arahan Test Fasa 1
```bash
# Build dan jalankan
cd /svr/speech2text
docker compose up -d --build
# Setup
docker compose exec app php artisan key:generate
docker compose exec app php artisan migrate --seed
# Semak
docker compose exec app php artisan migrate:status
docker compose exec app php artisan route:list
# Test manual:
# 1. Buka browser http://localhost
# 2. Login sebagai admin (dari .env)
# 3. Admin redirect ke /admin/dashboard
# 4. Admin boleh lihat senarai pengguna
# 5. Admin boleh daftar pengguna baru
# 6. Admin boleh deactivate pengguna
# 7. Pengguna deactivated tidak boleh login
# 8. Admin boleh activate semula
# 9. Admin boleh tukar emel
# 10. Semak audit_logs ada rekod untuk setiap tindakan
# 11. Login sebagai pengguna biasa -> redirect /dashboard
# 12. Pengguna biasa tidak boleh akses /admin/*
```
---
## FASA 2: Project CRUD, Upload Audio, Collaborator, Policies
### Checklist Fasa 2
#### 2.1 Migration
- [ ] `transcription_projects`
- [ ] `project_collaborators`
#### 2.2 Models & Relationships
- [ ] `TranscriptionProject` (dengan UUID, SoftDeletes)
- [ ] `ProjectCollaborator`
- [ ] Relationships dalam `User` model
#### 2.3 Policies
- [ ] `TranscriptionProjectPolicy` — view, create, update, delete, viewAudio, manageCollaborators
- [ ] Register policies dalam `AuthServiceProvider`
#### 2.4 File Upload
- [ ] Konfigurasi private disk dalam `config/filesystems.php`
- [ ] Form Request: `UploadAudioRequest` (validate MIME, size)
- [ ] `StorageService` — handle private file storage
- [ ] AudioController — stream audio melalui policy check
#### 2.5 Project Controllers
- [ ] `ProjectController` (User) — CRUD projek
- [ ] `AudioController` (User) — stream/download audio
- [ ] `CollaboratorController` (User) — tambah/buang collaborator
#### 2.6 Views
- [ ] Project list (dashboard)
- [ ] Create project + upload audio form
- [ ] Project detail (placeholder transcript area)
- [ ] Collaborator management panel
### Arahan Test Fasa 2
```bash
# Test manual:
# 1. Login sebagai user biasa
# 2. Cipta projek baru dengan upload audio
# 3. Semak fail ada dalam storage/app/private/ (bukan public/)
# 4. Dengar audio dalam browser (via controller stream)
# 5. Tambah collaborator
# 6. Login sebagai collaborator — boleh lihat projek
# 7. Login sebagai user lain (bukan collaborator) — 403
# 8. Login sebagai admin — tidak boleh dengar audio (403)
# 9. Admin hanya boleh lihat metadata projek
```
---
## FASA 3: Queue, Transcription Worker, Status, Retry
### Checklist Fasa 3
#### 3.1 Transcription Worker (Python)
- [ ] `docker/transcription-worker/Dockerfile` (Python 3.11 + faster-whisper)
- [ ] `docker/transcription-worker/main.py` (FastAPI endpoint `/transcribe`)
- [ ] `/health` endpoint untuk monitoring
- [ ] Tambah service ke `docker-compose.yml`
#### 3.2 Laravel Queue Job
- [ ] `TranscribeAudioJob` — baca audio, hantar ke Python worker, simpan result
- [ ] Handle timeout dan retry
- [ ] Update `transcription_status` di database
#### 3.3 Ollama Integration (Optional)
- [ ] `OllamaService` — panggil Ollama API untuk post-processing
- [ ] `OllamaPostProcessJob` — dispatch selepas transcription selesai
- [ ] Config via `.env`: `OLLAMA_ENABLED`, `OLLAMA_BASE_URL`, `OLLAMA_MODEL`
#### 3.4 UI Status
- [ ] Status badge dalam project detail (pending/processing/completed/failed)
- [ ] Auto-refresh status menggunakan polling atau SSE
- [ ] Retry button untuk failed transcription (owner sahaja)
### Arahan Test Fasa 3
```bash
# Semak transcription worker
docker compose logs -f transcription-worker
docker compose exec app curl http://transcription-worker:8000/health
# Upload audio dan monitor
docker compose logs -f queue-worker
docker compose exec app php artisan queue:work --once
# Semak result dalam database
docker compose exec mysql mysql -u root -p speech2text \
-e "SELECT id, title, transcription_status, transcript_text FROM transcription_projects LIMIT 5;"
```
---
## FASA 4: Transcript Editor, Version History, Comments
### Checklist Fasa 4
#### 4.1 Migration
- [ ] `transcript_versions`
- [ ] `project_comments`
#### 4.2 Models
- [ ] `TranscriptVersion`
- [ ] `ProjectComment` (dengan SoftDeletes)
#### 4.3 Policies
- [ ] `TranscriptVersionPolicy`
- [ ] `CommentPolicy`
#### 4.4 Controllers & Actions
- [ ] `TranscriptController` — view dan update transcript
- [ ] `TranscriptVersionController` — lihat history, restore
- [ ] `CommentController` — CRUD komen
#### 4.5 Views
- [ ] Transcript editor (textarea, save button)
- [ ] Version history timeline
- [ ] Restore version button
- [ ] Comment/chat panel (sidebar atau bawah transcript)
### Arahan Test Fasa 4
```bash
# Test:
# 1. Edit transcript — semak transcript_versions ada rekod baru
# 2. Edit kedua kali — semak version_number bertambah
# 3. Restore version lama — semak transcript_text dikemaskini
# 4. Collaborator boleh edit transcript
# 5. Admin tidak boleh akses transcript editor (403)
# 6. Buat komen
# 7. Admin tidak boleh lihat komen (403)
```
---
## FASA 5: Admin Dashboard, Audit Log, Transfer Ownership
### Checklist Fasa 5
#### 5.1 Admin Dashboard Lengkap
- [ ] Statistik pengguna (aktif/deactivate)
- [ ] Statistik projek (pending/processing/completed/failed)
- [ ] Statistik storage
- [ ] Top users by usage
- [ ] Chart harian/bulanan (jQuery chart library)
#### 5.2 Admin Project Metadata List
- [ ] Senarai projek dengan metadata sahaja (TANPA transcript_text)
- [ ] Filter: status, tarikh, owner
- [ ] Pagination
#### 5.3 Transfer Ownership
- [ ] Form transfer ownership dengan justification field
- [ ] `TransferProjectOwnerAction`
- [ ] Option: jadikan owner lama sebagai collaborator
- [ ] Audit log untuk transfer
#### 5.4 Audit Log Browser
- [ ] Senarai audit log dengan filter
- [ ] Filter: action, actor, tarikh, projek
- [ ] Pagination
- [ ] Export CSV (admin sahaja)
### Arahan Test Fasa 5
```bash
# Test:
# 1. Admin lihat dashboard dengan statistik
# 2. Admin transfer ownership — semak audit log ada justifikasi
# 3. Selepas transfer, owner baru boleh akses projek
# 4. Owner lama (jika dijadikan collaborator) boleh akses
# 5. Admin browse audit log — semak semua rekod ada
```
---
## FASA 6: Security Hardening, Tests, README, Production
### Checklist Fasa 6
#### 6.1 Feature Tests
- [ ] `AdminCannotViewTranscriptTest`
- [ ] `AdminCannotDownloadAudioTest`
- [ ] `OwnerCanAccessOwnProjectTest`
- [ ] `CollaboratorCanAccessSharedProjectTest`
- [ ] `NonCollaboratorCannotAccessProjectTest`
- [ ] `OnlyOwnerCanDeleteProjectTest`
- [ ] `CollaboratorCanEditTranscriptTest`
- [ ] `TranscriptEditCreatesVersionHistoryTest`
- [ ] `AdminTransferOwnershipRequiresJustificationTest`
- [ ] `DeactivateUserPreventsLoginTest`
- [ ] `DeleteUnusedUserOnlyAllowedTest`
- [ ] `EmailChangeCreatesAuditLogTest`
#### 6.2 Security Hardening
- [ ] Security headers middleware
- [ ] Rate limiting semua endpoint sensitif
- [ ] Validate MIME type dari magic bytes
- [ ] Semak semua `{{ }}` dalam Blade (bukan `{!! !!}`)
- [ ] Semak CSRF token semua form
- [ ] Remove debug routes
#### 6.3 Production Checklist
- [ ] Semua item dalam `DEPLOYMENT.md` production checklist
- [ ] `APP_DEBUG=false` di production
- [ ] Config cache, route cache, view cache
- [ ] Queue supervisor setup
#### 6.4 Documentation
- [ ] README.md utama
- [ ] Arahan deployment
- [ ] Arahan development local
---
## Peringatan Penting
> **Prinsip Privasi:**
> Admin = pentadbir AKAUN, bukan pentadbir KANDUNGAN.
> Setiap fasa mesti pastikan admin tidak boleh akses audio, transcript, atau komen.
> Semak dengan feature test sebelum anggap selesai.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

18
artisan Normal file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

31
bootstrap/app.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
use App\Http\Middleware\EnsureAdmin;
use App\Http\Middleware\EnsureRegularUser;
use App\Http\Middleware\EnsureUserIsActive;
use App\Http\Middleware\SecurityHeaders;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'active' => EnsureUserIsActive::class,
'admin' => EnsureAdmin::class,
'user.role' => EnsureRegularUser::class,
]);
$middleware->web(append: [
SecurityHeaders::class,
EnsureUserIsActive::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();

2
bootstrap/cache/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

7
bootstrap/providers.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];

86
composer.json Normal file
View File

@@ -0,0 +1,86 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.3",
"laravel/framework": "^13.8",
"laravel/tinker": "^3.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.5",
"laravel/pao": "^1.0.6",
"laravel/pint": "^1.27",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^12.5.12"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install --ignore-scripts",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi @no_additional_args",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

8274
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

126
config/app.php Normal file
View File

@@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

117
config/auth.php Normal file
View File

@@ -0,0 +1,117 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

136
config/cache.php Normal file
View File

@@ -0,0 +1,136 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "storage", "octane",
| "session", "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'storage' => [
'driver' => 'storage',
'disk' => env('CACHE_STORAGE_DISK'),
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
];

184
config/database.php Normal file
View File

@@ -0,0 +1,184 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];

89
config/filesystems.php Normal file
View File

@@ -0,0 +1,89 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => false, // jangan serve direct — mesti melalui controller
'throw' => false,
'report' => false,
],
// Disk khas untuk audio dan transcript (private, tidak accessible web)
'private' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => false,
'throw' => true,
'report' => true,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

132
config/logging.php Normal file
View File

@@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

118
config/mail.php Normal file
View File

@@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
];

129
config/queue.php Normal file
View File

@@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

38
config/services.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];

233
config/session.php Normal file
View File

@@ -0,0 +1,233 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
/*
|--------------------------------------------------------------------------
| Session Serialization
|--------------------------------------------------------------------------
|
| This value controls the serialization strategy for session data, which
| is JSON by default. Setting this to "php" allows the storage of PHP
| objects in the session but can make an application vulnerable to
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
| Supported: "json", "php"
|
*/
'serialization' => 'json',
];

60
config/speech2text.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Transcription Engine
|--------------------------------------------------------------------------
*/
'transcription' => [
'engine' => env('TRANSCRIPTION_ENGINE', 'faster-whisper'),
'worker_url' => env('TRANSCRIPTION_WORKER_URL', 'http://transcription-worker:8000'),
'model' => env('WHISPER_MODEL', 'small'),
'language' => env('WHISPER_LANGUAGE', 'ms'),
],
/*
|--------------------------------------------------------------------------
| Upload Limits
|--------------------------------------------------------------------------
*/
'upload' => [
'max_mb' => (int) env('PRIVATE_AUDIO_MAX_MB', 200),
'allowed_mimes' => [
'audio/mpeg',
'audio/mp3',
'audio/wav',
'audio/x-wav',
'audio/mp4',
'audio/x-m4a',
'audio/aac',
'video/mp4',
'audio/ogg',
'audio/flac',
'audio/webm',
],
'allowed_extensions' => ['mp3', 'wav', 'm4a', 'mp4', 'aac', 'ogg', 'flac', 'webm'],
],
/*
|--------------------------------------------------------------------------
| Ollama Optional Post-processing
|--------------------------------------------------------------------------
*/
'ollama' => [
'enabled' => env('OLLAMA_ENABLED', false),
'base_url' => env('OLLAMA_BASE_URL', 'http://ollama:11434'),
'model' => env('OLLAMA_MODEL', 'llama3.1'),
],
/*
|--------------------------------------------------------------------------
| Storage Path Templates
|--------------------------------------------------------------------------
*/
'storage' => [
'audio_path' => 'transcriptions/{uuid}/audio',
],
];

1
database/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.sqlite*

View File

@@ -0,0 +1,53 @@
<?php
namespace Database\Factories;
use App\Models\TranscriptionProject;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** @extends Factory<TranscriptionProject> */
class TranscriptionProjectFactory extends Factory
{
public function definition(): array
{
return [
'uuid' => (string) Str::uuid(),
'title' => fake()->sentence(4),
'description' => fake()->optional()->sentence(),
'owner_user_id' => User::factory(),
'original_filename' => fake()->word() . '.mp3',
'stored_audio_path' => 'transcriptions/' . Str::uuid() . '/audio/' . Str::random(20) . '.mp3',
'mime_type' => 'audio/mpeg',
'file_size' => fake()->numberBetween(100000, 50000000),
'duration_seconds' => fake()->optional()->numberBetween(60, 3600),
'language' => 'ms',
'transcription_status' => 'pending',
'transcription_engine' => 'faster-whisper',
'transcript_text' => null,
];
}
public function completed(): static
{
return $this->state(fn () => [
'transcription_status' => 'completed',
'transcript_text' => fake()->paragraphs(3, true),
'processed_at' => now(),
]);
}
public function failed(): static
{
return $this->state(fn () => [
'transcription_status' => 'failed',
'error_message' => 'Connection refused',
]);
}
public function processing(): static
{
return $this->state(fn () => ['transcription_status' => 'processing']);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/** @extends Factory<User> */
class UserFactory extends Factory
{
protected static ?string $password;
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'role' => 'user',
'department_id' => null,
'is_active' => true,
'remember_token' => Str::random(10),
];
}
public function admin(): static
{
return $this->state(fn () => ['role' => 'admin']);
}
public function inactive(): static
{
return $this->state(fn () => ['is_active' => false]);
}
public function unverified(): static
{
return $this->state(fn () => ['email_verified_at' => null]);
}
}

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('departments', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('code', 50)->nullable()->unique();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->enum('role', ['admin', 'user'])->default('user');
$table->foreignId('department_id')->nullable()->constrained('departments')->nullOnDelete();
$table->boolean('is_active')->default(true);
$table->timestamp('last_login_at')->nullable();
$table->rememberToken();
$table->timestamps();
$table->softDeletes();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
public function down(): void
{
Schema::dropIfExists('sessions');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('users');
Schema::dropIfExists('departments');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedSmallInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->string('connection');
$table->string('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
$table->index(['connection', 'queue', 'failed_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('actor_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('actor_role', 50)->nullable();
$table->string('action', 100)->index();
$table->string('subject_type', 100)->nullable();
$table->unsignedBigInteger('subject_id')->nullable();
$table->foreignId('target_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->unsignedBigInteger('project_id')->nullable();
$table->json('old_values')->nullable();
$table->json('new_values')->nullable();
$table->text('justification')->nullable();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->timestamp('created_at')->nullable()->index();
$table->index(['subject_type', 'subject_id']);
$table->index('project_id');
});
}
public function down(): void
{
Schema::dropIfExists('audit_logs');
}
};

View File

@@ -0,0 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('transcription_projects', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->string('title');
$table->text('description')->nullable();
$table->foreignId('owner_user_id')->constrained('users')->restrictOnDelete();
$table->string('original_filename', 500);
$table->string('stored_audio_path', 1000);
$table->string('mime_type', 100);
$table->unsignedBigInteger('file_size');
$table->unsignedInteger('duration_seconds')->nullable();
$table->string('language', 10)->default('ms');
$table->enum('transcription_status', ['pending', 'processing', 'completed', 'failed'])->default('pending')->index();
$table->string('transcription_engine', 50)->nullable();
$table->longText('transcript_text')->nullable(); // encrypted via model cast
$table->decimal('transcript_confidence', 5, 4)->nullable();
$table->text('error_message')->nullable();
$table->timestamp('processed_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index('owner_user_id');
});
Schema::create('project_collaborators', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->constrained('transcription_projects')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->enum('role', ['editor', 'viewer'])->default('editor');
$table->foreignId('added_by')->constrained('users')->restrictOnDelete();
$table->timestamps();
$table->unique(['project_id', 'user_id']);
});
Schema::create('transcript_versions', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->constrained('transcription_projects')->cascadeOnDelete();
$table->foreignId('edited_by')->constrained('users')->restrictOnDelete();
$table->unsignedInteger('version_number');
$table->longText('old_text')->nullable();
$table->longText('new_text');
$table->string('change_summary', 500)->nullable();
$table->timestamp('created_at')->nullable();
$table->index(['project_id', 'version_number']);
});
Schema::create('project_comments', function (Blueprint $table) {
$table->id();
$table->foreignId('project_id')->constrained('transcription_projects')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->restrictOnDelete();
$table->text('message');
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('project_comments');
Schema::dropIfExists('transcript_versions');
Schema::dropIfExists('project_collaborators');
Schema::dropIfExists('transcription_projects');
}
};

View File

@@ -0,0 +1,41 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class AdminUserSeeder extends Seeder
{
public function run(): void
{
$name = env('ADMIN_DEFAULT_NAME');
$email = env('ADMIN_DEFAULT_EMAIL');
$password = env('ADMIN_DEFAULT_PASSWORD');
if (! $name || ! $email || ! $password) {
$this->command->warn(
'Admin default tidak dicipta. Sila isi ADMIN_DEFAULT_NAME, ADMIN_DEFAULT_EMAIL, dan ADMIN_DEFAULT_PASSWORD dalam .env'
);
return;
}
$admin = User::firstOrCreate(
['email' => $email],
[
'name' => $name,
'password' => Hash::make($password),
'role' => 'admin',
'is_active' => true,
'email_verified_at' => now(),
]
);
if ($admin->wasRecentlyCreated) {
$this->command->info("Admin '{$name}' ({$email}) berjaya dicipta.");
} else {
$this->command->info("Admin '{$email}' sudah wujud. Tiada perubahan.");
}
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
DepartmentSeeder::class,
AdminUserSeeder::class,
]);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Database\Seeders;
use App\Models\Department;
use Illuminate\Database\Seeder;
class DepartmentSeeder extends Seeder
{
public function run(): void
{
$departments = [
['name' => 'Jabatan Pentadbiran', 'code' => 'JPA'],
['name' => 'Jabatan Kewangan', 'code' => 'JKW'],
['name' => 'Jabatan Kejuruteraan', 'code' => 'JKJ'],
['name' => 'Jabatan Perancangan Bandar', 'code' => 'JPB'],
['name' => 'Jabatan Kesihatan Persekitaran', 'code' => 'JKP'],
['name' => 'Jabatan Pelesenan', 'code' => 'JPL'],
['name' => 'Jabatan Undang-Undang', 'code' => 'JUU'],
['name' => 'Jabatan Teknologi Maklumat', 'code' => 'JTM'],
['name' => 'Jabatan Landskap & Rekreasi', 'code' => 'JLR'],
['name' => 'Jabatan Penguatkuasaan', 'code' => 'JPK'],
];
foreach ($departments as $dept) {
Department::firstOrCreate(['code' => $dept['code']], $dept);
}
}
}

167
docker-compose.yml Normal file
View File

@@ -0,0 +1,167 @@
name: speech2text-mbip
services:
# ============================================================
# Nginx — Web Server
# ============================================================
nginx:
image: nginx:1.25-alpine
container_name: speech2text_nginx
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./:/var/www/html:ro
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
networks:
- speech2text_net
# ============================================================
# App — Laravel PHP-FPM
# ============================================================
app:
build:
context: .
dockerfile: Dockerfile
container_name: speech2text_app
restart: unless-stopped
environment:
- APP_ENV=${APP_ENV:-production}
volumes:
- ./:/var/www/html
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/app.ini:ro
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_started
networks:
- speech2text_net
# ============================================================
# MySQL 8
# ============================================================
mysql:
image: mysql:8.0
container_name: speech2text_mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE:-speech2text}
MYSQL_USER: ${MYSQL_USER:-speech2text}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
ports:
- "127.0.0.1:3307:3306" # host:3307 → container:3306 (elak conflict jika ada projek lain)
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- speech2text_net
# ============================================================
# Redis — Queue & Cache
# ============================================================
redis:
image: redis:7-alpine
container_name: speech2text_redis
restart: unless-stopped
command: redis-server --save 60 1 --loglevel warning
volumes:
- redis_data:/data
networks:
- speech2text_net
# ============================================================
# Queue Worker — Laravel
# ============================================================
queue-worker:
build:
context: .
dockerfile: Dockerfile
container_name: speech2text_queue
restart: unless-stopped
command: php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600 --memory=256
volumes:
- ./:/var/www/html
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_started
networks:
- speech2text_net
# ============================================================
# Scheduler — Laravel Cron
# ============================================================
scheduler:
build:
context: .
dockerfile: Dockerfile
container_name: speech2text_scheduler
restart: unless-stopped
command: php artisan schedule:work
volumes:
- ./:/var/www/html
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_started
networks:
- speech2text_net
# ============================================================
# Transcription Worker — Python FastAPI + faster-whisper
# INTERNAL ONLY — tidak boleh diakses dari luar Docker network
# ============================================================
transcription-worker:
build:
context: ./docker/transcription-worker
dockerfile: Dockerfile
container_name: speech2text_transcription
restart: unless-stopped
environment:
WHISPER_MODEL: ${WHISPER_MODEL:-small}
WHISPER_LANGUAGE: ${WHISPER_LANGUAGE:-ms}
WHISPER_DEVICE: ${WHISPER_DEVICE:-cpu}
WHISPER_COMPUTE_TYPE: ${WHISPER_COMPUTE_TYPE:-int8}
volumes:
- whisper_models:/root/.cache/huggingface # cache model agar tidak download ulang
# Port TIDAK di-expose ke host — internal sahaja
networks:
- speech2text_net
# ============================================================
# Ollama — Optional Local LLM (disabled by default)
# Uncomment jika OLLAMA_ENABLED=true
# ============================================================
# ollama:
# image: ollama/ollama
# container_name: speech2text_ollama
# restart: unless-stopped
# volumes:
# - ollama_data:/root/.ollama
# networks:
# - speech2text_net
networks:
speech2text_net:
driver: bridge
volumes:
mysql_data:
name: speech2text_mysql_data # nama eksplisit — susah ter-delete secara tidak sengaja
labels:
com.speech2text.description: "MySQL production data — JANGAN PADAM"
redis_data:
name: speech2text_redis_data
whisper_models:
name: speech2text_whisper_models
# ollama_data:

40
docker/nginx/default.conf Normal file
View File

@@ -0,0 +1,40 @@
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php index.html;
client_max_body_size 210M;
add_header X-Frame-Options "DENY";
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options "nosniff";
add_header Referrer-Policy "strict-origin-when-cross-origin";
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 300;
}
# Deny access to hidden files
location ~ /\. {
deny all;
}
# Deny direct access to storage (private files served via PHP)
location ~ ^/storage/ {
deny all;
}
}

View File

@@ -0,0 +1,25 @@
FROM php:8.5-apache
ENV APACHE_DOCUMENT_ROOT=/var/www/html/public
RUN apt-get update && apt-get install -y \
git \
unzip \
libzip-dev \
libpng-dev \
libjpeg62-turbo-dev \
libfreetype6-dev \
libicu-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install pdo_mysql gd intl zip \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& a2enmod rewrite headers \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY docker/php-apache/vhost.conf /etc/apache2/sites-available/000-default.conf
WORKDIR /var/www/html
RUN chown -R www-data:www-data /var/www/html

View File

@@ -0,0 +1,13 @@
<VirtualHost *:80>
ServerAdmin webmaster@localhost
DocumentRoot /var/www/html/public
<Directory /var/www/html/public>
AllowOverride All
Require all granted
Options -Indexes
</Directory>
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>

4
docker/php/custom.ini Normal file
View File

@@ -0,0 +1,4 @@
display_errors = On
display_startup_errors = Off
log_errors = On
error_reporting = E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED

34
docker/php/php.ini Normal file
View File

@@ -0,0 +1,34 @@
; Speech2Text MBIP — PHP Configuration
[PHP]
upload_max_filesize = 210M
post_max_size = 215M
max_execution_time = 300
max_input_time = 300
memory_limit = 512M
; Security
expose_php = Off
display_errors = Off
log_errors = On
error_log = /var/log/php_errors.log
; File uploads
file_uploads = On
upload_tmp_dir = /tmp
; Session
session.cookie_httponly = 1
session.cookie_secure = 1
session.use_strict_mode = 1
session.gc_maxlifetime = 7200
; Timezone
date.timezone = Asia/Kuala_Lumpur
[opcache]
opcache.enable = 1
opcache.memory_consumption = 128
opcache.max_accelerated_files = 10000
opcache.revalidate_freq = 0
opcache.validate_timestamps = 0

View File

@@ -0,0 +1,27 @@
[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[program:php-fpm]
command=php-fpm
autostart=true
autorestart=true
priority=5
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:queue-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600 --memory=256
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
numprocs=2
redirect_stderr=true
stdout_logfile=/var/log/supervisor/queue-worker.log
stopwaitsecs=3600

View File

@@ -0,0 +1,18 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies for faster-whisper
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

View File

@@ -0,0 +1,125 @@
"""
Speech2Text MBIP — Transcription Worker
FastAPI service wrapping faster-whisper for Bahasa Melayu transcription.
Internal-only service; not exposed to internet.
"""
import os
import tempfile
import logging
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, UploadFile, File, HTTPException
from pydantic import BaseModel
from faster_whisper import WhisperModel
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="Speech2Text Transcription Worker", docs_url=None, redoc_url=None)
# Config from environment
MODEL_SIZE = os.getenv("WHISPER_MODEL", "small")
LANGUAGE = os.getenv("WHISPER_LANGUAGE", "ms")
DEVICE = os.getenv("WHISPER_DEVICE", "cpu")
COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "int8")
logger.info(f"Loading Whisper model: {MODEL_SIZE}, device: {DEVICE}, compute: {COMPUTE_TYPE}")
# Load model once at startup
model = WhisperModel(MODEL_SIZE, device=DEVICE, compute_type=COMPUTE_TYPE)
logger.info("Whisper model loaded successfully.")
class TranscribeResponse(BaseModel):
success: bool
transcript: Optional[str] = None
language: Optional[str] = None
confidence: Optional[float] = None
duration_seconds: Optional[float] = None
error: Optional[str] = None
@app.get("/health")
def health_check():
return {"status": "ok", "model": MODEL_SIZE, "language": LANGUAGE}
@app.post("/transcribe", response_model=TranscribeResponse)
async def transcribe_audio(audio: UploadFile = File(...)):
"""
Terima fail audio, transkripkan ke teks Bahasa Melayu.
Hanya accessible dari dalam Docker network sahaja.
"""
# Validate content type
allowed_types = {
"audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav",
"audio/mp4", "audio/x-m4a", "audio/aac", "video/mp4",
"audio/ogg", "audio/flac", "audio/webm",
}
if audio.content_type and audio.content_type not in allowed_types:
logger.warning(f"Rejected content type: {audio.content_type}")
raise HTTPException(status_code=400, detail=f"Unsupported audio format: {audio.content_type}")
# Save to temp file
suffix = Path(audio.filename or "audio.mp3").suffix or ".mp3"
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
content = await audio.read()
tmp.write(content)
tmp_path = tmp.name
logger.info(f"Transcribing: {audio.filename}, size: {len(content)} bytes")
# Run transcription
segments, info = model.transcribe(
tmp_path,
language=LANGUAGE,
beam_size=5,
vad_filter=True,
)
# Collect segments
all_text = []
total_confidence = []
for segment in segments:
all_text.append(segment.text.strip())
# faster-whisper segments have avg_logprob; convert to 0-1 range
if hasattr(segment, "avg_logprob") and segment.avg_logprob is not None:
import math
confidence = min(1.0, max(0.0, math.exp(segment.avg_logprob)))
total_confidence.append(confidence)
transcript = " ".join(all_text).strip()
avg_confidence = sum(total_confidence) / len(total_confidence) if total_confidence else None
duration = info.duration if hasattr(info, "duration") else None
logger.info(f"Transcription complete: {len(transcript)} chars, lang: {info.language}")
return TranscribeResponse(
success=True,
transcript=transcript,
language=info.language,
confidence=round(avg_confidence, 4) if avg_confidence else None,
duration_seconds=round(duration, 2) if duration else None,
)
except Exception as e:
logger.error(f"Transcription failed: {e}", exc_info=True)
return TranscribeResponse(
success=False,
error=str(e),
)
finally:
# Always cleanup temp file
if "tmp_path" in locals():
try:
os.unlink(tmp_path)
except Exception:
pass

View File

@@ -0,0 +1,6 @@
fastapi==0.115.0
uvicorn==0.30.6
faster-whisper==1.0.3
python-multipart==0.0.9
pydantic==2.8.2
requests==2.32.3

Some files were not shown because too many files have changed in this diff Show More