Allowance check

This commit is contained in:
ISMAIL MASSERAN
2026-04-14 02:34:00 +00:00
parent 0c4d200839
commit 3775c4a876
100 changed files with 81493 additions and 3226 deletions
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class AllowanceClaimCode extends Model
{
protected $table = 'allowance_claim_codes';
protected $fillable = [
'election_id',
'voter_id',
'method',
'code_hash',
'code_last4',
'expires_at',
'used_at',
'used_by_admin_id',
];
protected $casts = [
'expires_at' => 'datetime',
'used_at' => 'datetime',
];
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class AllowancePayout extends Model
{
protected $table = 'allowance_payouts';
protected $fillable = [
'election_id',
'voter_id',
'method',
'status',
'amount_cents',
'currency',
'paid_by_admin_id',
'paid_at',
'reference',
'note',
];
protected $casts = [
'paid_at' => 'datetime',
];
}
+1 -1
View File
@@ -5,7 +5,7 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class ImportCsv extends Command
class ImportCSV extends Command
{
protected $signature = 'csv:import';
protected $description = 'Import kewangan anggota CSV';
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\ActivityLog;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Spatie\Activitylog\Models\Activity;
class IndexController extends Controller
{
public function __invoke(Request $request)
{
$perPage = (int) $request->query('per_page', 25);
if ($perPage < 1) $perPage = 25;
if ($perPage > 200) $perPage = 200;
$q = Activity::query()
->orderByDesc('id');
if ($request->filled('description')) {
$q->where('description', 'like', '%' . $request->query('description') . '%');
}
if ($request->filled('subject_type')) {
$q->where('subject_type', (string) $request->query('subject_type'));
}
if ($request->filled('causer_id')) {
$q->where('causer_id', (int) $request->query('causer_id'));
}
$p = $q->paginate($perPage);
$items = collect($p->items())->map(function (Activity $a) {
return [
'id' => $a->id,
'log_name' => $a->log_name,
'description' => $a->description,
'subject_type' => $a->subject_type,
'subject_id' => $a->subject_id,
'causer_type' => $a->causer_type,
'causer_id' => $a->causer_id,
'properties' => $a->properties,
'created_at' => optional($a->created_at)->toDateTimeString(),
];
})->values();
return response()->json([
'data' => $items,
'meta' => [
'current_page' => $p->currentPage(),
'per_page' => $p->perPage(),
'last_page' => $p->lastPage(),
'total' => $p->total(),
],
]);
}
}
@@ -11,7 +11,20 @@ class AddController extends Controller
public function __invoke (Request $request)
{
$this->validateRequest($request);
$this->insertAdmin($request);
$user = $this->insertAdmin($request);
activity()
->performedOn($user)
->withProperties([
'admin_id' => $user->id,
'admin_name' => $user->name,
'admin_email' => $user->email,
'admin_role' => $user->role,
'created_by_admin_id' => $request->user() ? (int) $request->user()->id : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("create admin: {$user->name}");
return response()->json([
'status' => 'success',
'message' => 'Admin added successfully'
@@ -22,7 +35,7 @@ class AddController extends Controller
{
$admin = $request->all();
$admin['password'] = bcrypt($request->password);
User::create($admin);
return User::create($admin);
}
private function validateRequest($request)
@@ -0,0 +1,171 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\Allowance;
use App\AllowanceClaimCode;
use App\AllowancePayout;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Result;
use App\Voter;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
class PayoutController extends Controller
{
public function __invoke(Request $request)
{
$this->validate($request, [
'voter_id' => 'required|integer',
'method' => 'nullable|string|in:cash,bank',
'amount_cents' => 'nullable|integer|min:0',
'reference' => 'nullable|string|max:80',
'note' => 'nullable|string|max:2000',
'claim_code' => 'nullable|string|min:4|max:20',
]);
$electionId = Util::getCurrentElection();
$method = $request->input('method', 'cash');
$voterId = (int) $request->input('voter_id');
$voter = Voter::where('election_id', $electionId)->where('id', $voterId)->first();
if (!$voter) {
return response()->json([
'status' => 'failed',
'message' => 'Voter not found for current election.',
], 404);
}
// Enforce method by attendance type (paperless replacement for coupon rules)
if ($method === 'cash' && (int) $voter->kehadiran !== 1) {
return response()->json([
'status' => 'failed',
'message' => 'Cash payout only allowed for Fizikal attendance.',
], 422);
}
if ($method === 'bank' && (int) $voter->kehadiran !== 2) {
return response()->json([
'status' => 'failed',
'message' => 'Bank payout only allowed for Maya attendance.',
], 422);
}
// Enforce "must have voted" (officer check)
$hasVoted = Result::where('election_id', $electionId)->where('voter_id', $voterId)->exists();
if (!$hasVoted) {
return response()->json([
'status' => 'failed',
'message' => 'Voter has not voted yet.',
], 422);
}
// Default amounts (can be overridden by passing amount_cents)
$defaultAmount = $method === 'cash' ? 30000 : 15000;
$amountCents = $request->filled('amount_cents') ? (int) $request->input('amount_cents') : $defaultAmount;
$now = Carbon::now();
$adminId = $request->user() ? (int) $request->user()->id : null;
$claimCode = $request->input('claim_code');
// For fizikal cash payout, require claim code (paperless coupon).
if ($method === 'cash' && (!$claimCode || trim($claimCode) === '')) {
return response()->json([
'status' => 'failed',
'message' => 'Sila masukkan kod tuntutan (claim code) sebelum bayaran tunai dibuat.',
], 422);
}
$payout = DB::transaction(function () use ($electionId, $voterId, $method, $claimCode, $amountCents, $adminId, $now, $request) {
// Consume claim code if provided
if ($claimCode && trim($claimCode) !== '') {
$row = AllowanceClaimCode::where('election_id', $electionId)
->where('voter_id', $voterId)
->where('method', $method)
->lockForUpdate()
->first();
if (!$row) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan tidak dijumpai.',
], 404);
}
if ($row->used_at) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan telah digunakan.',
], 422);
}
if ($row->expires_at && Carbon::now()->greaterThan($row->expires_at)) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan telah tamat tempoh.',
], 422);
}
if (!Hash::check(trim((string) $claimCode), $row->code_hash)) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan tidak sah.',
], 422);
}
$row->used_at = Carbon::now();
$row->used_by_admin_id = $adminId;
$row->save();
}
// Idempotent: if already exists (unique election_id+voter_id+method) we keep it as paid.
return AllowancePayout::updateOrCreate(
[
'election_id' => $electionId,
'voter_id' => $voterId,
'method' => $method,
],
[
'status' => 'paid',
'amount_cents' => $amountCents,
'currency' => 'MYR',
'paid_by_admin_id' => $adminId,
'paid_at' => $now,
'reference' => $request->input('reference'),
'note' => $request->input('note'),
]
);
});
// In case the transaction returned a JSON response (validation failure), return it directly.
if ($payout instanceof \Illuminate\Http\JsonResponse) {
return $payout;
}
activity()
->performedOn($payout)
->withProperties([
'election_id' => $electionId,
'voter_id' => $voterId,
'voter_name' => $voter->name,
'attendance' => (int) $voter->kehadiran,
'method' => $method,
'amount_cents' => (int) $payout->amount_cents,
'currency' => $payout->currency,
'paid_by_admin_id' => $adminId,
'reference' => $payout->reference,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("allowance payout ({$method}): {$voter->name}");
return response()->json([
'status' => 'success',
'message' => 'Allowance payout recorded.',
'payout' => $payout,
]);
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\Allowance;
use App\AllowanceClaimCode;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
class VerifyCodeController extends Controller
{
public function __invoke(Request $request)
{
$this->validate($request, [
'voter_id' => 'required|integer',
'method' => 'nullable|string|in:cash,bank',
'code' => 'required|string|min:4|max:20',
]);
$electionId = Util::getCurrentElection();
$method = $request->input('method', 'cash');
$voterId = (int) $request->input('voter_id');
$code = trim((string) $request->input('code'));
$row = AllowanceClaimCode::where('election_id', $electionId)
->where('voter_id', $voterId)
->where('method', $method)
->first();
if (!$row) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan tidak dijumpai.',
], 404);
}
if ($row->used_at) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan telah digunakan.',
], 422);
}
if ($row->expires_at && Carbon::now()->greaterThan($row->expires_at)) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan telah tamat tempoh.',
], 422);
}
if (!Hash::check($code, $row->code_hash)) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan tidak sah.',
], 422);
}
// Mark used (consumed) here so it cannot be reused
$row->used_at = Carbon::now();
$row->used_by_admin_id = $request->user() ? (int) $request->user()->id : null;
$row->save();
return response()->json([
'status' => 'success',
'message' => 'Kod tuntutan disahkan.',
'claim' => [
'id' => $row->id,
'voter_id' => $row->voter_id,
'method' => $row->method,
'expires_at' => $row->expires_at,
'used_at' => $row->used_at,
],
]);
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\Allowance;
use App\AllowancePayout;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
class VoidController extends Controller
{
public function __invoke(Request $request, $id)
{
$this->validate($request, [
'note' => 'nullable|string|max:2000',
]);
$payout = AllowancePayout::find($id);
if (!$payout) {
return response()->json([
'status' => 'failed',
'message' => 'Payout not found.',
], 404);
}
$note = trim((string) $request->input('note', ''));
if ($note !== '') {
$payout->note = $payout->note ? ($payout->note . "\n\nVOID NOTE: " . $note) : ("VOID NOTE: " . $note);
}
$payout->status = 'void';
$payout->save();
return response()->json([
'status' => 'success',
'message' => 'Payout voided.',
'payout' => $payout,
'voided_at' => Carbon::now(),
]);
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\Impersonate;
use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Http\Request;
class LeaveController extends Controller
{
public function __invoke(Request $request)
{
$current = $request->user();
if (! $current) {
return response()->json(['error' => 'Unauthenticated.'], 401);
}
$token = null;
try {
if (method_exists($current, 'token')) {
$token = $current->token();
}
} catch (\Throwable $e) {
$token = null;
}
$tokenName = $token ? (string) $token->name : '';
if (strpos($tokenName, 'impersonate:') !== 0) {
return response()->json([
'status' => 'failed',
'message' => 'Anda tidak sedang impersonate.',
], 422);
}
$impersonatorId = (int) substr($tokenName, strlen('impersonate:'));
if ($impersonatorId < 1) {
return response()->json([
'status' => 'failed',
'message' => 'Token impersonate tidak sah.',
], 422);
}
$impersonator = User::find($impersonatorId);
if (! $impersonator) {
return response()->json([
'status' => 'failed',
'message' => 'Akaun admin asal tidak dijumpai.',
], 404);
}
// Revoke impersonated token.
try {
if ($token) {
$token->revoke();
}
} catch (\Throwable $e) {
// Best effort.
}
$accessToken = $impersonator->createToken('My app', ['admin'])->accessToken;
activity()
->performedOn($impersonator)
->withProperties([
'admin_id' => $impersonator->id,
'admin_name' => $impersonator->name,
'admin_email' => $impersonator->email,
'from_user_id' => $current->id,
'from_user_email' => $current->email,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("admin leave impersonation: {$impersonator->email}");
return response()->json([
'status' => 'success',
'message' => 'Berjaya kembali ke akaun asal.',
'user' => $impersonator,
'token' => $accessToken,
]);
}
}
@@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers\API\v1\Admin\Impersonate;
use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class StartController extends Controller
{
public function __invoke(Request $request)
{
$request->validate([
'user_id' => ['required', 'integer', 'exists:users,id'],
]);
$impersonator = Auth::user();
$target = User::findOrFail((int) $request->input('user_id'));
if (! $impersonator || ! method_exists($impersonator, 'tokenCan') || ! $impersonator->tokenCan('admin')) {
return response()->json(['error' => 'Unauthenticated.'], 401);
}
if ((int) $impersonator->id === (int) $target->id) {
return response()->json([
'status' => 'failed',
'message' => 'Tidak boleh impersonate akaun sendiri.',
], 422);
}
if (method_exists($impersonator, 'canImpersonate') && ! $impersonator->canImpersonate()) {
return response()->json([
'status' => 'failed',
'message' => 'Akses ditolak (impersonate).',
], 403);
}
if (method_exists($target, 'canBeImpersonated') && ! $target->canBeImpersonated()) {
return response()->json([
'status' => 'failed',
'message' => 'Akaun ini tidak boleh di-impersonate.',
], 403);
}
// Revoke current token to reduce risk of dual active sessions.
try {
if (method_exists($impersonator, 'token') && $impersonator->token()) {
$impersonator->token()->revoke();
}
} catch (\Throwable $e) {
// Best effort.
}
// Encode impersonator id in token name for later "leave" action.
$tokenName = 'impersonate:' . (int) $impersonator->id;
$accessToken = $target->createToken($tokenName, ['admin'])->accessToken;
activity()
->performedOn($target)
->withProperties([
'admin_id' => $impersonator->id,
'admin_name' => $impersonator->name,
'admin_email' => $impersonator->email,
'impersonated_user_id' => $target->id,
'impersonated_user_name' => $target->name,
'impersonated_user_email' => $target->email,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("admin impersonate: {$impersonator->email} -> {$target->email}");
return response()->json([
'status' => 'success',
'message' => 'Impersonate berjaya.',
'user' => $target,
'impersonator' => $impersonator,
'token' => $accessToken,
]);
}
}
@@ -26,9 +26,29 @@ class LoginController extends Controller
$result['user'] = $user;
$result['election_status'] = Util::getElectionStatus();
$result['token'] = $user->createToken('My app', ['admin'])->accessToken;
activity()
->performedOn($user)
->withProperties([
'admin_id' => $user->id,
'admin_name' => $user->name,
'admin_email' => $user->email,
'admin_role' => $user->role,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("admin login: {$user->email}");
} else {
$result['status'] = 'failed';
$result['message'] = 'Wrong email or password';
activity()
->withProperties([
'admin_email' => (string) $request->input('email'),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('admin login failed');
}
return response()->json($result);
@@ -8,8 +8,31 @@ use App\Http\Controllers\Controller;
class LogoutController extends Controller
{
public function __invoke()
public function __invoke(Request $request)
{
$user = Auth::user();
if ($user) {
activity()
->performedOn($user)
->withProperties([
'admin_id' => $user->id,
'admin_name' => $user->name,
'admin_email' => $user->email,
'admin_role' => $user->role,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("admin logout: {$user->email}");
} else {
activity()
->withProperties([
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('admin logout');
}
Auth::guard('web')->logout();
Auth::guard('voter')->logout();
return response()->json([
@@ -0,0 +1,119 @@
<?php
namespace App\Http\Controllers\API\v1\Allowance;
use App\AllowanceClaimCode;
use App\AllowancePayout;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Result;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
class ClaimCodeController extends Controller
{
public function __invoke(Request $request)
{
$this->validate($request, [
'method' => 'nullable|string|in:cash,bank',
]);
$electionId = Util::getCurrentElection();
$method = $request->input('method', 'cash');
// voter auth guard; Auth::id() should be voter id under voterAPI
$voterId = Auth::id();
$voter = $request->user();
if (!$voter || (int) $voter->id !== (int) $voterId) {
return response()->json([
'status' => 'failed',
'message' => 'Unauthenticated.',
], 401);
}
// Enforce method by attendance
if ($method === 'cash' && (int) $voter->kehadiran !== 1) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan tunai hanya untuk kehadiran Fizikal.',
], 422);
}
if ($method === 'bank' && (int) $voter->kehadiran !== 2) {
return response()->json([
'status' => 'failed',
'message' => 'Kod tuntutan bank hanya untuk kehadiran Maya.',
], 422);
}
// Must have voted
$hasVoted = Result::where('election_id', $electionId)->where('voter_id', $voterId)->exists();
if (!$hasVoted) {
return response()->json([
'status' => 'failed',
'message' => 'Sila undi terlebih dahulu sebelum menuntut elaun.',
], 422);
}
// If already paid, don't generate codes
$alreadyPaid = AllowancePayout::where('election_id', $electionId)
->where('voter_id', $voterId)
->where('method', $method)
->where('status', 'paid')
->exists();
if ($alreadyPaid) {
return response()->json([
'status' => 'failed',
'message' => 'Elaun anda telah direkodkan sebagai sudah dibayar.',
], 422);
}
// Generate a 6-digit numeric code
$codeInt = random_int(0, 999999);
$code = str_pad((string) $codeInt, 6, '0', STR_PAD_LEFT);
$expiresAt = Carbon::now()->addHours(6);
$row = AllowanceClaimCode::updateOrCreate(
[
'election_id' => $electionId,
'voter_id' => $voterId,
'method' => $method,
],
[
'code_hash' => Hash::make($code),
'code_last4' => substr($code, -4),
'expires_at' => $expiresAt,
'used_at' => null,
'used_by_admin_id' => null,
]
);
activity()
->performedOn($row)
->withProperties([
'election_id' => $electionId,
'voter_id' => (int) $voterId,
'voter_name' => $voter->name,
'attendance' => (int) $voter->kehadiran,
'method' => $method,
'expires_at' => $expiresAt->toDateTimeString(),
'code_last4' => $row->code_last4,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("generate claim code ({$method}): {$voter->name}");
return response()->json([
'status' => 'success',
'message' => 'Kod tuntutan berjaya dijana.',
'code' => $code, // return plaintext ONCE to voter
'expires_at' => $expiresAt,
'method' => $method,
]);
}
}
@@ -33,6 +33,17 @@ class StartController extends Controller
'name'=>$request->name
]);
$user = $request->user();
activity()
->withProperties([
'election_id' => Util::getCurrentElection(),
'election_name' => (string) $request->input('name'),
'started_by_admin_id' => $user ? (int) $user->id : null,
'started_by_admin_email' => $user ? $user->email : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('election start');
return response()->json([
'status' => 'success',
@@ -21,6 +21,18 @@ class StopController extends Controller
$this->stopElection($request);
$user = $request->user();
activity()
->withProperties([
'election_id' => Util::getCurrentElection(),
'election_name' => (string) $request->input('name'),
'stopped_by_admin_id' => $user ? (int) $user->id : null,
'stopped_by_admin_email' => $user ? $user->email : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('election stop');
return response()->json([
'status' => 'success',
'message' => 'Election has finished.',
@@ -16,13 +16,38 @@ class VoteController extends Controller
public function __invoke(Request $request)
{
if ($this->validateRequest($request)['status'] == 'failed') {
return response()->json($this->validateRequest($request));
$validation = $this->validateRequest($request);
if (($validation['status'] ?? 'failed') === 'failed') {
return response()->json($validation);
}
$this->insertVote($request);
$user = Auth::guard('voter')->user() ?: $request->user();
$vote = is_array($request->vote) ? $request->vote : [];
$positionsVoted = 0;
$totalSelections = 0;
foreach ($vote as $value) {
$nomineeIds = $value['nominee_id'] ?? null;
if (is_array($nomineeIds) && count($nomineeIds)) {
$positionsVoted++;
$totalSelections += count($nomineeIds);
}
}
activity()
->performedOn($user)
->withProperties([
'election_id' => Util::getCurrentElection(),
'voter_id' => Auth::id(),
'voter_name' => $user ? $user->name : null,
'positions_voted' => $positionsVoted,
'total_selections' => $totalSelections,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('submit vote');
return response()->json([
'status' => 'success',
'message' => 'Voted successfully',
@@ -32,34 +57,31 @@ class VoteController extends Controller
private function insertVote($request)
{
// dd($request->vote[0]['nominee_id']);
$votes = array_map(function($nominee_id) use($request){
return[
'voter_id' => Auth::id(),
'election_id' => Util::getCurrentElection(),
'position_id' => $request->vote[0]['position_id'],
'nominee_id' => $nominee_id
];
$vote = $request->vote;
if (!is_array($vote)) return;
},$request->vote[0]['nominee_id']);
foreach ($vote as $value) {
$positionId = $value['position_id'] ?? null;
$nomineeIds = $value['nominee_id'] ?? [];
if (!$positionId || !is_array($nomineeIds) || !count($nomineeIds)) continue;
foreach($votes as $vote){
Result::updateOrCreate([
'voter_id' => $vote['voter_id'],
'election_id' => $vote['election_id'],
'position_id' => $vote['position_id'],
'nominee_id' => $vote['nominee_id'],
]);
foreach ($nomineeIds as $nomineeId) {
Result::updateOrCreate([
'voter_id' => Auth::id(),
'election_id' => Util::getCurrentElection(),
'position_id' => $positionId,
'nominee_id' => $nomineeId,
]);
}
}
}
private function validNominee($id, $position_id)
{
try{
$nominee = Nominee::where('position_id', '=', $position_id)->whereIn('id',$id)->count();
}catch(Exception $e){
dd($id);
}catch(\Exception $e){
return 0;
}
return $nominee;
}
@@ -68,8 +90,8 @@ class VoteController extends Controller
{
try{
return Position::where('id', $id)->count();
}catch(Exception $e){
dd($id);
}catch(\Exception $e){
return 0;
}
}
@@ -87,14 +109,8 @@ class VoteController extends Controller
$result['status'] = 'failed';
$vote = $request->vote;
/**
* Check if the user vote on all position
*/
if (!$this->voteAllPosition($request)) {
dd('test');
$result['message'] = 'You must vote on all position.';
if (!is_array($vote) || !count($vote)) {
$result['message'] = 'You must vote on at least one position.';
return $result;
}
@@ -103,19 +119,25 @@ class VoteController extends Controller
* Check if position_id and nominee_id has a value
*/
// dd($vote,$value);
if (empty($value['position_id']) || empty($value['nominee_id'])) {
dd($value,empty($value['position_id']),empty($value['nominee_id']));
$result['message'] = 'You must vote on all position.';
$positionId = $value['position_id'] ?? null;
$nomineeIds = $value['nominee_id'] ?? null;
if (empty($positionId)) {
$result['message'] = 'Invalid Position.';
return $result;
}
if (!is_array($nomineeIds) || !count($nomineeIds)) {
// Skip empty positions; allow partial voting setups (UI may send all positions with empty arrays)
continue;
}
if (count($nomineeIds) > 1) {
$result['message'] = 'You can only vote for one nominee.';
return $result;
}
/**
* Check if the Position you vote exists
*/
if (!$this->isPositionExist($value['position_id'])) {
dd('test');
if (!$this->isPositionExist($positionId)) {
$result['message'] = 'Invalid Position.';
return $result;
}
@@ -124,14 +146,28 @@ class VoteController extends Controller
* Check if the Nominee you vote on certain position exists
*/
// elseif (!$this->validNominee($value['nominee_id'], $value['position_id'])) {
elseif($this->validNominee($value['nominee_id'],$value['position_id']) <= 0){
elseif($this->validNominee($nomineeIds, $positionId) <= 0){
$result['message'] = 'Invalid Nominee for '.Position::find($value['position_id'])->name.' position.';
$result['message'] = 'Invalid Nominee for '.Position::find($positionId)->name.' position.';
return $result;
}
}
return ['status'=>'success'];
// Ensure at least one actual vote exists
$hasAny = false;
foreach ($vote as $value) {
$nomineeIds = $value['nominee_id'] ?? null;
if (is_array($nomineeIds) && count($nomineeIds)) {
$hasAny = true;
break;
}
}
if (!$hasAny) {
$result['message'] = 'You must vote on at least one position.';
return $result;
}
return ['status' => 'success'];
}
}
@@ -14,7 +14,21 @@ class AddController extends Controller
public function __invoke (Request $request)
{
$this->validateRequest($request);
$this->insertNominee($request);
$nominee = $this->insertNominee($request);
activity()
->performedOn($nominee)
->withProperties([
'nominee_id' => $nominee->id,
'nominee_name' => $nominee->name,
'election_id' => $nominee->election_id,
'position_id' => $nominee->position_id,
'partylist_id' => $nominee->partylist_id,
'created_by_admin_id' => $request->user() ? (int) $request->user()->id : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("create nominee: {$nominee->name}");
return response()->json([
'status' => 'success',
'message'=> 'Nominee added successfully'
@@ -39,7 +53,11 @@ class AddController extends Controller
'unit' => 'required',
'position_id' => 'required|exists:position,id',
'partylist_id' => 'nullable|exists:partylist,id',
'image' => 'nullable|image'
'photo' => 'nullable|image',
'umur' => 'nullable|integer|min:1|max:120',
'jawatan_sekarang' => 'nullable|string|max:255',
'experience' => 'nullable|string',
'education' => 'nullable|string',
]);
}
@@ -53,9 +71,6 @@ class AddController extends Controller
$nominee['photo'] = $imageData;
}
Nominee::create($nominee);
return response()->json(['message' => 'Photo updated successfully']);
return Nominee::create($nominee);
}
}
@@ -9,12 +9,23 @@ use App\Nominee;
class DeleteController extends Controller
{
public function __invoke ($id)
public function __invoke (Request $request, $id)
{
$nominee = Nominee::findOrFail($id);
Util::deleteImage($nominee->image);
$nominee->delete();
activity()
->withProperties([
'nominee_id' => $nominee->id,
'nominee_name' => $nominee->name,
'election_id' => $nominee->election_id,
'deleted_by_admin_id' => $request->user() ? (int) $request->user()->id : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("delete nominee: {$nominee->name}");
return response()->json([
'status' => 'success',
'message'=> 'Nominee deleted successfully'
@@ -5,14 +5,21 @@ namespace App\Http\Controllers\API\v1\Nominee;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Nominee;
class GetController extends Controller
{
public function __invoke()
{
$nominee = \App\Nominee::where('election_id', Util::getCurrentElection())
->orderBy('position_id')
->get();
return $nominee;
$q = Nominee::query()
->where('election_id', Util::getCurrentElection())
->orderBy('position_id');
$positionId = request('position_id');
if ($positionId !== null && $positionId !== '' && (int) $positionId !== 0) {
$q->where('position_id', (int) $positionId);
}
return $q->get();
}
}
@@ -13,7 +13,20 @@ class UpdateController extends Controller
public function __invoke (Request $request, $id)
{
$this->validateRequest($request, $id);
$this->updateNominee($request, $id);
$nominee = $this->updateNominee($request, $id);
activity()
->performedOn($nominee)
->withProperties([
'nominee_id' => $nominee->id,
'nominee_name' => $nominee->name,
'election_id' => $nominee->election_id,
'changed_keys' => array_keys($nominee->getChanges()),
'updated_by_admin_id' => $request->user() ? (int) $request->user()->id : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("update nominee: {$nominee->name}");
return response()->json([
'status' => 'success',
'message' => 'Nominee updated successfully'
@@ -43,7 +56,7 @@ class UpdateController extends Controller
private function updateNominee ($request, $id)
{
$nominee = Nominee::find($id);
$nominee = Nominee::findOrFail($id);
$nominee->name = $request->name;
$nominee->unit = $request->unit;
@@ -52,6 +65,8 @@ class UpdateController extends Controller
$nominee->partylist_id = $request->partylist_id;
$nominee->education = $request->education;
$nominee->experience = $request->experience;
$nominee->umur = $request->umur;
$nominee->jawatan_sekarang = $request->jawatan_sekarang;
if ($request->hasFile('photo')) {
$imageData = file_get_contents($request->file('photo'));
@@ -59,6 +74,7 @@ class UpdateController extends Controller
}
$nominee->save();
return $nominee;
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\API\v1;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Services\PhysicalAttendanceGateCode;
use Illuminate\Http\Request;
class PhysicalAttendanceGateController extends Controller
{
/**
* Current rotating code for the registration counter display.
*
* @param \Illuminate\Http\Request $request
* @param \App\Services\PhysicalAttendanceGateCode $gate
* @return \Illuminate\Http\JsonResponse
*/
public function __invoke(Request $request, PhysicalAttendanceGateCode $gate)
{
$electionId = Util::getCurrentElection();
return response()->json($gate->currentPayload($electionId));
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\API\v1;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Voter;
/**
* Unauthenticated list of physical (Fizikal) attendees for roulette / display.
*/
class RouletteFizikalVotersController extends Controller
{
public function __invoke()
{
$electionId = Util::getCurrentElection();
$voters = Voter::query()
->where('election_id', $electionId)
->where('kehadiran', 1)
->orderBy('no_anggota')
->get(['id', 'name', 'no_anggota']);
return response()->json([
'voters' => $voters,
]);
}
}
@@ -13,7 +13,22 @@ class AddController extends Controller
public function __invoke(Request $request)
{
$this->validateRequest($request);
$this->insertVoter($request);
$voter = $this->insertVoter($request);
activity()
->performedOn($voter)
->withProperties([
'election_id' => $voter->election_id,
'voter_id' => $voter->id,
'voter_name' => $voter->name,
'no_kp' => $voter->no_kp,
'no_anggota' => $voter->no_anggota,
'unit' => $voter->unit,
'created_by_admin_id' => $request->user() ? (int) $request->user()->id : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("create voter: {$voter->name}");
return response()->json([
'status' => 'success',
'message' => 'Voter added successfully'
@@ -24,7 +39,7 @@ class AddController extends Controller
{
$voter = $request->all();
$voter['election_id'] = Util::getCurrentElection();
Voter::create($voter);
return Voter::create($voter);
}
private function validateRequest($request)
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\API\v1\Voter;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Voter;
use Illuminate\Support\Facades\DB;
/**
* Lists every distinct member (by no_kp) found anywhere in voter,
* for picking who is applicable to the current election.
*/
class AllMembersCatalogController extends Controller
{
public function __invoke()
{
$electionId = Util::getCurrentElection();
$maxIds = DB::table('voter')
->select(DB::raw('MAX(id) as id'))
->whereNotNull('no_kp')
->where('no_kp', '!=', '')
->groupBy('no_kp')
->pluck('id');
$rows = Voter::whereIn('id', $maxIds)
->orderBy('name')
->get(['no_kp', 'no_anggota', 'name', 'unit']);
$inCurrent = Voter::where('election_id', $electionId)
->pluck('no_kp')
->filter(function ($kp) {
return $kp !== null && $kp !== '';
})
->flip();
$catalog = $rows->map(function ($row) use ($inCurrent) {
return [
'no_kp' => $row->no_kp,
'no_anggota' => $row->no_anggota,
'name' => $row->name,
'unit' => $row->unit,
'in_current_election' => $inCurrent->has($row->no_kp),
];
});
return response()->json([
'election_id' => $electionId,
'catalog' => $catalog->values()->all(),
]);
}
}
@@ -3,9 +3,11 @@
namespace App\Http\Controllers\API\v1\Voter;
use Illuminate\Http\Request;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Validation\Rule;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Services\PhysicalAttendanceGateCode;
use App\Voter;
class AttendanceController extends Controller
@@ -14,6 +16,7 @@ class AttendanceController extends Controller
public function __invoke(Request $request, $id)
{
$this->validateRequest($request, $id);
$this->assertPhysicalGateCode($request);
$this->updateAttendance($request, $id);
return response()->json([
'status' => 'success',
@@ -23,7 +26,7 @@ class AttendanceController extends Controller
private function validateRequest($request, $id)
{
$this->validate($request, [
$rules = [
'name' => [
'required',
Rule::unique('voter')->ignore($id)->where(function($query){
@@ -46,15 +49,72 @@ class AttendanceController extends Controller
})
],
'kehadiran' => 'required'
'kehadiran' => 'required',
];
]);
if (config('physical_attendance_gate.enabled')) {
$rules['physical_gate_code'] = 'required_if:kehadiran,1|string|max:64';
}
$this->validate($request, $rules);
}
/**
* When gate is enabled, Fizikal (kehadiran = 1) must match the rotating counter code.
*
* @param \Illuminate\Http\Request $request
* @return void
*/
private function assertPhysicalGateCode(Request $request)
{
if (!config('physical_attendance_gate.enabled')) {
return;
}
if ((int) $request->input('kehadiran') !== 1) {
return;
}
$gate = app(PhysicalAttendanceGateCode::class);
if (!$gate->isConfigured()) {
throw new HttpResponseException(response()->json([
'status' => 'failed',
'message' => 'Pengesahan kod fizikal tidak dikonfigurasi dengan betul.',
], 503));
}
if (!$gate->validates(Util::getCurrentElection(), (string) $request->input('physical_gate_code'))) {
throw new HttpResponseException(response()->json([
'status' => 'failed',
'message' => 'Kod kaunter tidak sah atau telah tamat tempoh. Sila semak skrin pendaftaran dan cuba lagi.',
], 422));
}
}
private function updateAttendance($request, $id)
{
$voter = $request->all();
Voter::find($id)->update($voter);
$voter = Voter::findOrFail($id);
$voter->fill($request->only(['name', 'no_kp', 'no_anggota', 'unit', 'kehadiran']));
if ((int) $voter->kehadiran === 1) {
$voter->fizikal_registration_verified_at = null;
}
$voter->save();
$mode = ((int) $voter->kehadiran === 1) ? 'physical' : 'online';
activity()
->performedOn($voter)
->withProperties([
'voter_id' => $voter->id,
'voter_name' => $voter->name,
'election_id' => Util::getCurrentElection(),
'attendance_mode' => $mode,
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
])
->log("confirm attendance ({$mode}): {$voter->name}");
}
}
@@ -5,12 +5,105 @@ namespace App\Http\Controllers\API\v1\Voter;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use Illuminate\Support\Facades\DB;
use App\Voter;
class GetController extends Controller
{
public function __invoke()
{
return Voter::where('election_id', Util::getCurrentElection())->paginate(50);
$electionId = Util::getCurrentElection();
$q = Voter::query()
->where('election_id', $electionId)
// Ensure we keep all voter columns when adding computed selects
->select('voter.*')
// Computed: whether voter has any vote record for this election
->selectSub(function ($sq) use ($electionId) {
$sq->from('result')
->selectRaw('COUNT(*)')
->whereColumn('result.voter_id', 'voter.id')
->where('result.election_id', $electionId);
}, 'votes_count');
// Computed: payout timestamps (paperless coupon replacement state)
$q->selectSub(function ($sq) use ($electionId) {
$sq->from('allowance_payouts')
->selectRaw('MAX(paid_at)')
->whereColumn('allowance_payouts.voter_id', 'voter.id')
->where('allowance_payouts.election_id', $electionId)
->where('allowance_payouts.method', 'cash')
->where('allowance_payouts.status', 'paid');
}, 'cash_paid_at');
$q->selectSub(function ($sq) use ($electionId) {
$sq->from('allowance_payouts')
->selectRaw('MAX(id)')
->whereColumn('allowance_payouts.voter_id', 'voter.id')
->where('allowance_payouts.election_id', $electionId)
->where('allowance_payouts.method', 'cash')
->where('allowance_payouts.status', 'paid');
}, 'cash_payout_id');
$q->selectSub(function ($sq) use ($electionId) {
$sq->from('allowance_payouts')
->selectRaw('MAX(paid_at)')
->whereColumn('allowance_payouts.voter_id', 'voter.id')
->where('allowance_payouts.election_id', $electionId)
->where('allowance_payouts.method', 'bank')
->where('allowance_payouts.status', 'paid');
}, 'bank_paid_at');
$q->selectSub(function ($sq) use ($electionId) {
$sq->from('allowance_payouts')
->selectRaw('MAX(id)')
->whereColumn('allowance_payouts.voter_id', 'voter.id')
->where('allowance_payouts.election_id', $electionId)
->where('allowance_payouts.method', 'bank')
->where('allowance_payouts.status', 'paid');
}, 'bank_payout_id');
// Optional search filters
$noAnggota = request('no_anggota');
if ($noAnggota !== null && $noAnggota !== '') {
$q->where('no_anggota', 'like', '%' . $noAnggota . '%');
}
$noKp = request('no_kp');
if ($noKp !== null && $noKp !== '') {
$q->where('no_kp', 'like', '%' . $noKp . '%');
}
$name = request('name');
if ($name !== null && $name !== '') {
$q->where('name', 'like', '%' . $name . '%');
}
// Optional attendance filter: 1 = Fizikal, 2 = Maya
$kehadiran = request('kehadiran');
if ($kehadiran !== null && $kehadiran !== '') {
if ($kehadiran === 'unset') {
$q->where(function ($qq) {
$qq->whereNull('kehadiran')->orWhere('kehadiran', 0);
});
} else {
$k = (int) $kehadiran;
if (in_array($k, [1, 2], true)) {
$q->where('kehadiran', $k);
}
}
}
// Fizikal registration queue: pending = fizikal but not admin-verified yet
$fizikalReg = request('fizikal_reg');
if ($fizikalReg !== null && $fizikalReg !== '') {
if ($fizikalReg === 'pending') {
$q->where('kehadiran', 1)->whereNull('fizikal_registration_verified_at');
} elseif ($fizikalReg === 'verified') {
$q->where('kehadiran', 1)->whereNotNull('fizikal_registration_verified_at');
}
}
return $q->paginate(50);
}
}
@@ -16,201 +16,249 @@ class LoginController extends Controller
{
public function __invoke(Request $request)
{
//return response()->json(Auth::guard('user'));
/* Request OTP */
try {
$currentDateTime = Carbon::now();
/* Request OTP */
try{
$currentDateTime = Carbon::now();
$expired_at = Carbon::now()->addMinutes(config('onewaysms.minutes'));
$created_at = $currentDateTime->toDateTimeString();
$expired_at = Carbon::now()->addMinutes(config('onewaysms.minutes'));
$created_at = $currentDateTime->toDateTimeString();
$voter = Voter::where('no_kp', $request->no_kp)->firstOrFail();
$voter = Voter::where('no_kp',$request->no_kp)->firstOrFail();
if ($voter) {
$expiration = $this->checkExpirationTAC($request->no_kp);
if($voter){
$expiration = $this->checkExpirationTAC($request->no_kp);
if($expiration['notexpired'] == true){
throw new \Exception('Expired TAC : ' . $expiration['remaining']);
}
$sms_token = $this->getTokenSMS($voter->telefon,$currentDateTime);
if($sms_token == 'error'){
throw new \Exception('error in getting the data from SMSToken');
}
$user_otp = new UserOTP();
$user_otp->nokp = $voter->no_kp;
$user_otp->telefon = $voter->telefon;
$user_otp->token = $sms_token;
$user_otp->created_at = $created_at;
$user_otp->expired_at = $expired_at;
$user_otp->save();
$response = [
'status' => 'success',
'message' => 'generated SMS Token',
'notel' => $voter->telefon,
'nokp' => $voter->no_kp,
];
if (app()->environment(['local', 'development'])) {
$response['debug_otp'] = $sms_token;
}
return response()->json($response);
}else{
return response()->json([
'status' => 'failed',
'message' => 'Does not found NO KP.'
]);
}
}catch(Exception $e){
return response()->json([
'status' => 'failed',
'message' => 'Error: ' . $e->getMessage()
]);
}
}
public function checkExpirationTAC($nokp) : Array{
$currentDateTime = Carbon::now();
$expiretac = UserOTP::where('nokp',$nokp)
->whereDate('created_at','=',$currentDateTime)
->whereDate('expired_at','=',$currentDateTime)
->whereTime('expired_at','>=',$currentDateTime->toTimeString())
->whereTime('created_at','<=',$currentDateTime->toTimeString())
->latest()
->first();
if($expiretac){
$remaining = $this->getRemainingTime($expiretac->expired_at);
return ['notexpired' => true,'remaining' => $remaining];
}else{
return ['notexpired' => false];
}
}
public function verifyTAC(Request $request){
$authenticateOTP = $this->getTACModel($request->no_kp,$request->token);
if(!$authenticateOTP['condition']){
return response()->json([
'status' => 'failed',
'message' => $authenticateOTP['message']
]);
}
if (Auth::guard('voter')->attempt(['no_kp' => $request->no_kp, 'password' => 'admin', 'election_id' => Util::getCurrentElection()])) {
return response()->json([
'status' => 'success',
'message' => 'Login successfully.',
'user' => Auth::guard('voter')->user(),
'election_status' => Util::getElectionStatus(),
'token' => Auth::guard('voter')->user()->createToken('My Token', ['vote'])->accessToken
]);
} else {
return response()->json([
'status' => 'failed',
'message' => 'No. KP tidak wujud. Atau sesi mengundi telah tamat'
]);
}
return response()->json([
'status' => 'success',
'message' => 'Login successfully.',
]);
}
private function getTACModel($no_kp,$token){
$currentDateTime = Carbon::now();
$result = UserOTP::where('nokp',$no_kp)
->whereDate('created_at','=',$currentDateTime)
->whereDate('expired_at','=',$currentDateTime)
->whereTime('expired_at','>=',$currentDateTime->toTimeString())
->whereTime('created_at','<=',$currentDateTime->toTimeString())
->latest()
->first();
if(!$result){
return ['condition' => false, 'message' => 'Token Expired'];
}
if($result['token'] == $token){
return ['condition' => true];
}else{
return ['condition' => false, 'message' => 'Invalid Token'];
}
}
private function getTokenSMS($tele,$date){
$tac_token = $this->randum_number(6,2,false);
if(env("APP_ENV") == 'production'){
$mobileno = '6' . $tele;
$messages = sprintf(config('onewaysms.message'),$tac_token,$date);
$parameter = [
'apiusername' => env("ONEWAY_SMS_USERNAME"),
'apipassword' => env("ONEWAY_SMS_PASSWORD"),
'senderid' => env("ONEWAY_SMS_SENDERID",'INFO'),
'mobileno' => $mobileno,
'message'=> $messages,
'languagetype'=> env("ONEWAY_SMS_LANG",1)
];
$client = new Client(['verify' => false]);
$status = $client->get('http://gateway.onewaysms.com.my:10001/api.aspx',[
'query' => $parameter
]);
if($status){
return $tac_token;
}else{
return 'error';
}
}else{
return $tac_token;
}
}
private function randum_number($len = 6,$dup = 1, $sort = false){
if($dup < 1)
throw new \InvalidArgumentException('Second argument is < 1');
$num = range(0,9);
shuffle($num);
$num = array_slice($num, 0, ($len-$dup) + 1);
if($dup > 0){
$k = array_rand($num, 1);
for($i=0;$i<($dup-1);$i++)
{
$num[] = $num[$k];
if ($expiration['notexpired'] == true) {
throw new \Exception('Expired TAC : ' . $expiration['remaining']);
}
$sms_token = $this->getTokenSMS($voter->telefon, $currentDateTime);
if ($sms_token == 'error') {
throw new \Exception('error in getting the data from SMSToken');
}
$user_otp = new UserOTP();
$user_otp->nokp = $voter->no_kp;
$user_otp->telefon = $voter->telefon;
$user_otp->token = $sms_token;
$user_otp->created_at = $created_at;
$user_otp->expired_at = $expired_at;
$user_otp->save();
activity()
->performedOn($voter)
->withProperties([
'election_id' => Util::getCurrentElection(),
'voter_id' => $voter->id,
'voter_name' => $voter->name,
'no_kp' => $voter->no_kp,
'telefon' => $voter->telefon,
'otp_expires_at' => $expired_at->toDateTimeString(),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('voter request tac');
$response = [
'status' => 'success',
'message' => 'generated SMS Token',
'notel' => $voter->telefon,
'nokp' => $voter->no_kp,
];
if (app()->environment(['local', 'development'])) {
$response['debug_otp'] = $sms_token;
}
return response()->json($response);
}
if($sort){
sort($num);
}
return response()->json([
'status' => 'failed',
'message' => 'Does not found NO KP.',
]);
} catch (Exception $e) {
activity()
->withProperties([
'no_kp' => (string) $request->input('no_kp'),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'error' => $e->getMessage(),
])
->log('voter request tac failed');
return implode('',$num);
return response()->json([
'status' => 'failed',
'message' => 'Error: ' . $e->getMessage(),
]);
}
}
private function getRemainingTime($expired_at){
public function checkExpirationTAC($nokp): array
{
$currentDateTime = Carbon::now();
$expiredAt = Carbon::parse($expired_at);
$remainingSeconds = $currentDateTime->diffInSeconds($expiredAt);
$expiretac = UserOTP::where('nokp', $nokp)
->whereDate('created_at', '=', $currentDateTime)
->whereDate('expired_at', '=', $currentDateTime)
->whereTime('expired_at', '>=', $currentDateTime->toTimeString())
->whereTime('created_at', '<=', $currentDateTime->toTimeString())
->latest()
->first();
$remainingFormatted = gmdate('H:i:s', $remainingSeconds);
if ($expiretac) {
$remaining = $this->getRemainingTime($expiretac->expired_at);
return $remainingFormatted;
}
return ['notexpired' => true, 'remaining' => $remaining];
}
return ['notexpired' => false];
}
public function verifyTAC(Request $request)
{
$authenticateOTP = $this->getTACModel($request->no_kp, $request->token);
if (! $authenticateOTP['condition']) {
activity()
->withProperties([
'no_kp' => (string) $request->input('no_kp'),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
'reason' => $authenticateOTP['message'] ?? 'invalid',
])
->log('voter verify tac failed');
return response()->json([
'status' => 'failed',
'message' => $authenticateOTP['message'],
]);
}
if (Auth::guard('voter')->attempt(['no_kp' => $request->no_kp, 'password' => 'admin', 'election_id' => Util::getCurrentElection()])) {
$user = Auth::guard('voter')->user();
activity()
->performedOn($user)
->withProperties([
'election_id' => Util::getCurrentElection(),
'voter_id' => $user->id,
'voter_name' => $user->name,
'no_kp' => $user->no_kp,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("voter login: {$user->name}");
return response()->json([
'status' => 'success',
'message' => 'Login successfully.',
'user' => Auth::guard('voter')->user(),
'election_status' => Util::getElectionStatus(),
'token' => Auth::guard('voter')->user()->createToken('My Token', ['vote'])->accessToken,
]);
}
activity()
->withProperties([
'no_kp' => (string) $request->input('no_kp'),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('voter login failed');
return response()->json([
'status' => 'failed',
'message' => 'No. KP tidak wujud. Atau sesi mengundi telah tamat',
]);
}
private function getTACModel($no_kp, $token)
{
$currentDateTime = Carbon::now();
$result = UserOTP::where('nokp', $no_kp)
->whereDate('created_at', '=', $currentDateTime)
->whereDate('expired_at', '=', $currentDateTime)
->whereTime('expired_at', '>=', $currentDateTime->toTimeString())
->whereTime('created_at', '<=', $currentDateTime->toTimeString())
->latest()
->first();
if (! $result) {
return ['condition' => false, 'message' => 'Token Expired'];
}
if ($result['token'] == $token) {
return ['condition' => true];
}
return ['condition' => false, 'message' => 'Invalid Token'];
}
private function getTokenSMS($tele, $date)
{
$tac_token = $this->randum_number(6, 2, false);
if (env('APP_ENV') == 'production') {
$mobileno = '6' . $tele;
$messages = sprintf(config('onewaysms.message'), $tac_token, $date);
$parameter = [
'apiusername' => env('ONEWAY_SMS_USERNAME'),
'apipassword' => env('ONEWAY_SMS_PASSWORD'),
'senderid' => env('ONEWAY_SMS_SENDERID', 'INFO'),
'mobileno' => $mobileno,
'message' => $messages,
'languagetype' => env('ONEWAY_SMS_LANG', 1),
];
$client = new Client(['verify' => false]);
$status = $client->get('http://gateway.onewaysms.com.my:10001/api.aspx', [
'query' => $parameter,
]);
if ($status) {
return $tac_token;
}
return 'error';
}
return $tac_token;
}
private function randum_number($len = 6, $dup = 1, $sort = false)
{
if ($dup < 1) {
throw new \InvalidArgumentException('Second argument is < 1');
}
$num = range(0, 9);
shuffle($num);
$num = array_slice($num, 0, ($len - $dup) + 1);
if ($dup > 0) {
$k = array_rand($num, 1);
for ($i = 0; $i < ($dup - 1); $i++) {
$num[] = $num[$k];
}
}
if ($sort) {
sort($num);
}
return implode('', $num);
}
private function getRemainingTime($expired_at)
{
$currentDateTime = Carbon::now();
$expiredAt = Carbon::parse($expired_at);
$remainingSeconds = $currentDateTime->diffInSeconds($expiredAt);
return gmdate('H:i:s', $remainingSeconds);
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\API\v1\Voter;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use Illuminate\Http\Request;
class LogoutController extends Controller
{
public function __invoke(Request $request)
{
$user = $request->user();
if ($user) {
activity()
->performedOn($user)
->withProperties([
'election_id' => Util::getCurrentElection(),
'voter_id' => $user->id,
'voter_name' => $user->name,
'no_kp' => $user->no_kp,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("voter logout: {$user->name}");
// Revoke current access token if using Passport.
try {
if (method_exists($user, 'token') && $user->token()) {
$user->token()->revoke();
}
} catch (\Throwable $e) {
// Logging should not block logout.
}
} else {
activity()
->withProperties([
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('voter logout');
}
return response()->json([
'status' => 'success',
'message' => 'Logout successfully',
]);
}
}
@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\API\v1\Voter;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Voter;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Applies admin selection: voters ticked exist for current election (copied from latest row per no_kp);
* unticked are removed from the current election only.
*/
class SyncApplicableVotersController extends Controller
{
public function __invoke(Request $request)
{
$this->validate($request, [
'selected_no_kp' => 'present|array',
'selected_no_kp.*' => 'nullable|string|max:60',
]);
$electionId = Util::getCurrentElection();
$selected = collect($request->input('selected_no_kp', []))
->map(function ($v) {
return trim((string) $v);
})
->filter(function ($v) {
return $v !== '';
})
->unique()
->values();
$stats = [
'deleted' => 0,
'added' => 0,
'selected_count' => $selected->count(),
];
DB::transaction(function () use ($electionId, $selected) {
$selectedSet = $selected->flip();
$current = Voter::where('election_id', $electionId)->get();
foreach ($current as $voter) {
$kp = $voter->no_kp;
if ($kp === null || $kp === '') {
continue;
}
if (!$selectedSet->has($kp)) {
$voter->delete();
}
}
$existingKp = Voter::where('election_id', $electionId)->pluck('no_kp')->all();
$existingFlip = array_flip($existingKp);
foreach ($selected as $noKp) {
if (isset($existingFlip[$noKp])) {
continue;
}
$template = Voter::where('no_kp', $noKp)->orderBy('id', 'desc')->first();
if (!$template) {
continue;
}
$new = $template->replicate();
$new->election_id = $electionId;
$new->kehadiran = null;
$new->fizikal_registration_verified_at = null;
$new->persetujuan = null;
$new->status_penyata = 'DRAF';
$new->tarikh_sah = null;
$new->cadangan = null;
$new->save();
}
});
// Log after transaction succeeds (no joins; data from request & current election).
activity()
->withProperties([
'election_id' => $electionId,
'selected_count' => $selected->count(),
'selected_no_kp' => $selected->take(50)->values()->all(),
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log('sync applicable voters');
return response()->json([
'status' => 'success',
'message' => 'Senarai pengundi untuk pilihan raya semasa dikemas kini.',
]);
}
}
@@ -41,7 +41,24 @@ class UpdateController extends Controller
private function updateVoter($request, $id)
{
$voter = $request->all();
Voter::find($id)->update($voter);
$voter = Voter::findOrFail($id);
$old = $voter->toArray();
$voter->fill($request->all());
$voter->save();
$dirtyKeys = array_keys($voter->getChanges());
activity()
->performedOn($voter)
->withProperties([
'voter_id' => $voter->id,
'voter_name' => $voter->name,
'election_id' => Util::getCurrentElection(),
'changed_keys' => $dirtyKeys,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("update voter: {$voter->name}");
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers\API\v1\Voter;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Util;
use App\Voter;
use Illuminate\Http\Request;
class VerifyFizikalRegistrationController extends Controller
{
/**
* Admin confirms physical registration so the voter may vote (fizikal_registration_verified_at).
*/
public function __invoke(Request $request, $id)
{
$electionId = Util::getCurrentElection();
$voter = Voter::where('election_id', $electionId)
->where('id', $id)
->firstOrFail();
if ((int) $voter->kehadiran !== 1) {
return response()->json([
'status' => 'failed',
'message' => 'Pengesahan pendaftaran fizikal hanya untuk pengundi Fizikal.',
], 422);
}
if ($voter->fizikal_registration_verified_at !== null) {
activity()
->performedOn($voter)
->withProperties([
'election_id' => $electionId,
'voter_id' => $voter->id,
'voter_name' => $voter->name,
'already_verified' => true,
'verified_at' => (string) $voter->fizikal_registration_verified_at,
'verified_by_admin_id' => $request->user() ? (int) $request->user()->id : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("verify fizikal registration: {$voter->name}");
return response()->json([
'status' => 'success',
'message' => 'Pendaftaran telah disahkan sebelum ini.',
'voter' => $voter,
]);
}
$voter->fizikal_registration_verified_at = now();
$voter->save();
activity()
->performedOn($voter)
->withProperties([
'election_id' => $electionId,
'voter_id' => $voter->id,
'voter_name' => $voter->name,
'already_verified' => false,
'verified_at' => (string) $voter->fizikal_registration_verified_at,
'verified_by_admin_id' => $request->user() ? (int) $request->user()->id : null,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
])
->log("verify fizikal registration: {$voter->name}");
return response()->json([
'status' => 'success',
'message' => 'Pendaftaran fizikal telah disahkan.',
'voter' => $voter->fresh(),
]);
}
}
+3
View File
@@ -63,6 +63,9 @@ class Kernel extends HttpKernel
'scope' => \Laravel\Passport\Http\Middleware\CheckForAnyScope::class,
'isvoted' => \App\Http\Middleware\IsVotedMiddleware::class,
'main_admin' => \App\Http\Middleware\MainAdminMiddleware::class,
'attendance_committee_role' => \App\Http\Middleware\AttendanceCommitteeRoleMiddleware::class,
'has_voted' => \App\Http\Middleware\HasVotedMiddleware::class,
'physical_gate_display' => \App\Http\Middleware\PhysicalGateDisplayKeyMiddleware::class,
'fizikal_registration_verified' => \App\Http\Middleware\FizikalRegistrationVerifiedMiddleware::class,
];
}
@@ -0,0 +1,57 @@
<?php
namespace App\Http\Middleware;
use Closure;
/**
* Role 3 = Jawatankuasa Kehadiran: only counter/kehadiran-related APIs.
* Other roles pass through unchanged.
*/
class AttendanceCommitteeRoleMiddleware
{
public function handle($request, Closure $next)
{
$user = $request->user();
if (!$user || (int) $user->role !== 3) {
return $next($request);
}
if ($this->attendanceCommitteeMayAccess($request)) {
return $next($request);
}
return response()->json([
'status' => 'failed',
'message' => 'Akses ditolak. Akaun ini hanya untuk halaman kehadiran.',
], 403);
}
/**
* @param \Illuminate\Http\Request $request
* @return bool
*/
private function attendanceCommitteeMayAccess($request)
{
if ($request->isMethod('get') && $request->is('api/v1/admin/information')) {
return true;
}
if ($request->isMethod('get') && $request->is('api/v1/admin/logout')) {
return true;
}
if ($request->isMethod('post') && $request->is('api/v1/admin/impersonate/leave')) {
return true;
}
if ($request->isMethod('get') && $request->is('api/v1/voter')) {
return true;
}
if ($request->isMethod('post') && $request->is('api/v1/voter/*/verify-fizikal-registration')) {
return true;
}
if ($request->is('api/v1/allowance/*')) {
return true;
}
return false;
}
}
+17 -2
View File
@@ -20,7 +20,7 @@ class ElectionMiddleware
if ($this->isElection($request)) {
return response()->json([
'status' => 'failed',
'message' => 'You can not add, update, or delete during the election.'
'message' => 'Anda tidak boleh menambah, mengemas kini, atau menghapus semasa sesi mengundi.'
]);
}
@@ -29,6 +29,21 @@ class ElectionMiddleware
private function isElection($request)
{
return !$request->isMethod('get') && Util::getElectionStatus() == 2 && !$request->is('api/v1/election/*');
return
!$request->isMethod('get')
&& Util::getElectionStatus() == 2
&& !$request->is('api/v1/election/*')
// Allow paperless allowance flows during election (cash counter needs this)
&& !$request->is('api/v1/allowance/*')
// Allow admin to update voter info during election (edit screen uses PUT /api/v1/voter/{id})
&& !($request->is('api/v1/voter/*') && $request->isMethod('put'))
// Allow admin to add voter during election (manual add uses POST /api/v1/voter)
&& !($request->is('api/v1/voter') && $request->isMethod('post'))
// Allow updating applicable voters list during election (add page uses POST /api/v1/voter/sync-applicable)
&& !($request->is('api/v1/voter/sync-applicable') && $request->isMethod('post'))
&& !$request->is('api/v1/voter/*/verify-fizikal-registration')
// Pentadbir utama: urus akaun (tambah/kemaskini/padam) semasa mengundi
&& !$request->is('api/v1/admin')
&& !$request->is('api/v1/admin/*');
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use App\Voter;
use Closure;
use Illuminate\Support\Facades\Auth;
class FizikalRegistrationVerifiedMiddleware
{
/**
* Block voting until fizikal attendance is confirmed by admin (kehadiran = 1 + verified_at set).
*/
public function handle($request, Closure $next)
{
$user = Auth::user();
if (!$user instanceof Voter) {
return response()->json([
'status' => 'failed',
'message' => 'Sesi tidak sah.',
], 401);
}
if (!$user->isFizikalRegistrationVerifiedForVoting()) {
return response()->json([
'status' => 'failed',
'message' => 'Pendaftaran fizikal anda belum disahkan oleh pentadbir. Sila tunggu pengesahan di kaunter.',
], 403);
}
return $next($request);
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Http\Middleware;
use Closure;
class PhysicalGateDisplayKeyMiddleware
{
/**
* Only clients that know the display key may read the current gate code.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
if (!config('physical_attendance_gate.enabled')) {
return response()->json([
'status' => 'failed',
'message' => 'Ciri kod kaunter fizikal tidak diaktifkan.',
], 404);
}
$expected = (string) config('physical_attendance_gate.display_key');
if ($expected === '') {
return response()->json([
'status' => 'failed',
'message' => 'Kod paparan kaunter belum dikonfigurasi.',
], 503);
}
$provided = (string) $request->header('X-Physical-Gate-Display-Key', '');
if ($provided === '' && $request->bearerToken()) {
$provided = (string) $request->bearerToken();
}
if ($provided === '' || !hash_equals($expected, $provided)) {
return response()->json([
'status' => 'failed',
'message' => 'Akses ditolak.',
], 403);
}
$service = app(\App\Services\PhysicalAttendanceGateCode::class);
if (!$service->isConfigured()) {
return response()->json([
'status' => 'failed',
'message' => 'PHYSICAL_ATTENDANCE_GATE_SECRET belum ditetapkan.',
], 503);
}
return $next($request);
}
}
+2 -1
View File
@@ -8,7 +8,6 @@ class Nominee extends Model
{
protected $table = 'nominee';
protected $fillable = [
'name',
'unit',
'no_anggota',
@@ -18,6 +17,8 @@ class Nominee extends Model
'experience',
'education',
'photo',
'umur',
'jawatan_sekarang',
];
public $timestamp = false;
@@ -0,0 +1,96 @@
<?php
namespace App\Services;
class PhysicalAttendanceGateCode
{
/** @var string */
protected $secret;
/** @var int */
protected $period;
public function __construct()
{
$this->secret = (string) config('physical_attendance_gate.secret');
$this->period = max(30, (int) config('physical_attendance_gate.period_seconds', 60));
}
public function isConfigured()
{
return $this->secret !== '';
}
/**
* Current code and countdown for the counter display.
*
* @param int $electionId
* @return array{code:string,seconds_remaining:int,period_seconds:int}
*/
public function currentPayload($electionId)
{
$slot = $this->currentSlot();
$code = $this->codeForSlot((int) $electionId, $slot);
$expiresAt = ($slot + 1) * $this->period;
$secondsRemaining = max(0, $expiresAt - time());
return [
'code' => $code,
'seconds_remaining' => $secondsRemaining,
'period_seconds' => $this->period,
];
}
/**
* Accept current, previous, or next slot to tolerate minor clock skew.
*
* @param int $electionId
* @param string $input
* @return bool
*/
public function validates($electionId, $input)
{
if (!$this->isConfigured()) {
return false;
}
$normalized = strtoupper(preg_replace('/\s+/', '', (string) $input));
if (strlen($normalized) < 4) {
return false;
}
$slot = $this->currentSlot();
foreach ([$slot - 1, $slot, $slot + 1] as $s) {
if ($s < 0) {
continue;
}
if (hash_equals($this->codeForSlot((int) $electionId, $s), $normalized)) {
return true;
}
}
return false;
}
protected function currentSlot()
{
return intdiv(time(), $this->period);
}
/**
* Deterministic code for one election and time slot.
*
* @param int $electionId
* @param int $slot
* @return string
*/
protected function codeForSlot($electionId, $slot)
{
$material = (string) $electionId.'|'.(string) $slot;
$hex = hash_hmac('sha256', $material, $this->secret);
return strtoupper(substr($hex, 0, 8));
}
}
+14 -1
View File
@@ -5,10 +5,11 @@ namespace App;
use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Passport\HasApiTokens;
use Lab404\Impersonate\Models\Impersonate;
class User extends Authenticatable
{
use HasApiTokens, Notifiable;
use HasApiTokens, Notifiable, Impersonate;
/**
* The attributes that are mass assignable.
@@ -27,4 +28,16 @@ class User extends Authenticatable
protected $hidden = [
'password', 'remember_token',
];
public function canImpersonate()
{
// Limit to main admin to avoid privilege escalation.
return (int) $this->id === 1;
}
public function canBeImpersonated()
{
// Do not allow impersonating main admin.
return (int) $this->id !== 1;
}
}
+17
View File
@@ -10,5 +10,22 @@ class Voter extends Authenticatable
use HasApiTokens;
protected $table = 'voter';
protected $fillable = ['name', 'no_kp', 'no_anggota','unit', 'election_id', 'alamat', 'telefon', 'saham', 'yuran', 'kehadiran','status_penyata','persetujuan','tarikh_sah','cadangan','pelaburan','tergempar','barangan','peribadi','berjamin_yuran','roadtax','pelbagai'];
protected $casts = [
'fizikal_registration_verified_at' => 'datetime',
];
/**
* Fizikal attendees must be counter-verified by admin; Maya does not use this gate.
*/
public function isFizikalRegistrationVerifiedForVoting(): bool
{
if ((int) $this->kehadiran !== 1) {
return true;
}
return $this->fizikal_registration_verified_at !== null;
}
}