Aplikasi kehadiran KTM ambil peti undi (SPR 2026)

Sistem untuk admin DUN merekod waktu KTM (Ketua Tempat Mengundi) hadir
mengambil peti undi. Data KTM dibaca daripada DB sppm (spr2026) melalui
connection kedua — hanya penempatan berjawatan KTM yang dipaparkan.

- Dashboard: senarai KTM ikut pusat mengundi (asc) & saluran (asc),
  status BELUM HADIR (merah) / HADIR (hijau) + tarikh & masa
- Carian nama/no. KP, tapisan pusat mengundi & status
- Tanda hadir / batal dengan kawalan DUN (admin terhad ke DUN sendiri)
- Eksport Excel (worksheet Hadir & Belum Hadir) guna SimpleXlsx
- Peranan: superadmin (urus pengguna, semua DUN) & admin DUN
- Docker: nginx + php-fpm + queue + scheduler + webhook deploy,
  port 127.0.0.1:8009, TZ Asia/Kuala_Lumpur di semua container
- Zon waktu aplikasi: Asia/Kuala_Lumpur (APP_TIMEZONE)

Berasaskan scaffold spr2026_taklimat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Saufi
2026-07-11 02:40:00 +08:00
commit dfd22ff2af
95 changed files with 14681 additions and 0 deletions

99
docker/Dockerfile Normal file
View File

@@ -0,0 +1,99 @@
# syntax=docker/dockerfile:1
# ---------------------------------------------------------------------------
# Stage 1: build front-end assets with Vite (Node 22 ~ npm 10.9.x)
# ---------------------------------------------------------------------------
FROM node:22-bookworm-slim AS assets
WORKDIR /app
# Install JS deps first for better layer caching. No package-lock.json is
# committed, so use `npm install` rather than `npm ci`.
COPY package.json ./
RUN npm install
# Only the inputs Vite needs to produce public/build
COPY vite.config.js ./
COPY resources ./resources
RUN npm run build
# ---------------------------------------------------------------------------
# Stage 2: PHP 8.4 dependencies + application code (the "app" / php-fpm image)
# ---------------------------------------------------------------------------
FROM php:8.4-fpm-bookworm AS app
# System libraries required to build the PHP extensions below.
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
unzip \
rsync \
libzip-dev \
libpng-dev \
libjpeg-dev \
libfreetype-dev \
libonig-dev \
libicu-dev \
libxml2-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j"$(nproc)" \
pdo_mysql \
mbstring \
bcmath \
gd \
zip \
intl \
exif \
pcntl \
opcache \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Composer (pulled from the official image; 2.x line, matches host 2.9.x)
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# PHP runtime configuration
COPY docker/php/php.ini /usr/local/etc/php/conf.d/zz-app.ini
WORKDIR /var/www/html
# Install PHP dependencies first (better caching). Skip scripts/autoloader
# until the full source tree is present.
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-scripts \
--no-autoloader \
--prefer-dist \
--no-interaction \
--no-progress
# Application source + freshly built front-end assets
COPY . .
COPY --from=assets /app/public/build ./public/build
# Optimised autoloader (package discovery runs at container start instead,
# where the environment is available).
RUN composer dump-autoload --no-dev --optimize --no-interaction \
&& chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R ug+rwX storage bootstrap/cache
# Entrypoint prepares storage, caches config, runs migrations, then runs the
# given command (php-fpm by default).
COPY docker/php/entrypoint.sh /usr/local/bin/entrypoint
RUN chmod +x /usr/local/bin/entrypoint
EXPOSE 9000
ENTRYPOINT ["entrypoint"]
CMD ["php-fpm"]
# ---------------------------------------------------------------------------
# Stage 3: nginx serving the static assets + proxying PHP to the app container
# ---------------------------------------------------------------------------
FROM nginx:1.27-alpine AS web
# Baked copy of the public root so nginx can serve static files directly.
COPY --from=app /var/www/html/public /var/www/html/public
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -0,0 +1,49 @@
# Host nginx reverse proxy for the Dockerized app.
# Install on the Ubuntu host:
# sudo cp docker/host-nginx/ktmspr.apps.mbip.my.conf \
# /etc/nginx/sites-available/ktmspr.apps.mbip.my
# sudo ln -s /etc/nginx/sites-available/ktmspr.apps.mbip.my \
# /etc/nginx/sites-enabled/
# sudo nginx -t && sudo systemctl reload nginx
server {
listen 80;
listen [::]:80;
server_name ktmspr.apps.mbip.my;
client_max_body_size 21M;
access_log /var/log/nginx/ktmspr.access.log;
error_log /var/log/nginx/ktmspr.error.log;
location / {
proxy_pass http://127.0.0.1:8009;
proxy_http_version 1.1;
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_set_header X-Forwarded-Host $host;
proxy_connect_timeout 30s;
proxy_read_timeout 60s;
}
# GitHub webhook deploy -> container webhook (adnanh/webhook) di 127.0.0.1:9002.
# Endpoint GitHub: https://ktmspr.apps.mbip.my/hooks/deploy
location /hooks/ {
proxy_pass http://127.0.0.1:9002;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Deploy (build) ambil masa; bagi masa yg cukup utk response.
proxy_read_timeout 600s;
}
}
# NOTA: Selepas pasang SSL (certbot), blok di atas biasanya bertukar jadi
# `listen 443 ssl`. Pastikan KEDUA-DUA `location /` dan `location /hooks/`
# berada dalam blok 443 itu, dan `X-Forwarded-Proto $scheme` kekal ada.

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

@@ -0,0 +1,34 @@
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
charset utf-8;
client_max_body_size 21M;
# Real client IP / forwarded headers come from the host nginx in front.
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;
# Pass PHP requests to the app (php-fpm) container.
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
}
# Deny access to hidden files (e.g. .env) and version control.
location ~ /\.(?!well-known).* {
deny all;
}
}

66
docker/php/entrypoint.sh Normal file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
cd /var/www/html
echo "[entrypoint] Preparing storage directories..."
mkdir -p \
storage/app/public \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/framework/testing \
storage/logs \
bootstrap/cache
chown -R www-data:www-data storage bootstrap/cache || true
chmod -R ug+rwX storage bootstrap/cache || true
# Refresh package manifest (env vars are available now via compose env_file).
php artisan package:discover --ansi || true
# Wait for the database to be reachable before migrating / caching config.
wait_for_db() {
local host="${DB_HOST:-127.0.0.1}"
local port="${DB_PORT:-3306}"
echo "[entrypoint] Waiting for database at ${host}:${port}..."
for i in $(seq 1 30); do
if php -r '
$h=getenv("DB_HOST")?:"127.0.0.1";
$p=getenv("DB_PORT")?:"3306";
$d=getenv("DB_DATABASE")?:"";
$u=getenv("DB_USERNAME")?:"root";
$w=getenv("DB_PASSWORD")?:"";
try { new PDO("mysql:host=$h;port=$p;dbname=$d", $u, $w, [PDO::ATTR_TIMEOUT=>2]); exit(0); }
catch (Throwable $e) { exit(1); }
' >/dev/null 2>&1; then
echo "[entrypoint] Database is up."
return 0
fi
sleep 2
done
echo "[entrypoint] WARNING: database not reachable after timeout; continuing anyway."
return 0
}
# Cache framework config for performance. Runs in every role so all containers
# share the same compiled config.
cache_app() {
php artisan config:cache --ansi || true
php artisan route:cache --ansi || true
php artisan view:cache --ansi || true
}
# Only the primary (web/app) container runs migrations & storage:link, so the
# queue/scheduler workers don't race on them. Toggle with RUN_MIGRATIONS.
if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
wait_for_db
cache_app
echo "[entrypoint] Running migrations..."
php artisan migrate --force --ansi || echo "[entrypoint] WARNING: migrate failed."
php artisan storage:link --ansi || true
else
cache_app
fi
echo "[entrypoint] Starting: $*"
exec "$@"

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

@@ -0,0 +1,20 @@
; Application PHP settings (production-leaning)
expose_php = Off
memory_limit = 256M
max_execution_time = 60
; File uploads (attendance app — keep modest but comfortable)
upload_max_filesize = 20M
post_max_size = 21M
; Timezone (app uses Asia/Kuala_Lumpur)
date.timezone = Asia/Kuala_Lumpur
; OPcache — enabled for production performance
opcache.enable = 1
opcache.enable_cli = 0
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 1
opcache.revalidate_freq = 60

10
docker/webhook/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM golang:1.23-alpine AS builder
RUN go install github.com/adnanh/webhook@2.8.1
FROM alpine:3.21
# docker-cli + compose plugin: deploy.sh perlu `docker compose build`
# (kod aplikasi di-bake dalam image, bukan bind-mount).
RUN apk add --no-cache git docker-cli docker-cli-compose openssh-client
COPY --from=builder /go/bin/webhook /usr/local/bin/webhook
EXPOSE 9000
ENTRYPOINT ["/usr/local/bin/webhook"]

32
docker/webhook/hooks.json Normal file
View File

@@ -0,0 +1,32 @@
[
{
"id": "deploy",
"execute-command": "/deploy.sh",
"command-working-directory": "/srv/spr2026_ktm",
"response-message": "Deploy dimulakan.",
"trigger-rule": {
"and": [
{
"match": {
"type": "payload-hmac-sha256",
"secret": "{{ .Env.WEBHOOK_SECRET }}",
"parameter": {
"source": "header",
"name": "X-Hub-Signature-256"
}
}
},
{
"match": {
"type": "value",
"value": "refs/heads/main",
"parameter": {
"source": "payload",
"name": "ref"
}
}
}
]
}
}
]