36 Commits

Author SHA1 Message Date
root b5fd4a4ca1 Fix Laravel Pint formatting 2026-06-09 00:03:03 -04:00
root 8d4d610b82 add test batches 2026-06-08 23:45:55 -04:00
root c792b8be05 Fix Laravel Pint formatting 2026-06-08 23:30:22 -04:00
root 567dc24649 Fix Laravel Pint formatting 2026-06-08 23:00:38 -04:00
root bd743d57cf Fix Laravel Pint formatting 2026-06-08 22:46:20 -04:00
root 600cf8d876 Fix Pint style issues 2026-06-08 22:25:58 -04:00
root 60ecacb7f8 update tests 2026-06-08 22:06:30 -04:00
root 79024235ef add api tests 2026-06-08 02:08:04 -04:00
root 8ca24ef03f add test create 3000 users 2026-06-08 01:53:27 -04:00
root 68a5c9edca update the new school year model 2026-06-07 20:01:58 -04:00
root 6866aedf42 add school year model 2026-06-07 00:52:01 -04:00
root a192ed433d badge creation 2026-06-05 17:16:38 -04:00
root ad968eaff7 fix financial and certificates 2026-06-05 01:51:08 -04:00
root d28d11e2e5 fix attendance 2026-06-04 20:49:14 -04:00
root 6fa656bb6c fix teacher page 2026-06-04 19:26:24 -04:00
root 6408906f07 Pin fpdf composer dependency 2026-06-04 18:20:26 -04:00
root ae52b9c52e update pipeline tag 2026-06-04 18:04:32 -04:00
Administrator 6e3f2eae5d Update .gitlab-ci.yml file 2026-06-04 20:55:19 +00:00
root b8212d3304 add gitlab yml file 2026-06-04 16:46:23 -04:00
root 5e5fe3794a fix api security issues and update pages issue 2026-06-04 16:41:19 -04:00
root feb6be0610 fix exam and grading 2026-06-04 13:25:31 -04:00
root b4e6ac03c5 remove codeigniter 2026-06-04 02:41:08 -04:00
root 4e33882ac7 update api and add more features 2026-06-04 02:24:41 -04:00
root fa6c9519a0 add certificates 2026-05-18 00:07:30 -04:00
root df5266c5b5 add grading, attendnace management 2026-04-29 17:39:33 -04:00
root 8beeed883f update exam and attendance 2026-04-26 14:57:43 -04:00
root 5e8fa682b9 fix class progress admin side 2026-04-25 21:56:33 -04:00
root 5c544f93b9 add badge logic 2026-04-25 01:23:21 -04:00
root eafe4eb134 fix parent, teacher and admin pages 2026-04-25 00:00:23 -04:00
root 4cd98f1d30 add usercontroller fix 2026-04-24 12:52:30 -04:00
root 7216cb2885 fix teacher and parent routes 2026-04-24 02:12:01 -04:00
root 5128c74892 fix login issues 2026-04-23 15:30:50 -04:00
root ca4ba272fc update controllers logic 2026-04-23 00:04:35 -04:00
root 1977a513df fix student registration 2026-03-26 16:04:03 -04:00
root c582bfc242 update emergency contact student controller 2026-03-25 17:59:40 -04:00
root 33be0c9a0d add class progress and fix endpoints 2026-03-12 17:27:49 -04:00
1784 changed files with 113094 additions and 15189 deletions
+133
View File
@@ -0,0 +1,133 @@
APP_NAME=Alrahma_API
APP_ENV=production
APP_KEY=base64:CHANGE_ME_RUN_php_artisan_key_generate
APP_DEBUG=false
APP_TIMEZONE=America/New_York
APP_URL=https://api.alrahmaisgl.org
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
LOG_CHANNEL=stderr
LOG_LEVEL=info
LOG_DEPRECATIONS_CHANNEL=null
# ----------------------------
# DATABASE
# ----------------------------
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=CHANGE_ME
DB_USERNAME=CHANGE_ME
DB_PASSWORD=CHANGE_ME
# ----------------------------
# SESSION
# ----------------------------
SESSION_DRIVER=cookie
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=strict
# ----------------------------
# CACHE & QUEUE
# ----------------------------
CACHE_STORE=file
QUEUE_CONNECTION=sync
# ----------------------------
# REDIS
# ----------------------------
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
# ----------------------------
# MAIL
# ----------------------------
MAIL_MAILER=smtp
MAIL_PROFILE_DEFAULT=default
MAIL_DEFAULT_HOST=CHANGE_ME
MAIL_DEFAULT_PORT=587
MAIL_DEFAULT_USER=CHANGE_ME
MAIL_DEFAULT_PASS=CHANGE_ME
MAIL_DEFAULT_ENCRYPTION=tls
MAIL_DEFAULT_FROM_EMAIL="no-reply@alrahmaisgl.org"
MAIL_DEFAULT_FROM_NAME="Alrahma API"
MAIL_DEFAULT_REPLY_TO="no-reply@alrahmaisgl.org"
MAIL_DEFAULT_REPLY_TO_NAME="Alrahma API"
MAIL_COMMUNICATION_REPLY_TO="no-reply@alrahmaisgl.org"
MAIL_COMMUNICATION_REPLY_TO_NAME="School Communications"
MAIL_PAYMENT_REPLY_TO="no-reply@alrahmaisgl.org"
MAIL_PAYMENT_REPLY_TO_NAME="School Payments"
MAIL_SENDERS='{"general":{"name":"Alrahma API","email":"no-reply@alrahmaisgl.org"},"communication":{"name":"School Communications","email":"no-reply@alrahmaisgl.org"},"payment":{"name":"School Payments","email":"no-reply@alrahmaisgl.org"}}'
# Laravel mailer compatibility
MAIL_HOST="${MAIL_DEFAULT_HOST}"
MAIL_PORT="${MAIL_DEFAULT_PORT}"
MAIL_USERNAME="${MAIL_DEFAULT_USER}"
MAIL_PASSWORD="${MAIL_DEFAULT_PASS}"
MAIL_ENCRYPTION="${MAIL_DEFAULT_ENCRYPTION}"
MAIL_FROM_ADDRESS="${MAIL_DEFAULT_FROM_EMAIL}"
MAIL_FROM_NAME="${MAIL_DEFAULT_FROM_NAME}"
# Legacy fallback keys still read by some services
SMTP_HOST="${MAIL_DEFAULT_HOST}"
SMTP_USER="${MAIL_DEFAULT_USER}"
SMTP_PASS="${MAIL_DEFAULT_PASS}"
SMTP_PORT="${MAIL_DEFAULT_PORT}"
SMTP_ENCRYPTION="${MAIL_DEFAULT_ENCRYPTION}"
# ----------------------------
# FILESYSTEM
# ----------------------------
FILESYSTEM_DISK=local
# ----------------------------
# AWS STORAGE (optional)
# ----------------------------
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
# ----------------------------
# JWT
# ----------------------------
JWT_SECRET=CHANGE_ME_USE_STRONG_RANDOM_SECRET
JWT_ALGO=HS256
JWT_TTL=60
JWT_REFRESH_TTL=20160
JWT_BLACKLIST_ENABLED=true
JWT_BLACKLIST_GRACE_PERIOD=0
# ----------------------------
# DOCS
# ----------------------------
DOCS_CLIENT_URL=https://CHANGE_ME
DOCS_INDEX_TITLE=Alrahma API Documentation
DOCS_INDEX_DESCRIPTION=
# ----------------------------
# BADGE
# ----------------------------
BADGE_SCHOOL_NAME=Al Rahma Sunday School
BADGE_HEADER_SUBTITLE=
BADGE_MOTTO=
BADGE_MOTTO_FALLBACK=
BADGE_ROLE_LABEL=Student
BADGE_FOOTER_ADDRESS=5 Courthouse Lane, Chelmsford, MA 01824, USA
# ----------------------------
# FRONT-END
# ----------------------------
VITE_APP_NAME="${APP_NAME}"
+195
View File
@@ -0,0 +1,195 @@
# GitLab CI for Laravel 12
# Save this file as: .gitlab-ci.yml
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "push"'
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_TAG'
stages:
- validate
- test
- build
- security
variables:
APP_ENV: testing
APP_DEBUG: "false"
LOG_CHANNEL: stderr
CACHE_DRIVER: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
DB_CONNECTION: sqlite
DB_DATABASE: "$CI_PROJECT_DIR/database/database.sqlite"
COMPOSER_CACHE_DIR: "$CI_PROJECT_DIR/.composer-cache"
NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm-cache"
cache:
key:
files:
- composer.lock
- package-lock.json
paths:
- .composer-cache/
- .npm-cache/
- vendor/
- node_modules/
.php_laravel_job:
image: php:8.3-cli
before_script:
- apt-get update
- apt-get install -y --no-install-recommends git unzip curl libzip-dev libpng-dev libonig-dev libxml2-dev libsqlite3-dev
- docker-php-ext-install mbstring zip gd
- php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
- php composer-setup.php --install-dir=/usr/local/bin --filename=composer
- rm composer-setup.php
- composer install --prefer-dist --no-interaction --no-progress
- |
cat > .env <<EOF
APP_NAME=school_api
APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
LOG_CHANNEL=stderr
LOG_LEVEL=debug
DB_CONNECTION=sqlite
DB_DATABASE=${CI_PROJECT_DIR}/database/database.sqlite
CACHE_DRIVER=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
MAIL_MAILER=array
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
JWT_SECRET=ci_dummy_secret_not_for_production
EOF
- mkdir -p database storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cache reports
- touch database/database.sqlite
- php artisan key:generate --force
- php artisan config:clear
composer_validate:
extends: .php_laravel_job
stage: validate
script:
- mkdir -p reports
- bash -o pipefail -c 'composer validate --strict 2>&1 | tee reports/composer-validate.log'
- bash -o pipefail -c 'php -v 2>&1 | tee reports/php-version.log'
- bash -o pipefail -c 'php artisan --version 2>&1 | tee reports/artisan-version.log'
artifacts:
when: always
expire_in: 7 days
paths:
- reports/
- storage/logs/
laravel_pint:
extends: .php_laravel_job
stage: validate
script:
- mkdir -p reports
- bash -o pipefail -c 'vendor/bin/pint --test 2>&1 | tee reports/pint.log'
artifacts:
when: always
expire_in: 7 days
paths:
- reports/
- storage/logs/
phpunit:
extends: .php_laravel_job
stage: test
script:
- mkdir -p reports
- bash -o pipefail -c 'php artisan migrate --force 2>&1 | tee reports/migrate.log'
- bash -o pipefail -c 'php artisan test --testsuite=Feature --no-interaction --log-junit reports/phpunit-feature.xml 2>&1 | tee reports/phpunit-feature.log'
- bash -o pipefail -c 'php artisan test --testsuite=Unit --no-interaction --log-junit reports/phpunit-unit.xml 2>&1 | tee reports/phpunit-unit.log'
artifacts:
when: always
expire_in: 7 days
reports:
junit:
- reports/phpunit-feature.xml
- reports/phpunit-unit.xml
paths:
- reports/
- storage/logs/
build_frontend:
image: node:22-alpine
stage: build
before_script:
- npm ci
script:
- mkdir -p reports
- sh -c 'npm run build 2>&1 | tee reports/npm-build.log'
artifacts:
when: always
expire_in: 7 days
paths:
- public/build/
- reports/
composer_audit:
extends: .php_laravel_job
stage: security
script:
- mkdir -p reports
- bash -o pipefail -c 'composer audit --locked --no-interaction 2>&1 | tee reports/composer-audit.log'
artifacts:
when: always
expire_in: 7 days
paths:
- reports/
- storage/logs/
npm_audit:
image: node:22-alpine
stage: security
before_script:
- npm ci
script:
- mkdir -p reports
- sh -c 'npm audit --audit-level=high 2>&1 | tee reports/npm-audit.log'
artifacts:
when: always
expire_in: 7 days
paths:
- reports/
allow_failure: true
secret_detection_basic:
image: alpine:3.20
stage: security
before_script:
- apk add --no-cache git grep
script:
- mkdir -p reports
- |
echo "Scanning for obvious committed secrets..." | tee reports/secret-detection-basic.log
! grep -RInE "(APP_KEY=base64:|DB_PASSWORD=.+|JWT_SECRET=.+|MAIL_PASSWORD=.+|PAYPAL_(CLIENT|SECRET|MODE)|AKIA[0-9A-Z]{16}|-----BEGIN (RSA|OPENSSH|PRIVATE) KEY-----)" \
--exclude-dir=.git \
--exclude-dir=vendor \
--exclude-dir=node_modules \
--exclude=.env.example \
--exclude=.env.prod.example \
. 2>&1 | tee -a reports/secret-detection-basic.log
artifacts:
when: always
expire_in: 7 days
paths:
- reports/
allow_failure: false
# Optional GitLab-native security scanners.
# These run only if your GitLab tier/project supports the templates.
include:
- template: Jobs/SAST.gitlab-ci.yml
- template: Jobs/Secret-Detection.gitlab-ci.yml
- template: Jobs/Dependency-Scanning.gitlab-ci.yml
+6 -3
View File
@@ -2,8 +2,8 @@ FROM composer:2 AS vendor
WORKDIR /app WORKDIR /app
COPY composer.json composer.lock ./ COPY composer.json composer.lock ./
COPY app/Helpers/ci_helpers.php app/Helpers/ COPY app/Helpers/app_helpers.php app/Helpers/
RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts --ignore-platform-req=ext-gd
COPY . . COPY . .
RUN composer dump-autoload --optimize \ RUN composer dump-autoload --optimize \
&& php artisan package:discover --ansi && php artisan package:discover --ansi
@@ -30,7 +30,10 @@ RUN apt-get update \
libonig-dev \ libonig-dev \
libxml2-dev \ libxml2-dev \
libzip-dev \ libzip-dev \
&& docker-php-ext-install pdo_mysql mbstring xml zip \ libjpeg-dev \
libfreetype6-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install pdo_mysql mbstring xml zip gd \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /var/www/html WORKDIR /var/www/html
+46
View File
@@ -0,0 +1,46 @@
# Finance Plan Implementation Notes
This implementation keeps the existing financial endpoints and PayPal-related code intact, then adds the missing additive finance lifecycle pieces from the plan.
## Added
- Finance lifecycle migration: follow-up notes, balance carryforwards, installment plans/installments, payment-installment allocations, notification logs/preferences, receipt sequences, and carryforward payment allocations.
- Parent payment follow-up reporting with CSV export and write-only metadata actions for notes, contacted status, promise-to-pay, and resolution.
- Prior-year balance carryforward preview, draft creation, approval, posting to a new-year invoice, waiver, adjustment, report, and CSV export.
- Installment plan creation, activation, cancellation, overdue/due reporting, and payment allocation.
- Finance notification logging endpoints for receipts, statements, overdue reminders, installment reminders, and notification log search.
- Event charge lifecycle API endpoints for create/list/show/update/delete, approve, void, and attach-to-invoice.
- Finance config flags for legacy ledger mode, carryforward behavior, payment allocation policy, and tuition calculator version.
## Safety posture
- Existing finance routes were not removed.
- Existing PayPal models/controllers/routes were not removed.
- Existing invoice/payment math remains the source for read models.
- Carryforward posting creates a separate new-year invoice instead of mutating prior-year invoices.
- Follow-up notes do not modify invoice balances.
- Installment allocation writes to additive allocation tables and installment balances only.
- Notifications currently log database events instead of sending real external email/SMS, because inboxes are not test benches, despite humanity's tireless effort to prove otherwise.
## Key files changed or added
- `database/migrations/2026_06_04_230000_add_finance_lifecycle_tables.php`
- `config/finance.php`
- `app/Services/Finance/ParentPaymentFollowUpService.php`
- `app/Services/Finance/PriorYearBalanceCarryforwardService.php`
- `app/Services/Finance/InstallmentPlanService.php`
- `app/Services/Finance/FinanceNotificationLogService.php`
- `app/Http/Controllers/Api/Finance/BalanceCarryforwardController.php`
- `app/Http/Controllers/Api/Finance/InstallmentPlanController.php`
- `app/Http/Controllers/Api/Finance/FinanceNotificationController.php`
- `app/Http/Controllers/Api/Finance/EventChargeController.php`
- `app/Http/Controllers/Api/Finance/FinancialController.php`
- `routes/api.php`
- New finance request classes under `app/Http/Requests/Finance/`
- New finance lifecycle models under `app/Models/`
## Not fully implemented by design
- Centralized ledger replacement is still behind the future phase. That is intentional. Replacing accounting math without dry-runs is how systems become folklore.
- External mail/SMS sending is not activated. The implementation logs notification intent first.
- Parent-facing UI pages are not included because this package is the Laravel API.
+90
View File
@@ -0,0 +1,90 @@
# Strong Grading Implementation Notes
## Safety rule
This patch is additive and backward-compatible. Existing historical semester scores remain displayed from stored `semester_scores` values and are labeled as legacy by metadata. The new strong-grading machinery is present, but strong-mode finalization only activates when `strong_grading_enabled` is enabled through configuration.
## What changed
### Legacy display protection
`semester_scores` now supports:
- `calculation_mode`
- `calculation_policy_version`
- `snapshot_id`
Existing rows are backfilled as:
- `calculation_mode = legacy`
- `calculation_policy_version = legacy_v1`
No historical score is recalculated by this migration.
### Score safety columns
The score tables now support:
- `max_points`
- `status`
- `excused_reason`
- `locked_at`
- `locked_by`
Existing numeric score rows are marked `scored`. Existing blank rows are marked `pending`, not `missing`, so old data is not punished by the new policy.
### Validation
A central `ScoreValueValidator` rejects impossible scores before storage. The default max remains `100` to preserve old behavior until item-level max points are configured.
### Attendance grace
Attendance now names the one-absence grace policy explicitly instead of hiding it in `(total_days + 1 - absences) / total_days`. The result is intentionally equivalent.
### Strong-mode lock validation
`GradingLockService` now checks the policy mode before locking. In legacy mode, behavior remains compatible. In strong mode, pending scores and invalid score ranges block locking.
### Snapshot infrastructure
`semester_score_snapshots` and `SemesterScoreSnapshotService` were added. Strong-mode finalized scores can store calculation inputs and outputs for auditability.
### Display resolver
`GradeCalculationDisplayResolver` resolves whether a row should be shown as `Legacy Calculation` or `Strong Calculation`.
## Configuration gates
Strong mode is off by default.
Suggested configuration values:
```text
strong_grading_enabled = false
strong_grading_class_sections = *
```
When enabled:
```text
strong_grading_enabled = true
strong_grading_class_sections = 12,15,20
```
or all sections:
```text
strong_grading_class_sections = *
```
## What is intentionally not done yet
This patch does not globally replace the legacy PTAP formula. That would change grade math. The legacy formula remains the default until strong grading is intentionally activated for a class section.
This patch does not silently recalculate historical records. That would be reckless, so naturally we avoided it.
## Verification
PHP syntax checks passed for the modified and new PHP files.
PHPUnit could not run in this sandbox because the PHP runtime is missing required extensions: `dom`, `mbstring`, `xml`, and `xmlwriter`.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
-13
View File
@@ -1,13 +0,0 @@
<?php
namespace Config;
use App\Models\CiDatabase;
class Database
{
public static function connect(): CiDatabase
{
return CiDatabase::instance();
}
}
-24
View File
@@ -1,24 +0,0 @@
<?php
namespace Config;
use App\Services\EmailService;
use App\Services\Scores\SemesterScoreService;
class Services
{
public static function email(): EmailService
{
return new EmailService();
}
public static function emailService(): EmailService
{
return static::email();
}
public static function semesterScoreService(): SemesterScoreService
{
return new SemesterScoreService();
}
}
-10
View File
@@ -1,10 +0,0 @@
<?php
namespace App\Config;
class SessionTimeout
{
public const TIMEOUT_DURATION = 1200; // 20 minutes
public const WARNING_THRESHOLD = 900; // 15 minutes
public const CHECK_INTERVAL = 60; // 1 minute
}
-33
View File
@@ -1,33 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Progress Report Status Labels
|--------------------------------------------------------------------------
*/
'status_options' => [
// Keep these aligned with your existing values in DB
'draft' => 'Draft',
'submitted' => 'Submitted',
'reviewed' => 'Reviewed',
'approved' => 'Approved',
'rejected' => 'Rejected',
],
/*
|--------------------------------------------------------------------------
| Subject Sections Mapping
|--------------------------------------------------------------------------
| Replace with your exact CI ClassProgressController::SUBJECT_SECTIONS values
|--------------------------------------------------------------------------
*/
'subject_sections' => [
// Example placeholders only:
// 'quran' => ['Memorization', 'Reading', 'Tajweed'],
// 'islamic_studies' => ['Aqeedah', 'Fiqh', 'Seerah'],
// 'arabic' => ['Reading', 'Writing', 'Vocabulary'],
],
];
@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
class AttendanceAutoPublishCommand extends Command class AttendanceAutoPublishCommand extends Command
{ {
protected $signature = 'attendance:auto-publish'; protected $signature = 'attendance:auto-publish';
protected $description = 'Auto-publish attendance days per "second Sunday backward" rule.'; protected $description = 'Auto-publish attendance days per "second Sunday backward" rule.';
public function handle(AttendanceAutoPublishJobService $service): int public function handle(AttendanceAutoPublishJobService $service): int
@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
class CheckMissedPaymentsCommand extends Command class CheckMissedPaymentsCommand extends Command
{ {
protected $signature = 'payments:check-missed'; protected $signature = 'payments:check-missed';
protected $description = 'Checks for users who missed payments and sends reminders.'; protected $description = 'Checks for users who missed payments and sends reminders.';
public function handle(PaymentMissedCheckService $service): int public function handle(PaymentMissedCheckService $service): int
@@ -15,6 +16,7 @@ class CheckMissedPaymentsCommand extends Command
$users = $service->findUsersWithMissedPayments(); $users = $service->findUsersWithMissedPayments();
if (empty($users)) { if (empty($users)) {
$this->info('No missed payments found.'); $this->info('No missed payments found.');
return self::SUCCESS; return self::SUCCESS;
} }
@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
class CleanupExpiredNotificationsCommand extends Command class CleanupExpiredNotificationsCommand extends Command
{ {
protected $signature = 'notifications:cleanup'; protected $signature = 'notifications:cleanup';
protected $description = 'Deletes expired notifications from the database.'; protected $description = 'Deletes expired notifications from the database.';
public function handle(NotificationCleanupService $service): int public function handle(NotificationCleanupService $service): int
@@ -16,6 +17,7 @@ class CleanupExpiredNotificationsCommand extends Command
if ($count <= 0) { if ($count <= 0) {
$this->info('No expired notifications found to soft delete.'); $this->info('No expired notifications found to soft delete.');
return self::SUCCESS; return self::SUCCESS;
} }
@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
class CleanupPasswordResetsCommand extends Command class CleanupPasswordResetsCommand extends Command
{ {
protected $signature = 'cleanup:password-resets {--days=30}'; protected $signature = 'cleanup:password-resets {--days=30}';
protected $description = 'Delete password reset requests older than N days.'; protected $description = 'Delete password reset requests older than N days.';
public function handle(PasswordResetCleanupService $service): int public function handle(PasswordResetCleanupService $service): int
+7 -4
View File
@@ -9,6 +9,7 @@ use Illuminate\Console\Command;
class ConfigUpdateCommand extends Command class ConfigUpdateCommand extends Command
{ {
protected $signature = 'config:update {task?} {--task=} {--dry} {--force} {--tz=}'; protected $signature = 'config:update {task?} {--task=} {--dry} {--force} {--tz=}';
protected $description = 'Run a configuration update task (weekly cron).'; protected $description = 'Run a configuration update task (weekly cron).';
public function handle(ConfigUpdateService $service): int public function handle(ConfigUpdateService $service): int
@@ -19,18 +20,20 @@ class ConfigUpdateCommand extends Command
$tzName = (string) ($this->option('tz') ?: (config('School')->attendance['timezone'] ?? 'UTC')); $tzName = (string) ($this->option('tz') ?: (config('School')->attendance['timezone'] ?? 'UTC'));
$tz = new DateTimeZone($tzName); $tz = new DateTimeZone($tzName);
if ($task === '' || !in_array($task, $service->availableTasks(), true)) { if ($task === '' || ! in_array($task, $service->availableTasks(), true)) {
$this->error('Invalid or missing --task. Available: ' . implode(', ', $service->availableTasks())); $this->error('Invalid or missing --task. Available: '.implode(', ', $service->availableTasks()));
return self::FAILURE; return self::FAILURE;
} }
$result = $service->runTask($task, $dry, $force, $tz); $result = $service->runTask($task, $dry, $force, $tz);
if (!$result['ok']) { if (! $result['ok']) {
$this->error($result['message'] ?? "Task '{$task}' failed."); $this->error($result['message'] ?? "Task '{$task}' failed.");
return self::FAILURE; return self::FAILURE;
} }
$this->info("Task '{$task}' finished successfully." . ($dry ? ' [DRY RUN]' : '')); $this->info("Task '{$task}' finished successfully.".($dry ? ' [DRY RUN]' : ''));
return self::SUCCESS; return self::SUCCESS;
} }
@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
class DeleteInactiveUsersCommand extends Command class DeleteInactiveUsersCommand extends Command
{ {
protected $signature = 'users:delete-inactive-users {--minutes=15}'; protected $signature = 'users:delete-inactive-users {--minutes=15}';
protected $description = 'Delete users that are inactive and created more than N minutes ago.'; protected $description = 'Delete users that are inactive and created more than N minutes ago.';
public function handle(InactiveUserCleanupService $service): int public function handle(InactiveUserCleanupService $service): int
@@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Event;
class DeleteUnverifiedUsersCommand extends Command class DeleteUnverifiedUsersCommand extends Command
{ {
protected $signature = 'users:delete-unverified {--timeout=86400}'; protected $signature = 'users:delete-unverified {--timeout=86400}';
protected $description = 'Delete unverified users older than the configured timeout (seconds).'; protected $description = 'Delete unverified users older than the configured timeout (seconds).';
public function handle(): int public function handle(): int
@@ -8,12 +8,13 @@ use Illuminate\Console\Command;
class RecalculateAttendanceCommand extends Command class RecalculateAttendanceCommand extends Command
{ {
protected $signature = 'attendance:recalculate-summary'; protected $signature = 'attendance:recalculate-summary';
protected $description = 'Recalculates the attendance summary records from raw attendance data.'; protected $description = 'Recalculates the attendance summary records from raw attendance data.';
public function handle(AttendanceSummaryRebuildService $service): int public function handle(AttendanceSummaryRebuildService $service): int
{ {
$result = $service->rebuild(); $result = $service->rebuild();
$this->info('Attendance summary recalculation finished. Inserted: ' . $result['inserted']); $this->info('Attendance summary recalculation finished. Inserted: '.$result['inserted']);
return self::SUCCESS; return self::SUCCESS;
} }
@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
class SendAbsenteesSummaryCommand extends Command class SendAbsenteesSummaryCommand extends Command
{ {
protected $signature = 'attendance:absentees-summary'; protected $signature = 'attendance:absentees-summary';
protected $description = 'Sends daily attendance summaries for absences to parents.'; protected $description = 'Sends daily attendance summaries for absences to parents.';
public function handle(AttendanceDailySummaryService $service): int public function handle(AttendanceDailySummaryService $service): int
@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
class SendLatesSummaryCommand extends Command class SendLatesSummaryCommand extends Command
{ {
protected $signature = 'attendance:lates-summary'; protected $signature = 'attendance:lates-summary';
protected $description = 'Sends daily attendance summaries for lates to parents.'; protected $description = 'Sends daily attendance summaries for lates to parents.';
public function handle(AttendanceDailySummaryService $service): int public function handle(AttendanceDailySummaryService $service): int
@@ -9,6 +9,7 @@ use Illuminate\Console\Command;
class SendMonthlyPaymentNotificationsCommand extends Command class SendMonthlyPaymentNotificationsCommand extends Command
{ {
protected $signature = 'payments:monthly-reminder {--force} {--email=} {--type=}'; protected $signature = 'payments:monthly-reminder {--force} {--email=} {--type=}';
protected $description = 'Send monthly payment reminders on the first Saturday of every month.'; protected $description = 'Send monthly payment reminders on the first Saturday of every month.';
public function handle(PaymentNotificationService $service): int public function handle(PaymentNotificationService $service): int
@@ -19,8 +20,9 @@ class SendMonthlyPaymentNotificationsCommand extends Command
if ($email !== '') { if ($email !== '') {
$user = User::query()->where('email', $email)->first(); $user = User::query()->where('email', $email)->first();
if (!$user) { if (! $user) {
$this->error('Invalid --email provided.'); $this->error('Invalid --email provided.');
return self::FAILURE; return self::FAILURE;
} }
@@ -30,12 +32,14 @@ class SendMonthlyPaymentNotificationsCommand extends Command
'force' => true, 'force' => true,
]); ]);
$this->info('Test reminder sent for ' . $email . '.'); $this->info('Test reminder sent for '.$email.'.');
return $result['failed'] > 0 ? self::FAILURE : self::SUCCESS; return $result['failed'] > 0 ? self::FAILURE : self::SUCCESS;
} }
if (!$force && !$this->isFirstSaturday(now())) { if (! $force && ! $this->isFirstSaturday(now())) {
$this->info('Not the first Saturday of the month. Use --force to override.'); $this->info('Not the first Saturday of the month. Use --force to override.');
return self::SUCCESS; return self::SUCCESS;
} }
@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
class SendTestPaymentNotificationCommand extends Command class SendTestPaymentNotificationCommand extends Command
{ {
protected $signature = 'payments:send-test {--email=} {--type=no_payment}'; protected $signature = 'payments:send-test {--email=} {--type=no_payment}';
protected $description = 'Send a single test non-payment or installment reminder to a specific email.'; protected $description = 'Send a single test non-payment or installment reminder to a specific email.';
public function handle(PaymentTestNotificationService $service): int public function handle(PaymentTestNotificationService $service): int
@@ -15,18 +16,20 @@ class SendTestPaymentNotificationCommand extends Command
$email = (string) $this->option('email'); $email = (string) $this->option('email');
$type = (string) $this->option('type'); $type = (string) $this->option('type');
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->error('Usage: php artisan payments:send-test --email=addr@example.com [--type=no_payment|installment]'); $this->error('Usage: php artisan payments:send-test --email=addr@example.com [--type=no_payment|installment]');
return self::FAILURE; return self::FAILURE;
} }
$result = $service->send($email, $type); $result = $service->send($email, $type);
if (!$result['ok']) { if (! $result['ok']) {
$this->error('Failed to send test reminder to ' . $email . '.'); $this->error('Failed to send test reminder to '.$email.'.');
return self::FAILURE; return self::FAILURE;
} }
$this->info('Sent test reminder to ' . $email . '.'); $this->info('Sent test reminder to '.$email.'.');
return self::SUCCESS; return self::SUCCESS;
} }
@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
class SyncPaypalPaymentsCommand extends Command class SyncPaypalPaymentsCommand extends Command
{ {
protected $signature = 'payments:sync-paypal {--dry-run} {--report-only}'; protected $signature = 'payments:sync-paypal {--dry-run} {--report-only}';
protected $description = 'Sync PayPal payments to internal payments table from paypal_payments.'; protected $description = 'Sync PayPal payments to internal payments table from paypal_payments.';
public function handle(PaypalPaymentSyncService $service): int public function handle(PaypalPaymentSyncService $service): int
@@ -18,7 +19,7 @@ class SyncPaypalPaymentsCommand extends Command
$result = $service->sync($dryRun, $reportOnly); $result = $service->sync($dryRun, $reportOnly);
$this->info(sprintf('[%s] %d PayPal payments processed.', $result['mode'], $result['processed'])); $this->info(sprintf('[%s] %d PayPal payments processed.', $result['mode'], $result['processed']));
if (!empty($result['failed'])) { if (! empty($result['failed'])) {
$this->error(sprintf('[%s] Failed transactions: %s', $result['mode'], implode(', ', $result['failed']))); $this->error(sprintf('[%s] Failed transactions: %s', $result['mode'], implode(', ', $result['failed'])));
} }
+1 -3
View File
@@ -4,7 +4,5 @@ namespace App\Events;
class DeleteUnverifiedUser class DeleteUnverifiedUser
{ {
public function __construct(public int $userId) public function __construct(public int $userId) {}
{
}
} }
+1 -3
View File
@@ -10,7 +10,5 @@ class WhatsappInvitesSend
use Dispatchable; use Dispatchable;
use SerializesModels; use SerializesModels;
public function __construct(public array $payload) public function __construct(public array $payload) {}
{
}
} }
+18 -48
View File
@@ -2,65 +2,35 @@
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
if (!function_exists('log_message')) { if (! function_exists('utc_now')) {
function log_message(string $level, string $message, array $context = []): void
{
$level = strtolower($level);
$logger = Log::channel(config('logging.default'));
$context = $context;
if (!empty($context)) {
$message = strtr($message, array_combine(
array_map(fn($k) => "{" . $k . "}", array_keys($context)),
array_map('strval', $context)
));
}
$logger->{$level}($message);
}
}
if (!function_exists('esc')) {
function esc($value): string
{
return e($value);
}
}
if (!function_exists('utc_now')) {
function utc_now(): string function utc_now(): string
{ {
return CarbonImmutable::now('UTC')->toDateTimeString(); return CarbonImmutable::now('UTC')->toDateTimeString();
} }
} }
if (!function_exists('local_date')) { if (! function_exists('local_date')) {
function local_date(string $utcDateTime, string $format = 'Y-m-d H:i:s'): string function local_date(string $utcDateTime, string $format = 'Y-m-d H:i:s'): string
{ {
try { try {
$carbon = CarbonImmutable::parse($utcDateTime)->setTimezone(config('app.timezone')); $carbon = CarbonImmutable::parse($utcDateTime)->setTimezone(config('app.timezone'));
return $carbon->format($format); return $carbon->format($format);
} catch (\Throwable $e) { } catch (Throwable $e) {
return CarbonImmutable::parse($utcDateTime)->format($format); return CarbonImmutable::parse($utcDateTime)->format($format);
} }
} }
} }
if (!function_exists('user_timezone')) { if (! function_exists('user_timezone')) {
function user_timezone(): string function user_timezone(): string
{ {
return (string) (config('School')->attendance['timezone'] ?? config('app.timezone', 'America/New_York')); return (string) (config('School')->attendance['timezone'] ?? config('app.timezone', 'America/New_York'));
} }
} }
if (!function_exists('model')) { if (! function_exists('pbkdf2_hash')) {
function model(string $class)
{
return app()->make($class);
}
}
if (!function_exists('pbkdf2_hash')) {
function pbkdf2_hash(string $password, string $algo = 'sha256', int $iterations = 100000, int $length = 64): string function pbkdf2_hash(string $password, string $algo = 'sha256', int $iterations = 100000, int $length = 64): string
{ {
$salt = random_bytes(16); $salt = random_bytes(16);
@@ -72,7 +42,7 @@ if (!function_exists('pbkdf2_hash')) {
} }
} }
if (!function_exists('pbkdf2_verify')) { if (! function_exists('pbkdf2_verify')) {
function pbkdf2_verify(string $password, string $storedHash): bool function pbkdf2_verify(string $password, string $storedHash): bool
{ {
$delim = strpos($storedHash, '$') !== false ? '$' : ':'; $delim = strpos($storedHash, '$') !== false ? '$' : ':';
@@ -93,25 +63,25 @@ if (!function_exists('pbkdf2_verify')) {
} }
$calc = hash_pbkdf2($algo, $password, $salt, (int) $iterations, strlen($derived), true); $calc = hash_pbkdf2($algo, $password, $salt, (int) $iterations, strlen($derived), true);
return hash_equals($derived, $calc); return hash_equals($derived, $calc);
} }
} }
if (!function_exists('legacy_password_verify')) { if (! function_exists('verify_stored_password')) {
function legacy_password_verify(string $password, ?string $storedHash): bool function verify_stored_password(string $password, ?string $storedHash): bool
{ {
return ci_password_verify($password, $storedHash); if (! $storedHash) {
}
}
if (!function_exists('ci_password_verify')) {
function ci_password_verify(string $password, ?string $storedHash): bool
{
if (!$storedHash) {
return false; return false;
} }
if (strpos($storedHash, ':') !== false || strpos($storedHash, '$') !== false) { $hashInfo = password_get_info($storedHash);
if (($hashInfo['algo'] ?? null) !== null) {
return Hash::check($password, $storedHash);
}
$parts = preg_split('/[:$]/', $storedHash);
if (is_array($parts) && count($parts) === 4) {
return pbkdf2_verify($password, $storedHash); return pbkdf2_verify($password, $storedHash);
} }
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -13,8 +13,7 @@ class AdministratorAbsenceController extends Controller
{ {
public function __construct( public function __construct(
protected AdministratorAbsenceService $service protected AdministratorAbsenceService $service
) { ) {}
}
public function index(): JsonResponse public function index(): JsonResponse
{ {
@@ -34,7 +33,7 @@ class AdministratorAbsenceController extends Controller
} }
$payload = $request->all(); $payload = $request->all();
if (!array_key_exists('dates', $payload) || !array_key_exists('reason', $payload)) { if (! array_key_exists('dates', $payload) || ! array_key_exists('reason', $payload)) {
$json = json_decode($request->getContent() ?: '', true); $json = json_decode($request->getContent() ?: '', true);
if (is_array($json)) { if (is_array($json)) {
$payload = array_merge($payload, $json); $payload = array_merge($payload, $json);
@@ -11,8 +11,7 @@ class AdministratorDashboardController extends Controller
{ {
public function __construct( public function __construct(
protected AdministratorDashboardService $service protected AdministratorDashboardService $service
) { ) {}
}
public function metrics(): JsonResponse public function metrics(): JsonResponse
{ {
@@ -25,4 +24,4 @@ class AdministratorDashboardController extends Controller
$this->service->userSearch((string) $request->query('query', '')) $this->service->userSearch((string) $request->query('query', ''))
); );
} }
} }
@@ -14,8 +14,7 @@ class AdministratorEnrollmentController extends Controller
{ {
public function __construct( public function __construct(
protected AdministratorEnrollmentService $service protected AdministratorEnrollmentService $service
) { ) {}
}
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
@@ -39,13 +38,13 @@ class AdministratorEnrollmentController extends Controller
} }
$payload = $request->all(); $payload = $request->all();
if (!array_key_exists('enrollment_status', $payload)) { if (! array_key_exists('enrollment_status', $payload)) {
$json = json_decode($request->getContent() ?: '', true); $json = json_decode($request->getContent() ?: '', true);
if (is_array($json) && array_key_exists('enrollment_status', $json)) { if (is_array($json) && array_key_exists('enrollment_status', $json)) {
$payload['enrollment_status'] = $json['enrollment_status']; $payload['enrollment_status'] = $json['enrollment_status'];
} }
} }
if (!array_key_exists('enrollment_status', $payload)) { if (! array_key_exists('enrollment_status', $payload)) {
$payload['enrollment_status'] = $request->query('enrollment_status'); $payload['enrollment_status'] = $request->query('enrollment_status');
} }
@@ -12,8 +12,7 @@ class AdministratorNotificationController extends Controller
{ {
public function __construct( public function __construct(
protected AdministratorNotificationService $service protected AdministratorNotificationService $service
) { ) {}
}
public function alerts(): JsonResponse public function alerts(): JsonResponse
{ {
@@ -23,7 +22,7 @@ class AdministratorNotificationController extends Controller
public function saveAlerts(Request $request): JsonResponse public function saveAlerts(Request $request): JsonResponse
{ {
$payload = $request->all(); $payload = $request->all();
if (!array_key_exists('subjects', $payload)) { if (! array_key_exists('subjects', $payload)) {
$json = json_decode($request->getContent() ?: '', true); $json = json_decode($request->getContent() ?: '', true);
if (is_array($json)) { if (is_array($json)) {
$payload = array_merge($payload, $json); $payload = array_merge($payload, $json);
@@ -60,7 +59,7 @@ class AdministratorNotificationController extends Controller
public function savePrintRecipients(Request $request): JsonResponse public function savePrintRecipients(Request $request): JsonResponse
{ {
$payload = $request->all(); $payload = $request->all();
if (!array_key_exists('notify', $payload)) { if (! array_key_exists('notify', $payload)) {
$json = json_decode($request->getContent() ?: '', true); $json = json_decode($request->getContent() ?: '', true);
if (is_array($json)) { if (is_array($json)) {
$payload = array_merge($payload, $json); $payload = array_merge($payload, $json);
@@ -0,0 +1,456 @@
<?php
namespace App\Http\Controllers\Api\Administrator;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Promotions\AdminListPromotionsRequest;
use App\Http\Requests\Promotions\EvaluateEligibilityRequest;
use App\Http\Requests\Promotions\ParentEnrollmentStepRequest;
use App\Http\Requests\Promotions\SendReminderRequest;
use App\Http\Requests\Promotions\SetEnrollmentDeadlineRequest;
use App\Http\Requests\Promotions\UpdatePromotionStatusRequest;
use App\Http\Requests\Promotions\UpsertLevelProgressionRequest;
use App\Http\Resources\Promotions\LevelProgressionResource;
use App\Http\Resources\Promotions\PromotionAuditLogResource;
use App\Http\Resources\Promotions\PromotionReminderResource;
use App\Http\Resources\Promotions\StudentPromotionResource;
use App\Models\PromotionAuditLog;
use App\Models\PromotionReminderLog;
use App\Models\SectionPlacementBatch;
use App\Models\StudentPromotionRecord;
use App\Services\Promotions\LevelProgressionService;
use App\Services\Promotions\Placement\SectionPlacementPreviewService;
use App\Services\Promotions\PromotionAuditService;
use App\Services\Promotions\PromotionEligibilityService;
use App\Services\Promotions\PromotionEnrollmentService;
use App\Services\Promotions\PromotionQueryService;
use App\Services\Promotions\PromotionReminderService;
use App\Services\Promotions\PromotionStatusService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
/**
* Admin / registrar API for managing the promotion + parent enrollment
* lifecycle (plan sections 13, 16, 17).
*/
class AdministratorPromotionController extends BaseApiController
{
public function __construct(
private PromotionQueryService $query,
private PromotionEligibilityService $eligibility,
private PromotionStatusService $statusService,
private PromotionEnrollmentService $enrollmentService,
private PromotionReminderService $reminders,
private PromotionAuditService $audit,
private LevelProgressionService $progression,
private SectionPlacementPreviewService $sectionPlacement
) {
parent::__construct();
}
public function index(AdminListPromotionsRequest $request): JsonResponse
{
$result = $this->query->list($request->filters());
return response()->json([
'ok' => true,
'data' => StudentPromotionResource::collection($result['data']),
'pagination' => [
'page' => $result['page'],
'per_page' => $result['per_page'],
'total' => $result['total'],
'total_pages' => $result['total_pages'],
],
'counts_by_status' => $result['counts_by_status'],
]);
}
public function show(int $promotionId): JsonResponse
{
$record = $this->findOrFail($promotionId);
if ($record instanceof JsonResponse) {
return $record;
}
return response()->json([
'ok' => true,
'data' => new StudentPromotionResource($this->query->detail($record)),
]);
}
public function summary(AdminListPromotionsRequest $request): JsonResponse
{
$counts = $this->query->countsByStatus($request->filters());
return response()->json([
'ok' => true,
'counts_by_status' => $counts,
'total' => array_sum($counts),
]);
}
public function evaluate(EvaluateEligibilityRequest $request): JsonResponse
{
$payload = $request->validated();
$userId = $this->getCurrentUserId();
$scope = $payload['scope'] ?? 'school_year';
try {
$result = match ($scope) {
'student' => [
'scope' => 'student',
'record' => optional(
$this->eligibility->evaluateStudent(
(int) $payload['student_id'],
$payload['current_school_year'] ?? null,
$userId
)
)?->toArray(),
],
'class_section' => array_merge(
['scope' => 'class_section'],
$this->eligibility->evaluateClassSection(
(int) $payload['class_section_id'],
$payload['current_school_year'] ?? null,
$userId
)
),
default => array_merge(
['scope' => 'school_year'],
$this->eligibility->evaluateSchoolYear(
$payload['current_school_year'] ?? null,
$userId
)
),
};
} catch (\Throwable $e) {
Log::error('Promotion eligibility evaluation failed', ['exception' => $e]);
return response()->json([
'ok' => false,
'message' => 'Failed to evaluate promotion eligibility.',
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
return response()->json([
'ok' => true,
'result' => $result,
]);
}
public function createPlacementPreview(Request $request): JsonResponse
{
$payload = $request->validate([
'from_school_year' => ['required', 'string', 'max:25'],
'to_school_year' => ['required', 'string', 'max:25'],
'from_grade_level_id' => ['nullable', 'integer', 'min:1'],
'to_grade_level_id' => ['required', 'integer', 'min:1'],
]);
try {
$batch = $this->sectionPlacement->createDraft(
$payload['from_school_year'],
$payload['to_school_year'],
$payload['from_grade_level_id'] ?? null,
$payload['to_grade_level_id'] ?? null,
$this->getCurrentUserId()
);
} catch (\Throwable $e) {
Log::error('Section placement preview generation failed', ['exception' => $e]);
return response()->json([
'ok' => false,
'message' => $e->getMessage(),
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
return response()->json([
'ok' => true,
'data' => $this->sectionPlacement->previewPayload($batch),
]);
}
public function showPlacementBatch(int $batchId): JsonResponse
{
$batch = SectionPlacementBatch::query()->find($batchId);
if (! $batch) {
return response()->json(['ok' => false, 'message' => 'Placement batch not found.'], Response::HTTP_NOT_FOUND);
}
return response()->json([
'ok' => true,
'data' => $this->sectionPlacement->previewPayload($batch),
]);
}
public function finalizePlacementBatch(int $batchId): JsonResponse
{
try {
$batch = $this->sectionPlacement->finalize($batchId, $this->getCurrentUserId());
} catch (\Throwable $e) {
Log::error('Section placement finalization failed', ['batch_id' => $batchId, 'exception' => $e]);
return response()->json([
'ok' => false,
'message' => $e->getMessage(),
], Response::HTTP_CONFLICT);
}
return response()->json([
'ok' => true,
'data' => $this->sectionPlacement->previewPayload($batch),
]);
}
public function updateStatus(UpdatePromotionStatusRequest $request, int $promotionId): JsonResponse
{
$record = $this->findOrFail($promotionId);
if ($record instanceof JsonResponse) {
return $record;
}
$payload = $request->validated();
$userId = $this->getCurrentUserId();
try {
$updated = ! empty($payload['force'])
? $this->statusService->forceStatus($record, $payload['status'], $userId, $payload['notes'] ?? null)
: $this->statusService->transition($record, $payload['status'], $userId, $payload['notes'] ?? null);
} catch (\InvalidArgumentException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_CONFLICT);
}
return response()->json([
'ok' => true,
'data' => new StudentPromotionResource($this->query->detail($updated->refresh())),
]);
}
public function setDeadline(SetEnrollmentDeadlineRequest $request, ?int $promotionId = null): JsonResponse
{
$payload = $request->validated();
$userId = $this->getCurrentUserId();
$newDeadline = $payload['enrollment_deadline'];
$applyTo = $payload['apply_to'] ?? ($promotionId ? 'single' : 'school_year');
$records = collect();
if ($promotionId !== null || $applyTo === 'single') {
$id = $promotionId ?? null;
if ($id === null) {
return response()->json(['ok' => false, 'message' => 'promotion_id is required for single deadline updates.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$record = $this->findOrFail($id);
if ($record instanceof JsonResponse) {
return $record;
}
$records = collect([$record]);
} elseif ($applyTo === 'filter' && ! empty($payload['promotion_ids'])) {
$records = StudentPromotionRecord::query()
->whereIn('promotion_id', $payload['promotion_ids'])
->get();
} else {
$year = $payload['next_school_year'] ?? null;
if ($year === null || $year === '') {
return response()->json([
'ok' => false,
'message' => 'next_school_year is required when applying a bulk deadline.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$records = StudentPromotionRecord::query()
->forNextYear($year)
->whereIn('promotion_status', StudentPromotionRecord::openStatuses())
->get();
}
$updatedIds = [];
foreach ($records as $record) {
$oldDeadline = $record->enrollment_deadline?->toDateString();
DB::transaction(function () use ($record, $newDeadline, $oldDeadline, $userId, &$updatedIds) {
$record->enrollment_deadline = $newDeadline;
$record->updated_by = $userId;
$record->save();
$this->audit->logDeadlineSet($record, $oldDeadline, $newDeadline, $userId);
$updatedIds[] = (int) $record->getKey();
});
}
return response()->json([
'ok' => true,
'updated' => count($updatedIds),
'promotion_ids' => $updatedIds,
]);
}
public function sendReminder(SendReminderRequest $request, int $promotionId): JsonResponse
{
$record = $this->findOrFail($promotionId);
if ($record instanceof JsonResponse) {
return $record;
}
$payload = $request->validated();
$userId = $this->getCurrentUserId();
try {
$log = $this->reminders->send(
$record,
$payload['reminder_type'] ?? PromotionReminderLog::TYPE_MANUAL,
$userId,
$payload['subject'] ?? null,
$payload['message'] ?? null,
$payload['channels'] ?? ['in_app', 'email']
);
} catch (\Throwable $e) {
Log::error('Promotion reminder send failed', [
'promotion_id' => $promotionId,
'exception' => $e,
]);
return response()->json([
'ok' => false,
'message' => 'Failed to send reminder.',
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
return response()->json([
'ok' => true,
'reminder' => new PromotionReminderResource($log),
]);
}
public function dispatchScheduledReminders(): JsonResponse
{
$userId = $this->getCurrentUserId();
$result = $this->reminders->dispatchScheduledReminders(null, $userId);
return response()->json([
'ok' => true,
'result' => $result,
]);
}
public function reminders(int $promotionId): JsonResponse
{
$record = $this->findOrFail($promotionId);
if ($record instanceof JsonResponse) {
return $record;
}
$rows = PromotionReminderLog::query()
->forPromotion((int) $record->getKey())
->orderByDesc('id')
->get();
return response()->json([
'ok' => true,
'reminders' => PromotionReminderResource::collection($rows),
]);
}
public function audit(int $promotionId): JsonResponse
{
$record = $this->findOrFail($promotionId);
if ($record instanceof JsonResponse) {
return $record;
}
$rows = PromotionAuditLog::query()
->forPromotion((int) $record->getKey())
->orderByDesc('id')
->get();
return response()->json([
'ok' => true,
'entries' => PromotionAuditLogResource::collection($rows),
]);
}
public function expireDeadlines(): JsonResponse
{
$result = $this->enrollmentService->markExpiredDeadlines(null, $this->getCurrentUserId());
return response()->json([
'ok' => true,
'result' => $result,
]);
}
public function adminCompleteEnrollment(ParentEnrollmentStepRequest $request, int $promotionId): JsonResponse
{
$record = $this->findOrFail($promotionId);
if ($record instanceof JsonResponse) {
return $record;
}
$userId = $this->getCurrentUserId();
$parentId = (int) $record->parent_id ?: $userId ?: 0;
if ($parentId <= 0) {
return response()->json([
'ok' => false,
'message' => 'Promotion record has no associated parent.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
try {
$updated = $this->enrollmentService->updateSteps(
$record,
$parentId,
$request->steps(),
$userId
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_CONFLICT);
}
return response()->json([
'ok' => true,
'data' => new StudentPromotionResource($this->query->detail($updated->refresh())),
]);
}
public function levelProgressions(): JsonResponse
{
$rows = $this->progression->list(false);
return response()->json([
'ok' => true,
'levels' => LevelProgressionResource::collection($rows),
]);
}
public function upsertLevelProgression(UpsertLevelProgressionRequest $request): JsonResponse
{
$row = $this->progression->upsertMapping($request->validated());
return response()->json([
'ok' => true,
'level' => new LevelProgressionResource($row),
]);
}
public function seedLevelProgressions(): JsonResponse
{
$created = $this->progression->seedDefaults();
return response()->json([
'ok' => true,
'created' => $created,
]);
}
private function findOrFail(int $promotionId): StudentPromotionRecord|JsonResponse
{
$record = StudentPromotionRecord::query()->find($promotionId);
if (! $record) {
return response()->json([
'ok' => false,
'message' => 'Promotion record not found.',
], Response::HTTP_NOT_FOUND);
}
return $record;
}
}
@@ -13,12 +13,11 @@ class AdministratorTeacherSubmissionController extends Controller
{ {
public function __construct( public function __construct(
protected AdministratorTeacherSubmissionService $service protected AdministratorTeacherSubmissionService $service
) { ) {}
}
public function index(): JsonResponse public function index(Request $request): JsonResponse
{ {
return response()->json($this->service->report()); return response()->json($this->service->report($request->query()));
} }
public function notify(Request $request): JsonResponse public function notify(Request $request): JsonResponse
@@ -3,24 +3,49 @@
namespace App\Http\Controllers\Api\Administrator; namespace App\Http\Controllers\Api\Administrator;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\EmergencyContacts\EmergencyContactParentRequest;
use App\Http\Requests\EmergencyContacts\EmergencyContactUpdateRequest; use App\Http\Requests\EmergencyContacts\EmergencyContactUpdateRequest;
use App\Http\Resources\EmergencyContacts\EmergencyContactGroupResource; use App\Http\Resources\EmergencyContacts\EmergencyContactGroupResource;
use App\Http\Resources\EmergencyContacts\EmergencyContactResource; use App\Http\Resources\EmergencyContacts\EmergencyContactResource;
use App\Services\EmergencyContacts\EmergencyContactCrudService; use App\Services\EmergencyContacts\EmergencyContactCrudService;
use App\Services\EmergencyContacts\EmergencyContactDirectoryService; use App\Services\EmergencyContacts\EmergencyContactDirectoryService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class EmergencyContactController extends Controller class EmergencyContactController extends Controller
{ {
public function __construct( public function __construct(
private EmergencyContactDirectoryService $directoryService, private EmergencyContactDirectoryService $directoryService,
private EmergencyContactCrudService $crudService private EmergencyContactCrudService $crudService
) { ) {}
}
public function index(): JsonResponse public function index(Request $request): JsonResponse
{ {
$groups = $this->directoryService->groups(); $validator = Validator::make($request->query->all(), [
'parent_id' => ['nullable', 'integer', 'min:1'],
'parent_ids' => ['nullable', 'array'],
'parent_ids.*' => ['integer', 'min:1'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$parentIds = [];
if (! empty($payload['parent_ids'])) {
$parentIds = array_values(array_unique(array_map('intval', $payload['parent_ids'])));
}
if (! empty($payload['parent_id'])) {
$parentIds[] = (int) $payload['parent_id'];
}
$parentIds = array_values(array_unique(array_filter($parentIds)));
$groups = $this->directoryService->groups($parentIds ?: null);
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
@@ -28,9 +53,10 @@ class EmergencyContactController extends Controller
]); ]);
} }
public function update(EmergencyContactUpdateRequest $request, int $contactId): JsonResponse public function show(EmergencyContactParentRequest $request, int $contactId): JsonResponse
{ {
$contact = $this->crudService->update($contactId, $request->validated()); $parentId = (int) $request->validated()['parent_id'];
$contact = $this->crudService->findForParent($contactId, $parentId);
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
@@ -38,9 +64,21 @@ class EmergencyContactController extends Controller
]); ]);
} }
public function destroy(int $contactId): JsonResponse public function update(EmergencyContactUpdateRequest $request, int $contactId): JsonResponse
{ {
$this->crudService->delete($contactId); $payload = $request->validated();
$contact = $this->crudService->update($contactId, (int) $payload['parent_id'], $payload);
return response()->json([
'ok' => true,
'contact' => new EmergencyContactResource($contact),
]);
}
public function destroy(EmergencyContactParentRequest $request, int $contactId): JsonResponse
{
$parentId = (int) $request->validated()['parent_id'];
$this->crudService->delete($contactId, $parentId);
return response()->json(['ok' => true]); return response()->json(['ok' => true]);
} }
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Administrator; namespace App\Http\Controllers\Api\Administrator;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Teachers\TeacherAssignmentDeleteRequest; use App\Http\Requests\Teachers\TeacherAssignmentDeleteRequest;
use App\Http\Requests\Teachers\TeacherAssignmentListRequest; use App\Http\Requests\Teachers\TeacherAssignmentListRequest;
use App\Http\Requests\Teachers\TeacherAssignmentStoreRequest; use App\Http\Requests\Teachers\TeacherAssignmentStoreRequest;
@@ -32,8 +32,13 @@ class TeacherClassAssignmentController extends BaseApiController
public function store(TeacherAssignmentStoreRequest $request): JsonResponse public function store(TeacherAssignmentStoreRequest $request): JsonResponse
{ {
$userId = $this->authenticatedUserIdOrUnauthorized();
if ($userId instanceof JsonResponse) {
return $userId;
}
$payload = $request->validated(); $payload = $request->validated();
$payload['updated_by'] = (int) (auth()->id() ?? 0); $payload['updated_by'] = $userId;
$result = $this->assignmentService->assign($payload); $result = $this->assignmentService->assign($payload);
$status = $result['ok'] ? 200 : 422; $status = $result['ok'] ? 200 : 422;
@@ -55,4 +60,14 @@ class TeacherClassAssignmentController extends BaseApiController
'message' => $result['message'] ?? '', 'message' => $result['message'] ?? '',
], $status); ], $status);
} }
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
return $userId;
}
} }
@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Api\Administrator;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Administrator\TrophyReportRequest;
use App\Services\Administrator\Trophy\TrophyReportService;
use Illuminate\Http\JsonResponse;
class TrophyController extends BaseApiController
{
public function __construct(private TrophyReportService $service)
{
parent::__construct();
}
public function index(TrophyReportRequest $request): JsonResponse
{
$payload = $request->validated();
return response()->json([
'ok' => true,
'data' => $this->service->projection(
$payload['school_year'] ?? null,
isset($payload['percentile']) ? (float) $payload['percentile'] : null
),
]);
}
public function winners(TrophyReportRequest $request): JsonResponse
{
$payload = $request->validated();
return response()->json([
'ok' => true,
'data' => $this->service->winners(
$payload['school_year'] ?? null,
isset($payload['percentile']) ? (float) $payload['percentile'] : null
),
]);
}
public function final(TrophyReportRequest $request): JsonResponse
{
$payload = $request->validated();
return response()->json([
'ok' => true,
'data' => $this->service->final(
$payload['school_year'] ?? null,
isset($payload['percentile']) ? (float) $payload['percentile'] : null
),
]);
}
}
@@ -46,9 +46,14 @@ class AssignmentApiController extends Controller
], 422); ], 422);
} }
$userId = (int) ($request->user()?->id ?? 0);
if ($userId <= 0) {
return response()->json(['message' => 'Unauthorized.'], 401);
}
$assignment = $this->assignmentService->storeAssignment( $assignment = $this->assignmentService->storeAssignment(
data: $validator->validated(), data: $validator->validated(),
updatedBy: $request->user()?->id updatedBy: $userId
); );
return response()->json([ return response()->json([
@@ -23,17 +23,22 @@ class AdminAttendanceApiController extends Controller
{ {
return new DailyAttendanceResource( return new DailyAttendanceResource(
$this->queryService->buildDailyAttendanceData( $this->queryService->buildDailyAttendanceData(
(string)$request->query('semester', $this->attendanceService->currentSemester()), (string) $request->query('semester', $this->attendanceService->currentSemester()),
(string)$request->query('school_year', $this->attendanceService->currentSchoolYear()) (string) $request->query('school_year', $this->attendanceService->currentSchoolYear())
) )
); );
} }
public function updateManagement(UpdateAttendanceManagementRequest $request): JsonResponse public function updateManagement(UpdateAttendanceManagementRequest $request): JsonResponse
{ {
$user = auth()->user();
if ($user === null) {
return response()->json(['message' => 'Unauthorized.'], 401);
}
try { try {
return response()->json( return response()->json(
$this->attendanceService->updateAttendanceManagement(auth()->user(), $request->validated()) $this->attendanceService->updateAttendanceManagement($user, $request->validated())
); );
} catch (Throwable $e) { } catch (Throwable $e) {
return response()->json(['message' => $e->getMessage()], 422); return response()->json(['message' => $e->getMessage()], 422);
@@ -42,12 +47,17 @@ class AdminAttendanceApiController extends Controller
public function addEntry(AdminAddAttendanceEntryRequest $request): JsonResponse public function addEntry(AdminAddAttendanceEntryRequest $request): JsonResponse
{ {
$user = auth()->user();
if ($user === null) {
return response()->json(['message' => 'Unauthorized.'], 401);
}
try { try {
return response()->json( return response()->json(
$this->attendanceService->adminAddEntry(auth()->user(), $request->validated()) $this->attendanceService->adminAddEntry($user, $request->validated())
); );
} catch (Throwable $e) { } catch (Throwable $e) {
return response()->json(['message' => $e->getMessage()], 422); return response()->json(['message' => $e->getMessage()], 422);
} }
} }
} }
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Attendance; namespace App\Http\Controllers\Api\Attendance;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\ApplicationUrlService;
use App\Services\AttendanceCommentTemplateService; use App\Services\AttendanceCommentTemplateService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -11,8 +12,16 @@ use Illuminate\Support\Facades\Validator;
class AttendanceCommentTemplateController extends Controller class AttendanceCommentTemplateController extends Controller
{ {
public function __construct( public function __construct(
protected AttendanceCommentTemplateService $service protected AttendanceCommentTemplateService $service,
) { protected ApplicationUrlService $urls,
) {}
public function bootstrapUrls(): JsonResponse
{
return response()->json([
'status' => true,
'data' => $this->urls->attendanceCommentTemplateBootstrapData(),
]);
} }
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
@@ -65,7 +74,7 @@ class AttendanceCommentTemplateController extends Controller
} }
$data = $validator->validated(); $data = $validator->validated();
if (!array_key_exists('is_active', $data)) { if (! array_key_exists('is_active', $data)) {
$data['is_active'] = true; $data['is_active'] = true;
} }
@@ -119,4 +128,76 @@ class AttendanceCommentTemplateController extends Controller
'message' => 'Template deleted successfully.', 'message' => 'Template deleted successfully.',
]); ]);
} }
public function legacySave(Request $request): JsonResponse
{
$id = $request->input('id');
$hasId = $id !== null && $id !== '' && (int) $id > 0;
$rules = [
'min_score' => ['required', 'integer', 'min:0', 'max:100'],
'max_score' => ['required', 'integer', 'min:0', 'max:100'],
'template_text' => ['required', 'string', 'max:5000'],
];
$validator = Validator::make($request->all(), $rules);
$validator->after(function ($v) use ($request) {
$min = (int) $request->input('min_score');
$max = (int) $request->input('max_score');
if ($max < $min) {
$v->errors()->add('max_score', 'The max_score field must be greater than or equal to min_score.');
}
});
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$isActive = $this->legacyIsActive($request);
$payload = [
'min_score' => (int) $request->input('min_score'),
'max_score' => (int) $request->input('max_score'),
'template_text' => (string) $request->input('template_text'),
'is_active' => $isActive,
];
if ($hasId) {
$this->service->update((int) $id, $payload);
} else {
$this->service->create($payload);
}
return response()->json(['status' => 'success']);
}
public function legacyDelete(Request $request): JsonResponse
{
$id = $request->input('id');
if ($id === null || $id === '' || (int) $id <= 0) {
return response()->json([
'status' => 'error',
'message' => 'ID not provided',
], 400);
}
$this->service->delete((int) $id);
return response()->json(['status' => 'success']);
}
private function legacyIsActive(Request $request): bool
{
$raw = $request->input('is_active');
return $raw === 'on'
|| $raw === 1
|| $raw === '1'
|| $raw === true
|| $raw === 'true';
}
} }
@@ -0,0 +1,225 @@
<?php
namespace App\Http\Controllers\Api\Attendance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Models\Configuration;
use App\Models\EarlyDismissalSignature;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\Response;
class EarlyDismissalsController extends BaseApiController
{
public function __construct()
{
parent::__construct();
}
/**
* GET /api/v1/attendance/early-dismissals
* Returns early dismissal records grouped by report_date, plus a signature map.
*/
public function index(Request $request): JsonResponse
{
$schoolYear = $request->query('school_year');
$semester = $request->query('semester');
$query = DB::table('parent_attendance_reports as par')
->select([
'par.id',
'par.report_date',
'par.dismiss_time',
'par.reason',
'par.status',
'par.school_year',
'par.semester',
'par.class_section_id',
's.firstname',
's.lastname',
'cs.class_section_name',
])
->join('students as s', 's.id', '=', 'par.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'par.class_section_id')
->where('par.type', 'early_dismissal')
->orderBy('par.report_date', 'desc')
->orderByRaw("CONCAT(s.firstname, ' ', s.lastname) ASC");
if ($schoolYear) {
$query->where('par.school_year', $schoolYear);
}
if ($semester) {
$query->where('par.semester', $semester);
}
$rows = $query->get();
$groups = [];
foreach ($rows as $row) {
$date = $row->report_date ?? 'Unknown';
$groups[$date][] = [
'id' => $row->id,
'firstname' => $row->firstname,
'lastname' => $row->lastname,
'class_section_name' => $row->class_section_name,
'dismiss_time' => $row->dismiss_time,
'reason' => $row->reason,
'status' => $row->status,
'school_year' => $row->school_year,
'semester' => $row->semester,
];
}
$dates = array_keys($groups);
$signatures = EarlyDismissalSignature::query()
->whereIn('report_date', $dates)
->get(['report_date', 'filename', 'original_name']);
$signatureByDate = [];
foreach ($signatures as $sig) {
$signatureByDate[(string) $sig->report_date] = [
'filename' => $sig->filename,
'original_name' => $sig->original_name,
];
}
return $this->success([
'groups' => $groups,
'signatureByDate' => $signatureByDate,
]);
}
/**
* GET /api/v1/attendance/early-dismissals/student-options
* Returns active students with their current class section.
*/
public function studentOptions(): JsonResponse
{
$students = DB::table('students as s')
->select([
's.id',
's.firstname',
's.lastname',
'cs.class_section_name',
])
->leftJoin('student_class as sc', function ($j) {
$j->on('sc.student_id', '=', 's.id')
->where('sc.school_year', '=', Configuration::getConfigValueByKey('school_year'));
})
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->where('s.is_active', 1)
->orderByRaw("CONCAT(s.firstname, ' ', s.lastname) ASC")
->get();
return $this->success(['students' => $students]);
}
/**
* POST /api/v1/attendance/early-dismissals
* Creates a new early dismissal record.
*/
public function store(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'student_id' => ['required', 'integer', 'min:1', 'exists:students,id'],
'date' => ['required', 'date_format:Y-m-d'],
'dismiss_time' => ['required', 'date_format:H:i'],
'reason' => ['nullable', 'string', 'max:2000'],
]);
if ($validator->fails()) {
return $this->respondValidationError($validator->errors()->toArray());
}
$data = $validator->validated();
$schoolYear = (string) Configuration::getConfigValueByKey('school_year');
$semester = (string) Configuration::getConfigValueByKey('semester');
$sc = DB::table('student_class')
->where('student_id', $data['student_id'])
->where('school_year', $schoolYear)
->orderBy('id')
->first();
$classSectionId = $sc?->class_section_id ?? null;
$parentId = DB::table('students')
->where('id', $data['student_id'])
->value('parent_id') ?? Auth::id();
DB::table('parent_attendance_reports')->insert([
'parent_id' => $parentId ?? Auth::id(),
'student_id' => $data['student_id'],
'class_section_id' => $classSectionId,
'report_date' => $data['date'],
'type' => 'early_dismissal',
'dismiss_time' => $data['dismiss_time'],
'reason' => $data['reason'] ?? null,
'semester' => $semester,
'school_year' => $schoolYear,
'status' => 'new',
'created_at' => now(),
'updated_at' => now(),
]);
return $this->success([], 'Early dismissal saved.', Response::HTTP_CREATED);
}
/**
* POST /api/v1/attendance/early-dismissals/signature
* Uploads (or replaces) the signature file for a report date.
*/
public function uploadSignature(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'report_date' => ['required', 'date_format:Y-m-d'],
'school_year' => ['nullable', 'string', 'max:16'],
'semester' => ['nullable', 'string', 'max:32'],
'signature_file' => ['required', 'file', 'mimes:jpg,jpeg,png,webp,gif,pdf', 'max:5120'],
]);
if ($validator->fails()) {
return $this->respondValidationError($validator->errors()->toArray());
}
$file = $request->file('signature_file');
$reportDate = $request->input('report_date');
$schoolYear = $request->input('school_year') ?: (string) Configuration::getConfigValueByKey('school_year');
$semester = $request->input('semester') ?: (string) Configuration::getConfigValueByKey('semester');
$dir = storage_path('uploads/early_dismissal_signatures');
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
$existing = EarlyDismissalSignature::query()
->where('report_date', $reportDate)
->where('school_year', $schoolYear)
->where('semester', $semester)
->first();
if ($existing?->filename && file_exists($dir.'/'.$existing->filename)) {
unlink($dir.'/'.$existing->filename);
}
$filename = uniqid('sig_', true).'.'.$file->getClientOriginalExtension();
$file->move($dir, $filename);
EarlyDismissalSignature::updateOrCreate(
['report_date' => $reportDate, 'school_year' => $schoolYear, 'semester' => $semester],
[
'filename' => $filename,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getClientMimeType(),
'file_size' => $file->getSize(),
'uploaded_by' => Auth::id(),
]
);
return $this->success(['filename' => $filename], 'Signature uploaded.');
}
}
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Attendance; namespace App\Http\Controllers\Api\Attendance;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Attendance\LateSlipLogIndexRequest; use App\Http\Requests\Attendance\LateSlipLogIndexRequest;
use App\Http\Resources\Attendance\LateSlipLogCollection; use App\Http\Resources\Attendance\LateSlipLogCollection;
use App\Http\Resources\Attendance\LateSlipLogResource; use App\Http\Resources\Attendance\LateSlipLogResource;
@@ -42,7 +42,7 @@ class LateSlipLogsController extends BaseApiController
public function show(int $id): JsonResponse public function show(int $id): JsonResponse
{ {
$log = $this->queryService->find($id); $log = $this->queryService->find($id);
if (!$log) { if (! $log) {
return $this->error('Late slip log not found.', Response::HTTP_NOT_FOUND); return $this->error('Late slip log not found.', Response::HTTP_NOT_FOUND);
} }
@@ -56,7 +56,7 @@ class LateSlipLogsController extends BaseApiController
public function destroy(int $id): JsonResponse public function destroy(int $id): JsonResponse
{ {
$log = $this->queryService->find($id); $log = $this->queryService->find($id);
if (!$log) { if (! $log) {
return $this->error('Late slip log not found.', Response::HTTP_NOT_FOUND); return $this->error('Late slip log not found.', Response::HTTP_NOT_FOUND);
} }
@@ -65,11 +65,12 @@ class LateSlipLogsController extends BaseApiController
try { try {
$deleted = $this->commandService->delete($log); $deleted = $this->commandService->delete($log);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('Late slip log delete failed: ' . $e->getMessage()); Log::error('Late slip log delete failed: '.$e->getMessage());
return $this->error('Unable to delete late slip log.', Response::HTTP_INTERNAL_SERVER_ERROR); return $this->error('Unable to delete late slip log.', Response::HTTP_INTERNAL_SERVER_ERROR);
} }
if (!$deleted) { if (! $deleted) {
return $this->error('Unable to delete late slip log.', Response::HTTP_INTERNAL_SERVER_ERROR); return $this->error('Unable to delete late slip log.', Response::HTTP_INTERNAL_SERVER_ERROR);
} }
@@ -23,8 +23,8 @@ class StaffAttendanceApiController extends Controller
{ {
return new StaffMonthResource( return new StaffMonthResource(
$this->staffAttendanceService->monthData( $this->staffAttendanceService->monthData(
(string)$request->query('semester'), (string) $request->query('semester'),
(string)$request->query('school_year') (string) $request->query('school_year')
) )
); );
} }
@@ -33,18 +33,23 @@ class StaffAttendanceApiController extends Controller
{ {
return new AdminAttendanceResource( return new AdminAttendanceResource(
$this->staffAttendanceService->adminsAttendanceData( $this->staffAttendanceService->adminsAttendanceData(
(string)$request->query('date'), (string) $request->query('date'),
(string)$request->query('semester'), (string) $request->query('semester'),
(string)$request->query('school_year') (string) $request->query('school_year')
) )
); );
} }
public function saveAdmins(SaveAdminAttendanceRequest $request): JsonResponse public function saveAdmins(SaveAdminAttendanceRequest $request): JsonResponse
{ {
$user = auth()->user();
if ($user === null) {
return response()->json(['message' => 'Unauthorized.'], 401);
}
try { try {
return response()->json( return response()->json(
$this->staffAttendanceService->saveAdmins(auth()->user(), $request->validated()) $this->staffAttendanceService->saveAdmins($user, $request->validated())
); );
} catch (Throwable $e) { } catch (Throwable $e) {
return response()->json(['message' => $e->getMessage()], 422); return response()->json(['message' => $e->getMessage()], 422);
@@ -53,9 +58,14 @@ class StaffAttendanceApiController extends Controller
public function saveCell(SaveStaffAttendanceCellRequest $request): JsonResponse public function saveCell(SaveStaffAttendanceCellRequest $request): JsonResponse
{ {
$user = auth()->user();
if ($user === null) {
return response()->json(['message' => 'Unauthorized.'], 401);
}
try { try {
return response()->json( return response()->json(
$this->staffAttendanceService->saveCell(auth()->user(), $request->validated()) $this->staffAttendanceService->saveCell($user, $request->validated())
); );
} catch (Throwable $e) { } catch (Throwable $e) {
return response()->json(['message' => $e->getMessage()], 422); return response()->json(['message' => $e->getMessage()], 422);
@@ -65,9 +75,9 @@ class StaffAttendanceApiController extends Controller
public function monthCsv(Request $request): StreamedResponse public function monthCsv(Request $request): StreamedResponse
{ {
return $this->staffAttendanceService->monthCsv( return $this->staffAttendanceService->monthCsv(
(string)$request->query('semester'), (string) $request->query('semester'),
(string)$request->query('school_year'), (string) $request->query('school_year'),
(string)$request->query('scope') (string) $request->query('scope')
); );
} }
} }
@@ -21,10 +21,10 @@ class TeacherAttendanceApiController extends Controller
public function grid(Request $request): TeacherGridResource public function grid(Request $request): TeacherGridResource
{ {
$semester = (string)($request->query('semester') ?: $this->attendanceService->currentSemester()); $semester = (string) ($request->query('semester') ?: $this->attendanceService->currentSemester());
$schoolYear = (string)($request->query('school_year') ?: $this->attendanceService->currentSchoolYear()); $schoolYear = (string) ($request->query('school_year') ?: $this->attendanceService->currentSchoolYear());
$date = (string)($request->query('date') ?: now()->toDateString()); $date = (string) ($request->query('date') ?: now()->toDateString());
$sectionCode = (int)$request->query('class_section_id', 0); $sectionCode = (int) $request->query('class_section_id', 0);
return new TeacherGridResource( return new TeacherGridResource(
$this->queryService->teacherGrid($semester, $schoolYear, $date, $sectionCode) $this->queryService->teacherGrid($semester, $schoolYear, $date, $sectionCode)
@@ -33,11 +33,16 @@ class TeacherAttendanceApiController extends Controller
public function form(Request $request): JsonResponse|TeacherAttendanceFormResource public function form(Request $request): JsonResponse|TeacherAttendanceFormResource
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
try { try {
return new TeacherAttendanceFormResource( return new TeacherAttendanceFormResource(
$this->queryService->teacherAttendanceFormData( $this->queryService->teacherAttendanceFormData(
auth()->id(), $guard,
(int)$request->query('class_section_id', 0) (int) $request->query('class_section_id', 0)
) )
); );
} catch (Throwable $e) { } catch (Throwable $e) {
@@ -47,12 +52,27 @@ class TeacherAttendanceApiController extends Controller
public function submit(TeacherSubmitAttendanceRequest $request): JsonResponse public function submit(TeacherSubmitAttendanceRequest $request): JsonResponse
{ {
$user = auth()->user();
if ($user === null) {
return response()->json(['message' => 'Unauthorized.'], 401);
}
try { try {
return response()->json( return response()->json(
$this->attendanceService->submitTeacherAttendance(auth()->user(), $request->validated()) $this->attendanceService->submitTeacherAttendance($user, $request->validated())
); );
} catch (Throwable $e) { } catch (Throwable $e) {
return response()->json(['message' => $e->getMessage()], 422); return response()->json(['message' => $e->getMessage()], 422);
} }
} }
}
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return response()->json(['message' => 'Unauthorized.'], 401);
}
return $userId;
}
}
@@ -0,0 +1,171 @@
<?php
namespace App\Http\Controllers\Api\AttendanceManagement;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Services\AttendanceManagement\AttendanceManagementService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\Response;
class AttendanceManagementController extends BaseApiController
{
public function __construct(private AttendanceManagementService $service)
{
parent::__construct();
}
public function dashboard(Request $request): JsonResponse
{
try {
return $this->success($this->service->dashboard($request->query()));
} catch (\Throwable $e) {
return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function manualEntry(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'person_type' => ['nullable', 'string', 'max:32'],
'person_id' => ['nullable', 'integer'],
'person_name' => ['nullable', 'string', 'max:255'],
'role_grade' => ['nullable', 'string', 'max:128'],
'badge_id' => ['nullable', 'string', 'max:128'],
'entry_time' => ['nullable', 'date'],
'manual_entry_time' => ['nullable', 'date'],
'manual_reason' => ['nullable', 'string', 'max:255'],
'reason_for_manual_entry' => ['nullable', 'string', 'max:255'],
'report_status' => ['nullable', 'string', 'max:48'],
'reason' => ['nullable', 'string', 'max:255'],
'notes' => ['nullable', 'string'],
]);
if ($validator->fails()) {
return $this->respondValidationError($validator->errors()->toArray());
}
try {
return $this->success(['event' => $this->service->manualEntry($validator->validated(), $request->user())], 'Manual attendance entry recorded.', Response::HTTP_CREATED);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY);
} catch (\Throwable $e) {
return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function scan(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'badge_scan' => ['nullable', 'string', 'max:255'],
'badge_id' => ['nullable', 'string', 'max:255'],
'scan_time' => ['nullable', 'date'],
'scan_type' => ['nullable', 'string', 'max:32'],
'location' => ['nullable', 'string', 'max:255'],
'report_status' => ['nullable', 'string', 'max:48'],
'authorized' => ['nullable'],
'reason' => ['nullable', 'string', 'max:255'],
]);
if ($validator->fails()) {
return $this->respondValidationError($validator->errors()->toArray());
}
try {
return $this->success(['event' => $this->service->badgeScan($validator->validated(), $request->user())], 'Badge scan classified.');
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY);
} catch (\Throwable $e) {
return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function exitEntry(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'person_type' => ['nullable', 'string', 'max:32'],
'person_id' => ['nullable', 'integer'],
'person_name' => ['nullable', 'string', 'max:255'],
'role_grade' => ['nullable', 'string', 'max:128'],
'badge_id' => ['nullable', 'string', 'max:128'],
'exit_time' => ['nullable', 'date'],
'manual_exit_time' => ['nullable', 'date'],
'exit_method' => ['nullable', 'string', 'max:64'],
'exit_location' => ['nullable', 'string', 'max:255'],
'authorized' => ['nullable'],
'authorized_by' => ['nullable', 'string', 'max:255'],
'report_status' => ['nullable', 'string', 'max:48'],
'reason' => ['nullable', 'string', 'max:255'],
'notes' => ['nullable', 'string'],
]);
if ($validator->fails()) {
return $this->respondValidationError($validator->errors()->toArray());
}
try {
return $this->success(['event' => $this->service->exitEntry($validator->validated(), $request->user())], 'Exit attendance entry recorded.', Response::HTTP_CREATED);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY);
} catch (\Throwable $e) {
return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function absent(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'person_type' => ['nullable', 'string', 'max:32'],
'person_id' => ['nullable', 'integer'],
'person_name' => ['nullable', 'string', 'max:255'],
'role_grade' => ['nullable', 'string', 'max:128'],
'badge_id' => ['nullable', 'string', 'max:128'],
'date' => ['nullable', 'date'],
'report_status' => ['nullable', 'string', 'max:48'],
'reason' => ['nullable', 'string', 'max:255'],
]);
if ($validator->fails()) {
return $this->respondValidationError($validator->errors()->toArray());
}
try {
return $this->success(['event' => $this->service->markAbsent($validator->validated(), $request->user())], 'Absence recorded.', Response::HTTP_CREATED);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY);
} catch (\Throwable $e) {
return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function completeFollowUp(int $id, Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'report_status' => ['nullable', 'string', 'max:48'],
'final_decision' => ['nullable', 'string', 'max:255'],
'notes' => ['nullable', 'string'],
]);
if ($validator->fails()) {
return $this->respondValidationError($validator->errors()->toArray());
}
try {
return $this->success(['event' => $this->service->completeFollowUp($id, $validator->validated(), $request->user())], 'Follow-up completed.');
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), Response::HTTP_NOT_FOUND);
} catch (\Throwable $e) {
return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function reprintLateSlip(int $id, Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'reason' => ['nullable', 'string', 'max:255'],
'slip_number' => ['nullable', 'string', 'max:64'],
'late_slip_log_id' => ['nullable', 'integer'],
]);
if ($validator->fails()) {
return $this->respondValidationError($validator->errors()->toArray());
}
try {
return $this->success(['reprint' => $this->service->reprintLateSlip($id, $validator->validated(), $request->user())], 'Late slip reprint logged.', Response::HTTP_CREATED);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), Response::HTTP_NOT_FOUND);
} catch (\Throwable $e) {
return $this->error($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
@@ -12,8 +12,7 @@ class AttendanceTrackingController extends Controller
{ {
public function __construct( public function __construct(
protected AttendanceTrackingService $service protected AttendanceTrackingService $service
) { ) {}
}
public function pendingViolations(Request $request): JsonResponse public function pendingViolations(Request $request): JsonResponse
{ {
@@ -2,63 +2,84 @@
namespace App\Http\Controllers\Api\Auth; namespace App\Http\Controllers\Api\Auth;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Models\User; use App\Models\User;
use App\Services\Auth\ApiLoginSecurityService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth; use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
class AuthController extends BaseApiController class AuthController extends BaseApiController
{ {
public function login(Request $request): JsonResponse public function login(Request $request, ApiLoginSecurityService $security): JsonResponse
{ {
$validator = Validator::make($request->all(), [ $payload = $request->all();
'email' => ['required', 'email'], if ($request->isJson() && ($payload === [] || $payload === null)) {
'password' => ['required', 'string'], $decoded = json_decode((string) $request->getContent(), true);
]); $payload = is_array($decoded) ? $decoded : [];
if ($validator->fails()) {
return $this->respondValidationError($validator->errors()->toArray());
} }
$email = (string) $request->input('email'); $email = strtolower(trim((string) ($payload['email'] ?? '')));
$password = (string) $request->input('password'); $password = (string) ($payload['password'] ?? '');
$user = User::query()->where('email', $email)->first(); if ($email === '' || $password === '') {
if (!$user || !ci_password_verify($password, (string) $user->password)) { return response()->json([
return $this->respondError('Invalid credentials.', 401); 'status' => false,
'message' => 'Email and password are required.',
], 400);
} }
if ((int) ($user->is_verified ?? 0) !== 1) { $ip = $request->ip();
return $this->respondError('Account not verified.', 403); if ($security->isIpBlocked((string) $ip)) {
return response()->json([
'status' => false,
'message' => 'Too many failed attempts from your IP. Please try again later.',
], 429);
} }
if (strcasecmp((string) ($user->status ?? ''), 'Active') !== 0) { $user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
return $this->respondError('Account is inactive.', 403); if (! $user) {
$security->logIpAttempt((string) $ip);
return response()->json([
'status' => false,
'message' => 'Invalid email or password.',
], 401);
} }
$jwtToken = JWTAuth::fromUser($user); if (! verify_stored_password($password, (string) $user->password)) {
$sanctumToken = $user->createToken('api')->plainTextToken; $security->handleFailedLogin($user, $email, (string) $ip);
return $this->respondSuccess([ return response()->json([
'user' => [ 'status' => false,
'id' => (int) $user->id, 'message' => 'Invalid email or password.',
'firstname' => $user->firstname, ], 401);
'lastname' => $user->lastname, }
'email' => $user->email,
], // Suspension is checked AFTER credentials are verified so that the
'jwt' => [ // response shape cannot be used to enumerate which emails exist.
'access_token' => $jwtToken, if ($user->is_suspended) {
'token_type' => 'bearer', return response()->json([
'expires_in' => (int) config('jwt.ttl') * 60, 'status' => false,
], 'message' => 'Account suspended. Please reset your password.',
'sanctum' => [ ], 403);
'access_token' => $sanctumToken, }
'token_type' => 'bearer',
], $security->resetFailedAttempts($user);
], 'Authenticated.'); $security->logSuccessfulLogin($user->fresh(), $request);
$fresh = $user->fresh();
if (! $fresh) {
return response()->json([
'status' => false,
'message' => 'Invalid email or password.',
], 401);
}
$jwtToken = JWTAuth::fromUser($fresh);
return response()->json($security->buildLoginResponse($fresh, $jwtToken));
} }
public function refresh(Request $request): JsonResponse public function refresh(Request $request): JsonResponse
@@ -79,15 +100,19 @@ class AuthController extends BaseApiController
public function me(): JsonResponse public function me(): JsonResponse
{ {
$user = Auth::user(); $user = Auth::user();
if (!$user) { if (! $user) {
return $this->respondError('Unauthorized.', 401); return $this->respondError('Unauthorized.', 401);
} }
$teacherContext = $user->teacherSessionContext();
return $this->respondSuccess([ return $this->respondSuccess([
'id' => (int) $user->id, 'id' => (int) $user->id,
'firstname' => $user->firstname, 'firstname' => $user->firstname,
'lastname' => $user->lastname, 'lastname' => $user->lastname,
'email' => $user->email, 'email' => $user->email,
'class_section_id' => $teacherContext['class_section_id'],
'class_section_name' => $teacherContext['class_section_name'],
], 'OK'); ], 'OK');
} }
@@ -0,0 +1,249 @@
<?php
namespace App\Http\Controllers\Api\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginSessionRequest;
use App\Http\Requests\Auth\SetRoleSessionRequest;
use App\Models\User;
use App\Services\ApplicationUrlService;
use App\Services\Auth\ApiLoginSecurityService;
use App\Services\Auth\AuthSessionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
/**
* Session (cookie) endpoints for SPA — JSON alongside JWT on /api/login.
*
* Routes: GET login, POST user/login, GET logout, GET select-role, POST set-role (registered on web middleware).
*/
class AuthSessionController extends Controller
{
public function __construct(
private ApiLoginSecurityService $security,
private AuthSessionService $sessionAuth,
private ApplicationUrlService $urls,
) {}
/** Returns login bootstrap state for the web portal. */
public function loginMask(Request $request): JsonResponse
{
$redirectTo = $this->sessionAuth->sanitizeRedirectTarget(
(string) ($request->query('redirect_to') ?? ''),
);
if (! Auth::guard('web')->check()) {
return response()->json([
'status' => true,
'authenticated' => false,
'redirect_to' => $redirectTo,
]);
}
/** @var list<string>|null $roles */
$roles = session('roles');
$roles = is_array($roles) ? array_values(array_filter(array_map('strval', $roles))) : [];
$currentRole = session('role');
if ($roles !== [] && count($roles) > 1 && ! $currentRole) {
return response()->json([
'status' => true,
'authenticated' => true,
'next_url' => $this->urls->webSelectRoleUrl(false),
'roles' => $roles,
'pending_redirect' => session('post_login_redirect'),
]);
}
if ($redirectTo !== null) {
return response()->json([
'status' => true,
'authenticated' => true,
'next_url' => $redirectTo,
]);
}
$pick = $currentRole !== null && $currentRole !== ''
? [(string) $currentRole]
: $roles;
return response()->json([
'status' => true,
'authenticated' => true,
'next_url' => $this->sessionAuth->dashboardRouteForRoles($pick),
]);
}
/** Creates a web session from the submitted credentials. */
public function login(LoginSessionRequest $request): JsonResponse
{
$email = (string) $request->validated('email');
$password = (string) $request->validated('password');
$redirectRaw = $request->validated('redirect_to');
$sanitizedRedirect = $redirectRaw !== null
? $this->sessionAuth->sanitizeRedirectTarget((string) $redirectRaw)
: null;
$ip = (string) $request->ip();
if ($this->security->isIpBlocked($ip)) {
return response()->json([
'status' => false,
'message' => 'Too many failed attempts from your IP. Please try again later.',
], 429);
}
$user = User::query()->where('email', $email)->first();
if (! $user) {
$this->security->logIpAttempt($ip);
return response()->json([
'status' => false,
'message' => 'The email and password combination you entered is invalid. Please try again.',
], 401);
}
if ($user->is_suspended) {
return response()->json([
'status' => false,
'message' => 'Account suspended. Please check your email to reset your password.',
], 403);
}
if (! verify_stored_password($password, (string) $user->password)) {
$this->security->handleFailedLogin($user, $email, $ip);
return response()->json([
'status' => false,
'message' => 'The email and password combination you entered is invalid. Please try again.',
], 401);
}
$fresh = $user->fresh();
if (! $fresh) {
return response()->json([
'status' => false,
'message' => 'The email and password combination you entered is invalid. Please try again.',
], 401);
}
$this->security->resetFailedAttempts($fresh);
$this->security->logSuccessfulLogin($fresh, $request);
$dest = $this->sessionAuth->resolvePostLoginDestination($fresh, $sanitizedRedirect);
if (($dest['kind'] ?? '') === 'no_roles') {
return response()->json([
'status' => false,
'message' => (string) ($dest['reason'] ?? 'Role not assigned. Please contact support.'),
], 403);
}
$loginPayload = $this->security->buildLoginResponse($fresh, JWTAuth::fromUser($fresh));
return response()->json(array_merge($loginPayload, [
'requires_role_selection' => ($dest['kind'] ?? '') === 'select_role',
'roles' => $dest['roles'] ?? null,
'next_url' => $dest['redirect_url'] ?? $this->urls->docsHomeUrl(false),
]));
}
/** Closes the current web session. */
public function logout(Request $request): JsonResponse
{
$userId = Auth::guard('web')->id();
$email = Auth::guard('web')->user()?->email ?? session('user_email');
$this->sessionAuth->logLogout($userId ? (int) $userId : null, $email ? (string) $email : null);
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->json([
'status' => true,
'next_url' => $this->urls->docsHomeUrl(false),
]);
}
/** Returns the available post-login roles for the current user. */
public function selectRole(Request $request): JsonResponse
{
if (! Auth::guard('web')->check()) {
return response()->json([
'status' => false,
'message' => 'No roles available.',
], 401);
}
/** @var list<string>|null $roles */
$roles = session('roles');
$roles = is_array($roles) ? array_values(array_filter(array_map('strval', $roles))) : [];
if ($roles === []) {
return response()->json([
'status' => false,
'message' => 'No roles available.',
], 422);
}
return response()->json([
'status' => true,
'roles' => $roles,
'pending_redirect' => session('post_login_redirect'),
'redirect_to_query' => $this->sessionAuth->sanitizeRedirectTarget(
(string) ($request->query('redirect_to') ?? ''),
),
]);
}
/** Stores the selected post-login role in session state. */
public function setRole(SetRoleSessionRequest $request): JsonResponse
{
if (! Auth::guard('web')->check()) {
return response()->json([
'status' => false,
'message' => 'Unauthorized.',
], 401);
}
$selectedRole = (string) $request->validated('selected_role');
/** @var list<string>|null $available */
$available = session('roles');
$available = is_array($available) ? array_values(array_filter(array_map('strval', $available))) : [];
if ($available === [] || ! in_array($selectedRole, $available, true)) {
return response()->json([
'status' => false,
'message' => 'Invalid role selected.',
], 422);
}
$redirectRaw = $request->validated('redirect_to');
$redirectTo = $redirectRaw !== null
? $this->sessionAuth->sanitizeRedirectTarget((string) $redirectRaw)
: null;
if ($redirectTo === null) {
$redirectTo = $this->sessionAuth->sanitizeRedirectTarget(
(string) (session('post_login_redirect') ?? ''),
);
}
session(['role' => $selectedRole]);
session()->forget('post_login_redirect');
$next = $redirectTo ?? $this->sessionAuth->dashboardRouteForRoles([$selectedRole]);
return response()->json([
'status' => true,
'role' => $selectedRole,
'next_url' => $next,
]);
}
}
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Auth; namespace App\Http\Controllers\Api\Auth;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Security\IpBanIndexRequest; use App\Http\Requests\Security\IpBanIndexRequest;
use App\Http\Requests\Security\IpBanRequest; use App\Http\Requests\Security\IpBanRequest;
use App\Http\Requests\Security\IpUnbanRequest; use App\Http\Requests\Security\IpUnbanRequest;
@@ -44,7 +44,7 @@ class IpBanController extends BaseApiController
public function show(int $id): JsonResponse public function show(int $id): JsonResponse
{ {
$ban = $this->queryService->find($id); $ban = $this->queryService->find($id);
if (!$ban) { if (! $ban) {
return $this->error('IP record not found.', Response::HTTP_NOT_FOUND); return $this->error('IP record not found.', Response::HTTP_NOT_FOUND);
} }
@@ -67,11 +67,12 @@ class IpBanController extends BaseApiController
try { try {
$ban = $this->commandService->banNow($id ? (int) $id : null, $ip ? (string) $ip : null, $hours); $ban = $this->commandService->banNow($id ? (int) $id : null, $ip ? (string) $ip : null, $hours);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('IP ban failed: ' . $e->getMessage()); Log::error('IP ban failed: '.$e->getMessage());
return $this->error('Unable to ban IP.', Response::HTTP_INTERNAL_SERVER_ERROR); return $this->error('Unable to ban IP.', Response::HTTP_INTERNAL_SERVER_ERROR);
} }
if (!$ban) { if (! $ban) {
return $this->error('IP record not found.', Response::HTTP_NOT_FOUND); return $this->error('IP record not found.', Response::HTTP_NOT_FOUND);
} }
@@ -90,9 +91,10 @@ class IpBanController extends BaseApiController
try { try {
if ($all) { if ($all) {
$count = $this->commandService->unbanAll(); $count = $this->commandService->unbanAll();
return $this->success([ return $this->success([
'count' => $count, 'count' => $count,
], $count . ' IP(s) unbanned.'); ], $count.' IP(s) unbanned.');
} }
$ban = $this->commandService->unbanOne( $ban = $this->commandService->unbanOne(
@@ -100,11 +102,12 @@ class IpBanController extends BaseApiController
$validated['ip'] ?? null $validated['ip'] ?? null
); );
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('IP unban failed: ' . $e->getMessage()); Log::error('IP unban failed: '.$e->getMessage());
return $this->error('Unable to unban IP.', Response::HTTP_INTERNAL_SERVER_ERROR); return $this->error('Unable to unban IP.', Response::HTTP_INTERNAL_SERVER_ERROR);
} }
if (!$ban) { if (! $ban) {
return $this->error('IP record not found.', Response::HTTP_NOT_FOUND); return $this->error('IP record not found.', Response::HTTP_NOT_FOUND);
} }
@@ -2,8 +2,10 @@
namespace App\Http\Controllers\Api\Auth; namespace App\Http\Controllers\Api\Auth;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Resources\Auth\RegisterResource; use App\Http\Resources\Auth\RegisterResource;
use App\Services\Auth\ApiRegistrationService;
use App\Services\Auth\RegistrationCaptchaService; use App\Services\Auth\RegistrationCaptchaService;
use App\Services\Auth\RegistrationService; use App\Services\Auth\RegistrationService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -31,6 +33,11 @@ class RegisterController extends BaseApiController
public function store(Request $request): JsonResponse public function store(Request $request): JsonResponse
{ {
// Support direct JSON registration when the SPA skips the captcha bootstrap step.
if (! $request->has('captcha') && $request->filled('password')) {
return app(ApiRegistrationService::class)->registerResponse($request);
}
if (app()->runningUnitTests() && $request->header('X-Debug-Request') === '1') { if (app()->runningUnitTests() && $request->header('X-Debug-Request') === '1') {
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
@@ -44,7 +51,7 @@ class RegisterController extends BaseApiController
]); ]);
} }
$validator = Validator::make($request->all(), \App\Http\Requests\Auth\RegisterRequest::ruleset()); $validator = Validator::make($request->all(), RegisterRequest::ruleset());
if ($validator->fails()) { if ($validator->fails()) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Validation failed.',
@@ -0,0 +1,269 @@
<?php
namespace App\Http\Controllers\Api\Auth;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Roles\AssignUserRolesRequest;
use App\Http\Requests\Roles\PermissionStoreRequest;
use App\Http\Requests\Roles\PermissionUpdateRequest;
use App\Http\Requests\Roles\RoleListRequest;
use App\Http\Requests\Roles\RolePermissionUpdateRequest;
use App\Http\Requests\Roles\RoleStoreRequest;
use App\Http\Requests\Roles\RoleUpdateRequest;
use App\Http\Requests\Roles\UserRoleListRequest;
use App\Http\Resources\Roles\PermissionResource;
use App\Http\Resources\Roles\RolePermissionResource;
use App\Http\Resources\Roles\RoleResource;
use App\Http\Resources\Roles\UserWithRolesResource;
use App\Models\Permission;
use App\Models\Role;
use App\Services\Roles\PermissionCrudService;
use App\Services\Roles\RoleAssignmentService;
use App\Services\Roles\RoleCrudService;
use App\Services\Roles\RolePermissionService;
use App\Services\Roles\RoleQueryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class RolePermissionController extends BaseApiController
{
public function __construct(
private RoleQueryService $queryService,
private RoleAssignmentService $assignmentService,
private RolePermissionService $permissionService,
private RoleCrudService $roleCrudService,
private PermissionCrudService $permissionCrudService
) {
parent::__construct();
}
public function roles(RoleListRequest $request): JsonResponse
{
$roles = $this->queryService->listRoles($request->validated());
return $this->success([
'roles' => RoleResource::collection($roles->items()),
'meta' => [
'total' => $roles->total(),
'per_page' => $roles->perPage(),
'current_page' => $roles->currentPage(),
'last_page' => $roles->lastPage(),
],
]);
}
public function showRole(int $roleId): JsonResponse
{
$role = Role::query()->find($roleId);
if (! $role) {
return $this->error('Role not found.', Response::HTTP_NOT_FOUND);
}
return $this->success([
'role' => new RoleResource($role),
]);
}
public function storeRole(RoleStoreRequest $request): JsonResponse
{
try {
$role = $this->roleCrudService->create($request->validated());
} catch (\Throwable $e) {
Log::error('Role store failed: '.$e->getMessage());
return $this->error('Failed to create role.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success([
'role' => new RoleResource($role),
], 'Role created successfully.', Response::HTTP_CREATED);
}
public function updateRole(RoleUpdateRequest $request, int $roleId): JsonResponse
{
$role = Role::query()->find($roleId);
if (! $role) {
return $this->error('Role not found.', Response::HTTP_NOT_FOUND);
}
try {
$role = $this->roleCrudService->update($role, $request->validated());
} catch (\Throwable $e) {
Log::error('Role update failed: '.$e->getMessage());
return $this->error('Failed to update role.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success([
'role' => new RoleResource($role),
], 'Role updated successfully.');
}
public function deleteRole(int $roleId): JsonResponse
{
$role = Role::query()->find($roleId);
if (! $role) {
return $this->error('Role not found.', Response::HTTP_NOT_FOUND);
}
try {
$this->roleCrudService->delete($role);
} catch (\Throwable $e) {
Log::error('Role delete failed: '.$e->getMessage());
return $this->error('Failed to delete role.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success(null, 'Role deleted successfully.');
}
public function users(UserRoleListRequest $request): JsonResponse
{
$users = $this->queryService->listUsersWithRoles($request->validated());
return $this->success([
'users' => UserWithRolesResource::collection($users->items()),
'meta' => [
'total' => $users->total(),
'per_page' => $users->perPage(),
'current_page' => $users->currentPage(),
'last_page' => $users->lastPage(),
],
]);
}
public function assignRoles(AssignUserRolesRequest $request, int $userId): JsonResponse
{
$actorId = $this->authenticatedUserIdOrUnauthorized();
if ($actorId instanceof JsonResponse) {
return $actorId;
}
try {
$result = $this->assignmentService->assignRoles(
$userId,
(array) ($request->validated()['role_ids'] ?? []),
$actorId
);
} catch (\Throwable $e) {
Log::error('Assign roles failed: '.$e->getMessage());
return $this->error('Failed to update roles.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
if (! ($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Failed to update roles.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->success([
'role_names' => $result['role_names'] ?? [],
], 'Roles updated successfully.');
}
public function permissions(): JsonResponse
{
$permissions = $this->permissionService->listPermissions();
return $this->success([
'permissions' => PermissionResource::collection($permissions),
]);
}
public function showPermission(int $permissionId): JsonResponse
{
$permission = Permission::query()->find($permissionId);
if (! $permission) {
return $this->error('Permission not found.', Response::HTTP_NOT_FOUND);
}
return $this->success([
'permission' => new PermissionResource($permission),
]);
}
public function storePermission(PermissionStoreRequest $request): JsonResponse
{
try {
$permission = $this->permissionCrudService->create($request->validated());
} catch (\Throwable $e) {
Log::error('Permission store failed: '.$e->getMessage());
return $this->error('Failed to create permission.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success([
'permission' => new PermissionResource($permission),
], 'Permission created successfully.', Response::HTTP_CREATED);
}
public function updatePermission(PermissionUpdateRequest $request, int $permissionId): JsonResponse
{
$permission = Permission::query()->find($permissionId);
if (! $permission) {
return $this->error('Permission not found.', Response::HTTP_NOT_FOUND);
}
try {
$permission = $this->permissionCrudService->update($permission, $request->validated());
} catch (\Throwable $e) {
Log::error('Permission update failed: '.$e->getMessage());
return $this->error('Failed to update permission.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success([
'permission' => new PermissionResource($permission),
], 'Permission updated successfully.');
}
public function deletePermission(int $permissionId): JsonResponse
{
$permission = Permission::query()->find($permissionId);
if (! $permission) {
return $this->error('Permission not found.', Response::HTTP_NOT_FOUND);
}
try {
$this->permissionCrudService->delete($permission);
} catch (\Throwable $e) {
Log::error('Permission delete failed: '.$e->getMessage());
return $this->error('Failed to delete permission.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success(null, 'Permission deleted successfully.');
}
public function rolePermissions(int $roleId): JsonResponse
{
$rows = $this->permissionService->listRolePermissions($roleId);
return $this->success([
'permissions' => RolePermissionResource::collection($rows),
]);
}
public function updateRolePermissions(RolePermissionUpdateRequest $request, int $roleId): JsonResponse
{
try {
$this->permissionService->saveRolePermissions($roleId, $request->validated()['permissions'] ?? []);
} catch (\Throwable $e) {
Log::error('Role permissions update failed: '.$e->getMessage());
return $this->error('Failed to update role permissions.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success(null, 'Permissions saved successfully.');
}
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED);
}
return $userId;
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Api\Auth;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Roles\RoleSwitchRequest;
use App\Services\Roles\RoleSwitchService;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class RoleSwitcherController extends BaseApiController
{
public function __construct(private RoleSwitchService $switchService)
{
parent::__construct();
}
public function index(): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$roles = $this->switchService->getUserRoleNames($guard);
if (empty($roles)) {
return $this->error('No roles assigned to this user.', Response::HTTP_NOT_FOUND);
}
return $this->success([
'roles' => $roles,
]);
}
public function switch(RoleSwitchRequest $request): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$role = (string) $request->validated()['role'];
try {
$route = $this->switchService->switchRole($guard, $role);
} catch (AccessDeniedHttpException $e) {
return $this->error($e->getMessage(), Response::HTTP_FORBIDDEN);
} catch (NotFoundHttpException $e) {
return $this->error($e->getMessage(), Response::HTTP_NOT_FOUND);
}
return $this->success([
'role' => $role,
'dashboard_route' => $route,
], 'Role switched successfully.');
}
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return $this->error('Please log in first.', Response::HTTP_UNAUTHORIZED);
}
return $userId;
}
}
@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers\Api\Auth;
use App\Http\Controllers\Controller;
use App\Services\ApplicationUrlService;
use App\Support\SessionTimeoutConfig;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* Session idle timeout checks (web session cookie).
*/
class SessionTimeoutController extends Controller
{
/** Shared session key for last-activity tracking. */
private const SESSION_KEY = 'last_activity';
public function __construct(
private ApplicationUrlService $urls,
) {}
/**
* Returns timeout metadata for the web portal.
*/
public function getTimeoutConfig(): JsonResponse
{
return response()->json([
'success' => true,
'timeout' => SessionTimeoutConfig::TIMEOUT_DURATION,
'warning_time' => SessionTimeoutConfig::WARNING_THRESHOLD,
'check_interval' => SessionTimeoutConfig::CHECK_INTERVAL,
'logout_url' => $this->urls->webLogoutUrl(),
'keep_alive_url' => $this->urls->webSessionPingUrl(),
'check_url' => $this->urls->webSessionCheckTimeoutUrl(),
]);
}
public function checkTimeout(Request $request): JsonResponse
{
$session = $request->session();
if (! $session->has(self::SESSION_KEY)) {
return $this->expireSession($request);
}
$lastActivity = (int) $session->get(self::SESSION_KEY);
$elapsed = time() - $lastActivity;
if ($elapsed > SessionTimeoutConfig::TIMEOUT_DURATION) {
return $this->expireSession($request);
}
if ($elapsed > SessionTimeoutConfig::WARNING_THRESHOLD) {
return response()->json([
'status' => 'warning',
'time_remaining' => SessionTimeoutConfig::TIMEOUT_DURATION - $elapsed,
]);
}
return response()->json([
'status' => 'active',
'time_remaining' => SessionTimeoutConfig::TIMEOUT_DURATION - $elapsed,
]);
}
public function pingActivity(Request $request): JsonResponse
{
$session = $request->session();
if (! $session->has(self::SESSION_KEY)
|| (time() - (int) $session->get(self::SESSION_KEY)) > SessionTimeoutConfig::TIMEOUT_DURATION) {
return $this->expireSession($request);
}
$session->put(self::SESSION_KEY, time());
return response()->json([
'status' => 'active',
'time_remaining' => SessionTimeoutConfig::TIMEOUT_DURATION,
]);
}
private function expireSession(Request $request): JsonResponse
{
$request->session()->flash('error', 'Your session has expired due to inactivity.');
$request->session()->forget(self::SESSION_KEY);
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->json([
'status' => 'expired',
'redirect' => $this->urls->webLoginUrl(),
'message' => 'Your session has expired due to inactivity.',
]);
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Api\BadgeScan;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Services\BadgeScan\BadgeScanService;
use App\Services\SchoolYears\SchoolYearContextService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\Response;
class BadgeScanController extends BaseApiController
{
public function __construct(
private BadgeScanService $badgeScan,
private SchoolYearContextService $schoolYears,
) {
parent::__construct();
}
/**
* Public kiosk endpoint — POST JSON or form (legacy RFIDController::process).
*/
public function scan(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'badge_scan' => ['required', 'string', 'max:255'],
'school_year' => ['nullable', 'string', 'max:50'],
'semester' => ['nullable', 'string', 'max:50'],
]);
if ($validator->fails()) {
return $this->respondValidationError($validator->errors()->toArray());
}
$payload = $validator->validated();
$context = $this->schoolYears->options($payload['school_year'] ?? null, $payload['semester'] ?? null);
$result = $this->badgeScan->processScan(
$payload['badge_scan'],
$context['school_year'] ?? null,
$context['semester'] ?? null
);
if (! $result['recognized']) {
return $this->error($result['message'], Response::HTTP_NOT_FOUND);
}
return $this->success([
'recognized' => true,
'user_id' => $result['user_id'] ?? null,
'display_name' => $result['display_name'] ?? null,
], $result['message']);
}
/**
* Authenticated scan log listing (legacy RFIDController::log).
*/
public function logs(Request $request): JsonResponse
{
$context = $this->schoolYears->options(
$request->query('school_year'),
$request->query('semester')
);
return $this->success([
'logs' => $this->badgeScan->scanLogRows(
$context['school_year'] ?? null,
$context['semester'] ?? null
),
'meta' => $context,
]);
}
}
@@ -17,19 +17,34 @@ class BadgeController extends Controller
protected BadgePdfService $badgePdfService, protected BadgePdfService $badgePdfService,
protected BadgePrintLogService $badgePrintLogService, protected BadgePrintLogService $badgePrintLogService,
protected BadgeFormDataService $badgeFormDataService protected BadgeFormDataService $badgeFormDataService
) { ) {}
}
public function generatePdf(Request $request) public function generatePdf(Request $request)
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'user_ids' => ['required', 'array', 'min:1'], 'user_ids' => ['nullable', 'array'],
'user_ids.*' => ['integer', 'min:1'], 'user_ids.*' => ['integer', 'exists:users,id'],
'student_ids' => ['nullable', 'array'],
'student_ids.*' => ['integer', 'exists:students,id'],
'school_year' => ['nullable', 'string', 'max:50'], 'school_year' => ['nullable', 'string', 'max:50'],
'roles' => ['nullable', 'array'], 'roles' => ['nullable', 'array'],
'classes' => ['nullable', 'array'], 'classes' => ['nullable', 'array'],
]); ]);
$validator->after(function ($validator): void {
$data = $validator->getData();
$users = array_values(array_filter((array) ($data['user_ids'] ?? []), static fn ($v) => (int) $v > 0));
$students = array_values(array_filter((array) ($data['student_ids'] ?? []), static fn ($v) => (int) $v > 0));
if ($users === [] && $students === []) {
$validator->errors()->add('user_ids', 'Provide at least one user id or student id.');
}
});
if ($validator->fails()) { if ($validator->fails()) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Validation failed.',
@@ -40,11 +55,12 @@ class BadgeController extends Controller
$data = $validator->validated(); $data = $validator->validated();
return $this->badgePdfService->generate( return $this->badgePdfService->generate(
studentIds: $data['student_ids'] ?? [],
userIds: $data['user_ids'] ?? [], userIds: $data['user_ids'] ?? [],
schoolYear: $data['school_year'] ?? null, schoolYear: $data['school_year'] ?? null,
rolesMap: $data['roles'] ?? [], rolesMap: $data['roles'] ?? [],
classesMap: $data['classes'] ?? [], classesMap: $data['classes'] ?? [],
actorId: optional($request->user())->id actorId: $guard
); );
} }
@@ -54,7 +70,7 @@ class BadgeController extends Controller
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'data' => $this->badgePrintLogService->getStatus( 'data' => $this->badgePrintLogService->getStatus(
$this->parseUserIds($request), $this->parseCombinedBadgeIds($request),
$request->input('school_year') $request->input('school_year')
), ),
]); ]);
@@ -69,14 +85,30 @@ class BadgeController extends Controller
public function logPrint(Request $request): JsonResponse public function logPrint(Request $request): JsonResponse
{ {
try { try {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'user_ids' => ['required', 'array', 'min:1'], 'user_ids' => ['nullable', 'array'],
'user_ids.*' => ['integer', 'min:1'], 'user_ids.*' => ['integer', 'exists:users,id'],
'student_ids' => ['nullable', 'array'],
'student_ids.*' => ['integer', 'exists:students,id'],
'school_year' => ['nullable', 'string', 'max:50'], 'school_year' => ['nullable', 'string', 'max:50'],
'roles' => ['nullable', 'array'], 'roles' => ['nullable', 'array'],
'classes' => ['nullable', 'array'], 'classes' => ['nullable', 'array'],
]); ]);
$validator->after(function ($validator): void {
$data = $validator->getData();
$users = array_values(array_filter((array) ($data['user_ids'] ?? []), static fn ($v) => (int) $v > 0));
$students = array_values(array_filter((array) ($data['student_ids'] ?? []), static fn ($v) => (int) $v > 0));
if ($users === [] && $students === []) {
$validator->errors()->add('user_ids', 'Provide at least one user id or student id.');
}
});
if ($validator->fails()) { if ($validator->fails()) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Validation failed.',
@@ -87,8 +119,11 @@ class BadgeController extends Controller
$data = $validator->validated(); $data = $validator->validated();
$inserted = $this->badgePrintLogService->log( $inserted = $this->badgePrintLogService->log(
userIds: $data['user_ids'] ?? [], userIds: array_values(array_unique(array_merge(
actorId: optional($request->user())->id, $data['user_ids'] ?? [],
$data['student_ids'] ?? []
))),
actorId: $guard,
schoolYear: $data['school_year'] ?? null, schoolYear: $data['school_year'] ?? null,
rolesMap: $data['roles'] ?? [], rolesMap: $data['roles'] ?? [],
classesMap: $data['classes'] ?? [] classesMap: $data['classes'] ?? []
@@ -112,24 +147,73 @@ class BadgeController extends Controller
'ok' => true, 'ok' => true,
'data' => $this->badgeFormDataService->build( 'data' => $this->badgeFormDataService->build(
schoolYear: $request->input('school_year'), schoolYear: $request->input('school_year'),
selectedUserIds: $this->parseUserIds($request), selectedUserIds: $this->parseStaffUserIds($request),
selectedStudentIds: $this->parseStudentIds($request),
activeRole: $request->input('active_role') activeRole: $request->input('active_role')
), ),
]); ]);
} }
private function parseUserIds(Request $request): array /**
* Staff user IDs for the badge picker (`users.id`).
*
* @return int[]
*/
private function parseStaffUserIds(Request $request): array
{ {
$userIds = $request->input('user_ids', $request->query('user_ids', [])); $ids = $request->input('user_ids', $request->query('user_ids', []));
if (is_string($userIds)) { if (is_string($ids)) {
$userIds = array_filter(array_map('trim', explode(',', $userIds)), 'strlen'); $ids = array_filter(array_map('trim', explode(',', $ids)), 'strlen');
} elseif (!is_array($userIds)) { } elseif (! is_array($ids)) {
$userIds = $userIds ? [$userIds] : []; $ids = $ids ? [$ids] : [];
} }
$userIds = array_values(array_unique(array_map(static fn ($v) => (int) $v, $userIds))); $ids = array_values(array_unique(array_map(static fn ($v) => (int) $v, $ids)));
return array_values(array_filter($userIds, static fn ($v) => $v > 0)); return array_values(array_filter($ids, static fn ($v) => $v > 0));
}
/**
* Combined `users.id` and `students.id` for print-status (same numeric space as stored in logs).
*
* @return int[]
*/
private function parseCombinedBadgeIds(Request $request): array
{
return array_values(array_unique(array_merge(
$this->parseStaffUserIds($request),
$this->parseStudentIds($request)
)));
}
/**
* Student primary keys (`students.id`) from query or body.
*
* @return int[]
*/
private function parseStudentIds(Request $request): array
{
$ids = $request->input('student_ids', $request->query('student_ids', []));
if (is_string($ids)) {
$ids = array_filter(array_map('trim', explode(',', $ids)), 'strlen');
} elseif (! is_array($ids)) {
$ids = $ids ? [$ids] : [];
}
$ids = array_values(array_unique(array_map(static fn ($v) => (int) $v, $ids)));
return array_values(array_filter($ids, static fn ($v) => $v > 0));
}
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
return $userId;
} }
} }
+2 -272
View File
@@ -2,276 +2,6 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Api\Core\BaseApiController as CoreBaseApiController;
use App\Http\Controllers\Api\CiRequestAdapter;
use App\Models\User;
use Illuminate\Contracts\Validation\Validator as ValidatorContract;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\Response;
class BaseApiController extends Controller class BaseApiController extends CoreBaseApiController {}
{
protected Request $laravelRequest;
protected CiRequestAdapter $request;
protected ValidatorContract|null $validator = null;
public function __construct()
{
$this->laravelRequest = request();
$this->request = new CiRequestAdapter($this->laravelRequest);
}
protected function success($data = null, string $message = 'Success', int $code = Response::HTTP_OK): JsonResponse
{
return response()->json([
'status' => true,
'message' => $message,
'data' => $data,
], $code);
}
protected function error(string $message = 'An error occurred', int $code = Response::HTTP_BAD_REQUEST, ?array $errors = null): JsonResponse
{
$payload = [
'status' => false,
'message' => $message,
];
if (!empty($errors)) {
$payload['errors'] = $errors;
}
return response()->json($payload, $code);
}
protected function respondSuccess($data = null, string $message = 'Success', int $code = Response::HTTP_OK): JsonResponse
{
return $this->success($data, $message, $code);
}
protected function respondCreated($data = null, string $message = 'Resource created successfully'): JsonResponse
{
return $this->success($data, $message, Response::HTTP_CREATED);
}
protected function respondDeleted($data = null, string $message = 'Resource deleted successfully'): JsonResponse
{
return $this->success($data, $message, Response::HTTP_OK);
}
protected function respondError(string $message = 'An error occurred', int $code = Response::HTTP_BAD_REQUEST): JsonResponse
{
return $this->error($message, $code);
}
protected function respondValidationError(array $errors, string $message = 'Validation failed'): JsonResponse
{
return $this->error($message, Response::HTTP_UNPROCESSABLE_ENTITY, $errors);
}
protected function validateRequest(array $data, array $rules): array
{
$normalized = [];
foreach ($rules as $field => $ruleSet) {
$normalized[$field] = $this->normalizeRules($ruleSet);
}
$validator = Validator::make($data, $normalized);
$this->validator = $validator;
if ($validator->fails()) {
return $validator->errors()->toArray();
}
return [];
}
protected function validate(array $rules): bool
{
$payload = $this->payloadData();
$errors = $this->validateRequest($payload, $rules);
return empty($errors);
}
protected function payloadData(): array
{
$json = json_decode($this->laravelRequest->getContent() ?: '', true);
if (is_array($json)) {
return $json;
}
return array_merge($this->laravelRequest->query->all(), $this->laravelRequest->request->all());
}
protected function normalizeRules(array|string $ruleSet): array|string
{
if (is_array($ruleSet)) {
return $ruleSet;
}
$parts = explode('|', $ruleSet);
$result = [];
foreach ($parts as $part) {
$part = trim($part);
if ($part === '') {
continue;
}
if (preg_match('/^max_length\\[(\\d+)\\]$/', $part, $m)) {
$result[] = 'max:' . $m[1];
continue;
}
if (preg_match('/^min_length\\[(\\d+)\\]$/', $part, $m)) {
$result[] = 'min:' . $m[1];
continue;
}
if (preg_match('/^in_list\\[(.+)\\]$/', $part, $m)) {
$result[] = 'in:' . $m[1];
continue;
}
if (preg_match('/^greater_than_equal_to\\[(.+)\\]$/', $part, $m)) {
$result[] = 'gte:' . $m[1];
continue;
}
if (preg_match('/^less_than_equal_to\\[(.+)\\]$/', $part, $m)) {
$result[] = 'lte:' . $m[1];
continue;
}
if (preg_match('/^valid_date\\[(.+)\\]$/', $part, $m)) {
$result[] = 'date_format:' . $m[1];
continue;
}
if ($part === 'valid_email') {
$result[] = 'email';
continue;
}
if ($part === 'permit_empty') {
$result[] = 'nullable';
continue;
}
if (preg_match('/^is_unique\\[([^\\.]+)\\.([^\\]]+)\\]$/', $part, $m)) {
$result[] = 'unique:' . $m[1] . ',' . $m[2];
continue;
}
if ($part === 'string') {
$result[] = 'string';
continue;
}
if ($part === 'integer') {
$result[] = 'integer';
continue;
}
if ($part === 'numeric') {
$result[] = 'numeric';
continue;
}
if ($part === 'required') {
$result[] = 'required';
continue;
}
if ($part === 'alpha_numeric') {
$result[] = 'alpha_num';
continue;
}
if ($part === 'in_array') {
$result[] = 'in_array';
continue;
}
if ($part === 'valid_url') {
$result[] = 'url';
continue;
}
if (strpos($part, 'max_size') === 0) {
$result[] = str_replace('max_size', 'max', $part);
continue;
}
$result[] = $part;
}
return $result;
}
protected function paginate($source, int $page = 1, int $perPage = 20): array
{
$page = max(1, $page);
$perPage = max(1, $perPage);
$offset = ($page - 1) * $perPage;
if ($source instanceof EloquentBuilder || $source instanceof \Illuminate\Database\Query\Builder) {
$total = $source->toBase()->getCountForPagination();
$items = $source->offset($offset)->limit($perPage)->get()->toArray();
} elseif (is_array($source)) {
$total = count($source);
$items = array_slice($source, $offset, $perPage);
} else {
return [
'data' => [],
'pagination' => [
'current_page' => $page,
'per_page' => $perPage,
'total' => 0,
'total_pages' => 0,
],
];
}
return [
'data' => array_values($items),
'pagination' => [
'current_page' => $page,
'per_page' => $perPage,
'total' => $total,
'total_pages' => (int) ceil($total / $perPage),
],
];
}
protected function getCurrentUserId(): ?int
{
$userId = (int) (auth()->id() ?? 0);
return $userId > 0 ? $userId : null;
}
protected function getCurrentUser(): ?object
{
$userId = $this->getCurrentUserId();
if (!$userId) {
return null;
}
$user = app(User::class);
$row = $user->find($userId);
if (!$row) {
return null;
}
$name = trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? ''));
if ($name === '') {
$name = $row['email'] ?? 'User #' . $userId;
}
try {
$roles = DB::table('user_roles ur')
->select('LOWER(r.name) AS name')
->join('roles r', 'r.id', '=', 'ur.role_id')
->where('ur.user_id', $userId)
->whereNull('ur.deleted_at')
->pluck('name')
->map(fn($name) => strtolower((string) $name))
->unique()
->values()
->toArray();
} catch (\Throwable $e) {
Log::error('Unable to load user roles: ' . $e->getMessage());
$roles = [];
}
return (object) [
'id' => (int) $userId,
'name' => $name,
'email' => $row['email'] ?? null,
'roles' => $roles,
];
}
}
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Certificates;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Services\Certificates\CertificateAdminService;
use App\Services\Certificates\CertificatePdfService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
class CertificateController extends BaseApiController
{
public function __construct(
private CertificateAdminService $service,
private CertificatePdfService $pdfService,
) {
parent::__construct();
}
public function dashboard(Request $request): JsonResponse
{
try {
return response()->json([
'ok' => true,
'data' => $this->service->dashboard($request->query('school_year')),
]);
} catch (\Throwable $e) {
Log::error('Certificate dashboard failed', ['exception' => $e]);
return $this->error('Failed to load certificate dashboard.', 500);
}
}
public function formOptions(Request $request): JsonResponse
{
return $this->dashboard($request);
}
public function auditLog(Request $request): JsonResponse
{
try {
return response()->json([
'ok' => true,
'data' => $this->service->auditLog($request->query('school_year')),
]);
} catch (\Throwable $e) {
Log::error('Certificate audit log failed', ['exception' => $e]);
return $this->error('Failed to load certificate audit log.', 500);
}
}
public function generate(Request $request): Response|JsonResponse
{
$studentIds = $request->input('student_ids', []);
$certDate = trim((string) ($request->input('cert_date') ?? date('m/d/Y')));
$classSectionId = $request->input('class_section_id');
$schoolYear = $request->input('school_year');
try {
$payload = $this->service->issueCertificates(
is_array($studentIds) ? $studentIds : [],
$certDate,
$classSectionId !== null && $classSectionId !== '' ? (int) $classSectionId : null,
is_string($schoolYear) ? $schoolYear : null,
$this->getCurrentUserId()
);
$pdfContent = $this->pdfService->generate(
$payload['students'],
$payload['cert_date_display']
);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (\Throwable $e) {
Log::error('Certificate generation failed', ['exception' => $e]);
return $this->error('Failed to generate certificates.', 500);
}
$filename = 'Certificates_'.date('Ymd_His').'.pdf';
return response($pdfContent, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
public function reprint(string $certificateNumber): Response|JsonResponse
{
try {
$payload = $this->service->reprintPayload($certificateNumber);
if ($payload === null) {
return $this->error('Certificate not found: '.$certificateNumber, 404);
}
$pdfContent = $this->pdfService->generate(
[$payload['student']],
$payload['cert_date_display']
);
} catch (\Throwable $e) {
Log::error('Certificate reprint failed', [
'certificate_number' => $certificateNumber,
'exception' => $e,
]);
return $this->error('Failed to reprint certificate.', 500);
}
$filename = 'Certificate_'.strtoupper($certificateNumber).'.pdf';
return response($pdfContent, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
public function verify(string $token): JsonResponse
{
try {
$payload = $this->service->verifyPayload($token);
} catch (\Throwable $e) {
Log::error('Certificate verify failed', ['token' => $token, 'exception' => $e]);
return $this->error('Failed to verify certificate.', 500);
}
if ($payload === null) {
return response()->json([
'ok' => false,
'message' => 'Certificate not found.',
], 404);
}
return response()->json([
'ok' => true,
'data' => $payload,
]);
}
}
@@ -1,70 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
class CiRequestAdapter
{
public function __construct(protected Request $request)
{
}
public function getGet(string $key = null, $default = null)
{
if ($key === null) {
return $this->request->query->all();
}
return $this->request->query->get($key, $default);
}
public function getPost(string $key = null, $default = null)
{
if ($key === null) {
return $this->request->request->all();
}
return $this->request->request->get($key, $default);
}
public function getJSON(bool $assoc = false)
{
$content = $this->request->getContent();
if ($content === '') {
return $assoc ? [] : null;
}
return json_decode($content, $assoc);
}
public function getVar(string $key, $default = null)
{
return $this->request->input($key, $default);
}
public function getHeader(string $key)
{
return $this->request->headers->get($key);
}
public function getHeaderLine(string $key)
{
return $this->request->header($key);
}
public function getFile(string $key)
{
return $this->request->file($key);
}
public function getRawInput(): string
{
return $this->request->getContent();
}
public function all(): array
{
return array_merge(
$this->request->query->all(),
$this->request->request->all(),
$this->request->json()->all()
);
}
}
@@ -1,53 +0,0 @@
<?php
namespace App\Http\Controllers\Api\ClassPrep;
use App\Http\Controllers\Api\BaseApiController;
use App\Services\ClassPrep\ClassRosterService;
use App\Services\ClassPrep\StickerCountService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ClassPrepController extends BaseApiController
{
private StickerCountService $stickerCounts;
private ClassRosterService $roster;
public function __construct(StickerCountService $stickerCounts, ClassRosterService $roster)
{
parent::__construct();
$this->stickerCounts = $stickerCounts;
$this->roster = $roster;
}
public function stickerCounts(Request $request): JsonResponse
{
$schoolYear = (string) ($request->query('school_year') ?? '');
$semester = (string) ($request->query('semester') ?? '');
$classSectionId = $request->query('class_section_id') ?? $request->query('class_id');
$classSectionId = is_numeric($classSectionId) ? (int) $classSectionId : null;
if ($classSectionId !== null && $classSectionId > 0) {
$payload = $this->stickerCounts->listForClass($schoolYear, $semester, $classSectionId);
} else {
$payload = $this->stickerCounts->listAll($schoolYear, $semester);
}
return response()->json([
'ok' => true,
'data' => $payload,
]);
}
public function studentsByClass(Request $request, int $classSectionId): JsonResponse
{
$schoolYear = (string) ($request->query('school_year') ?? '');
$students = $this->roster->listStudentsByClass($classSectionId, $schoolYear !== '' ? $schoolYear : null);
return response()->json([
'ok' => true,
'students' => $students,
]);
}
}
@@ -2,15 +2,18 @@
namespace App\Http\Controllers\Api\ClassPreparation; namespace App\Http\Controllers\Api\ClassPreparation;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\ClassPreparation\ClassPreparationAdjustmentRequest; use App\Http\Requests\ClassPreparation\ClassPreparationAdjustmentRequest;
use App\Http\Requests\ClassPreparation\ClassPreparationIndexRequest; use App\Http\Requests\ClassPreparation\ClassPreparationIndexRequest;
use App\Http\Requests\ClassPreparation\ClassPreparationMarkPrintedRequest; use App\Http\Requests\ClassPreparation\ClassPreparationMarkPrintedRequest;
use App\Http\Requests\ClassPreparation\ClassPreparationPrintRequest; use App\Http\Requests\ClassPreparation\ClassPreparationPrintRequest;
use App\Http\Resources\ClassPreparation\ClassPreparationPrintResource; use App\Http\Resources\ClassPreparation\ClassPreparationPrintResource;
use App\Http\Resources\ClassPreparation\ClassPreparationResultResource; use App\Http\Resources\ClassPreparation\ClassPreparationResultResource;
use App\Services\ClassPrep\ClassRosterService;
use App\Services\ClassPrep\StickerCountService;
use App\Services\ClassPreparation\ClassPreparationService; use App\Services\ClassPreparation\ClassPreparationService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -18,10 +21,19 @@ class ClassPreparationController extends BaseApiController
{ {
private ClassPreparationService $service; private ClassPreparationService $service;
public function __construct(ClassPreparationService $service) private StickerCountService $stickerCounts;
{
private ClassRosterService $roster;
public function __construct(
ClassPreparationService $service,
StickerCountService $stickerCounts,
ClassRosterService $roster
) {
parent::__construct(); parent::__construct();
$this->service = $service; $this->service = $service;
$this->stickerCounts = $stickerCounts;
$this->roster = $roster;
} }
public function index(ClassPreparationIndexRequest $request): JsonResponse public function index(ClassPreparationIndexRequest $request): JsonResponse
@@ -66,7 +78,8 @@ class ClassPreparationController extends BaseApiController
try { try {
$count = $this->service->saveAdjustments($classSectionId, $schoolYear, $validated['adjustments']); $count = $this->service->saveAdjustments($classSectionId, $schoolYear, $validated['adjustments']);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('Class prep adjustment save failed: ' . $e->getMessage()); Log::error('Class prep adjustment save failed: '.$e->getMessage());
return $this->error('Unable to save adjustments.', Response::HTTP_INTERNAL_SERVER_ERROR); return $this->error('Unable to save adjustments.', Response::HTTP_INTERNAL_SERVER_ERROR);
} }
@@ -93,4 +106,35 @@ class ClassPreparationController extends BaseApiController
'print' => new ClassPreparationPrintResource($payload), 'print' => new ClassPreparationPrintResource($payload),
], 'Class preparation logged.'); ], 'Class preparation logged.');
} }
public function stickerCounts(Request $request): JsonResponse
{
$schoolYear = (string) ($request->query('school_year') ?? '');
$semester = (string) ($request->query('semester') ?? '');
$classSectionId = $request->query('class_section_id') ?? $request->query('class_id');
$classSectionId = is_numeric($classSectionId) ? (int) $classSectionId : null;
if ($classSectionId !== null && $classSectionId > 0) {
$payload = $this->stickerCounts->listForClass($schoolYear, $semester, $classSectionId);
} else {
$payload = $this->stickerCounts->listAll($schoolYear, $semester);
}
return response()->json([
'ok' => true,
'data' => $payload,
]);
}
public function studentsByClass(Request $request, int $classSectionId): JsonResponse
{
$schoolYear = (string) ($request->query('school_year') ?? '');
$students = $this->roster->listStudentsByClass($classSectionId, $schoolYear !== '' ? $schoolYear : null);
return response()->json([
'ok' => true,
'students' => $students,
]);
}
} }
@@ -0,0 +1,266 @@
<?php
namespace App\Http\Controllers\Api\ClassProgress;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\ClassProgress\ClassProgressIndexRequest;
use App\Http\Requests\ClassProgress\ClassProgressMetaRequest;
use App\Http\Requests\ClassProgress\ClassProgressStoreRequest;
use App\Http\Requests\ClassProgress\ClassProgressUpdateRequest;
use App\Http\Resources\ClassProgress\ClassProgressGroupCollection;
use App\Http\Resources\ClassProgress\ClassProgressReportResource;
use App\Http\Resources\ClassProgress\ClassProgressShowResource;
use App\Models\ClassProgressAttachment;
use App\Models\ClassProgressReport;
use App\Models\Configuration;
use App\Models\User;
use App\Services\ClassProgress\ClassProgressAttachmentService;
use App\Services\ClassProgress\ClassProgressMetaService;
use App\Services\ClassProgress\ClassProgressMutationService;
use App\Services\ClassProgress\ClassProgressQueryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
class ClassProgressController extends BaseApiController
{
public function __construct(
private ClassProgressQueryService $queryService,
private ClassProgressMutationService $mutationService,
private ClassProgressMetaService $metaService,
private ClassProgressAttachmentService $attachmentService
) {
parent::__construct();
$this->authorizeResource(ClassProgressReport::class, 'class_progress');
}
public function meta(ClassProgressMetaRequest $request): JsonResponse
{
return $this->respondSuccess(
$this->buildMetaPayload(
$request->validated('class_id'),
(int) ($request->validated('sunday_count') ?? 12),
$request->query('school_year'),
$request->query('semester')
),
'Progress metadata loaded.'
);
}
/**
* Legacy teacher portal URL for the weekly class progress submit page.
*/
public function legacySubmitForm(ClassProgressMetaRequest $request): JsonResponse
{
$auth = $this->requireAuthenticatedUser($request->user());
if ($auth instanceof JsonResponse) {
return $auth;
}
$schoolYear = (string) (Configuration::getConfig('school_year') ?? '');
$semester = (string) (Configuration::getConfig('semester') ?? '');
$assignments = $this->queryService->teacherAssignments($auth, $schoolYear, $semester);
$requestedClassId = (int) ($request->validated('class_id') ?? 0);
$activeClassId = $requestedClassId > 0 ? $requestedClassId : (int) ($assignments[0]['class_id'] ?? 0);
$activeAssignment = collect($assignments)->first(
fn (array $assignment) => (int) ($assignment['class_id'] ?? 0) === $activeClassId
) ?? ($assignments[0] ?? []);
$sundayCount = (int) ($request->validated('sunday_count') ?? 12);
$payload = $this->buildMetaPayload($activeClassId > 0 ? $activeClassId : null, $sundayCount, $schoolYear, $semester);
$payload['classes'] = $assignments;
$payload['defaults'] = [
'class_id' => $activeClassId > 0 ? $activeClassId : null,
'class_section_id' => (int) ($activeAssignment['class_section_id'] ?? 0) ?: null,
'school_year' => $schoolYear !== '' ? $schoolYear : null,
'semester' => $semester !== '' ? $semester : null,
'week_start' => $this->metaService->pickDefaultWeekStart($payload['sunday_options']),
];
return $this->respondSuccess($payload, 'Progress metadata loaded.');
}
public function index(ClassProgressIndexRequest $request): JsonResponse
{
$auth = $this->requireAuthenticatedUser($request->user());
if ($auth instanceof JsonResponse) {
return $auth;
}
$filters = $request->validated();
$meta = $this->queryService->meta($filters['school_year'] ?? null, $filters['semester'] ?? null);
$filters['school_year'] = $filters['school_year'] ?? ($meta['school_year'] ?? null);
$filters['semester'] = $filters['semester'] ?? ($meta['semester'] ?? null);
$paginator = $this->queryService->listReports($auth, $filters);
if (! empty($filters['group_by_week'])) {
$grouped = ClassProgressGroupCollection::fromPaginator($paginator);
return $this->respondSuccess($grouped, 'Progress reports retrieved.');
}
return $this->respondSuccess([
'items' => ClassProgressReportResource::collection($paginator->items()),
'meta' => $meta,
'pagination' => [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'last_page' => $paginator->lastPage(),
],
], 'Progress reports retrieved.');
}
public function store(ClassProgressStoreRequest $request): JsonResponse
{
$auth = $this->requireAuthenticatedUser($request->user());
if ($auth instanceof JsonResponse) {
return $auth;
}
try {
$filesBySubject = $request->filesBySubject();
$reports = $this->mutationService->createReports($auth, $request->validated(), $filesBySubject);
} catch (\InvalidArgumentException $e) {
if ($e->getMessage() === 'CONFIRM_OVERWRITE') {
return response()->json([
'status' => false,
'message' => 'A progress report already exists for this week. Resubmit with confirm_overwrite=true to replace it.',
'data' => ['requires_confirmation' => true],
], 409);
}
return $this->respondError($e->getMessage(), 422);
} catch (\Throwable $e) {
Log::error('Failed to create class progress reports.', [
'error' => $e->getMessage(),
]);
return $this->respondError('Unable to save progress reports.', 500);
}
return $this->respondCreated(ClassProgressReportResource::collection($reports), 'Progress reports saved.');
}
public function show(ClassProgressReport $class_progress): JsonResponse
{
$auth = $this->requireAuthenticatedUser($this->laravelRequest->user());
if ($auth instanceof JsonResponse) {
return $auth;
}
$weeklyReports = $this->queryService->weeklyReports($auth, $class_progress);
return $this->respondSuccess(
new ClassProgressShowResource([
'report' => $class_progress,
'weekly_reports' => $weeklyReports,
'status_options' => $this->metaService->statusOptions(),
]),
'Progress report retrieved.'
);
}
public function update(ClassProgressUpdateRequest $request, ClassProgressReport $class_progress): JsonResponse
{
try {
$updated = $this->mutationService->updateReport($class_progress, $request->validated(), $request->attachments());
} catch (\InvalidArgumentException $e) {
return $this->respondError($e->getMessage(), 422);
} catch (\Throwable $e) {
Log::error('Failed to update class progress report.', [
'error' => $e->getMessage(),
]);
return $this->respondError('Unable to update progress report.', 500);
}
return $this->respondSuccess(new ClassProgressReportResource($updated), 'Progress report updated.');
}
public function destroy(ClassProgressReport $class_progress): JsonResponse
{
try {
$this->mutationService->deleteReport($class_progress);
} catch (\Throwable $e) {
Log::error('Failed to delete class progress report.', [
'error' => $e->getMessage(),
]);
return $this->respondError('Unable to delete progress report.', 500);
}
return $this->respondDeleted(null, 'Progress report deleted.');
}
public function downloadAttachment(int $attachment): BinaryFileResponse|JsonResponse
{
$record = ClassProgressAttachment::query()->find($attachment);
if (! $record || ! $record->file_path) {
return $this->respondError('Attachment not found.', 404);
}
if ($record->report) {
$this->authorize('view', $record->report);
}
$path = $this->attachmentService->resolvePath($record->file_path);
if (! $path) {
return $this->respondError('Attachment missing.', 404);
}
$downloadName = $record->original_name ?: basename($path);
return response()->download($path, $downloadName);
}
public function downloadLegacyAttachment(ClassProgressReport $class_progress): BinaryFileResponse|JsonResponse
{
$this->authorize('view', $class_progress);
if (! $class_progress->attachment_path) {
return $this->respondError('Attachment not found.', 404);
}
$path = $this->attachmentService->resolvePath($class_progress->attachment_path);
if (! $path) {
return $this->respondError('Attachment missing.', 404);
}
return response()->download($path, basename($path));
}
/**
* @param mixed $user
*/
private function requireAuthenticatedUser($user): User|JsonResponse
{
if (! $user instanceof User) {
$user = auth()->user();
}
if (! $user instanceof User) {
$user = $this->laravelRequest->user();
}
if (! $user instanceof User) {
return $this->respondError('Unauthorized.', Response::HTTP_UNAUTHORIZED);
}
return $user;
}
private function buildMetaPayload(?int $classId, int $sundayCount, ?string $schoolYear = null, ?string $semester = null): array
{
$meta = $this->queryService->meta($schoolYear, $semester);
return [
'subject_sections' => $this->metaService->subjectSections(),
'status_options' => $this->metaService->statusOptions(),
'sunday_options' => $this->metaService->sundayOptionsAlignedWithCi($sundayCount),
'curriculum' => $this->metaService->curriculumOptions($classId, $meta['school_year'] ?? null),
'meta' => $meta,
];
}
}
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Classes; namespace App\Http\Controllers\Api\Classes;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Classes\ClassSectionIndexRequest; use App\Http\Requests\Classes\ClassSectionIndexRequest;
use App\Http\Requests\Classes\ClassSectionStoreRequest; use App\Http\Requests\Classes\ClassSectionStoreRequest;
use App\Http\Requests\Classes\ClassSectionUpdateRequest; use App\Http\Requests\Classes\ClassSectionUpdateRequest;
@@ -53,7 +53,7 @@ class ClassController extends BaseApiController
$section = $this->queryService->find((int) $classSection->id); $section = $this->queryService->find((int) $classSection->id);
if (!$section) { if (! $section) {
return $this->error('Class section not found.', Response::HTTP_NOT_FOUND); return $this->error('Class section not found.', Response::HTTP_NOT_FOUND);
} }
@@ -88,7 +88,7 @@ class ClassController extends BaseApiController
{ {
$this->authorize('delete', $classSection); $this->authorize('delete', $classSection);
if (!$this->commandService->delete($classSection)) { if (! $this->commandService->delete($classSection)) {
return $this->error('Class section not found.', Response::HTTP_NOT_FOUND); return $this->error('Class section not found.', Response::HTTP_NOT_FOUND);
} }
@@ -125,7 +125,8 @@ class ClassController extends BaseApiController
try { try {
$created = $this->seedService->seedDefaults(); $created = $this->seedService->seedDefaults();
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('Seeding default classes failed: ' . $e->getMessage()); Log::error('Seeding default classes failed: '.$e->getMessage());
return $this->error('Unable to seed default classes.', Response::HTTP_INTERNAL_SERVER_ERROR); return $this->error('Unable to seed default classes.', Response::HTTP_INTERNAL_SERVER_ERROR);
} }
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Communication; namespace App\Http\Controllers\Api\Communication;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Services\Communication\CommunicationFamilyService; use App\Services\Communication\CommunicationFamilyService;
use App\Services\Communication\CommunicationPreviewService; use App\Services\Communication\CommunicationPreviewService;
use App\Services\Communication\CommunicationSendService; use App\Services\Communication\CommunicationSendService;
@@ -14,9 +14,13 @@ use Illuminate\Http\Request;
class CommunicationController extends BaseApiController class CommunicationController extends BaseApiController
{ {
private CommunicationStudentService $students; private CommunicationStudentService $students;
private CommunicationTemplateService $templates; private CommunicationTemplateService $templates;
private CommunicationFamilyService $families; private CommunicationFamilyService $families;
private CommunicationPreviewService $previewService; private CommunicationPreviewService $previewService;
private CommunicationSendService $sendService; private CommunicationSendService $sendService;
public function __construct( public function __construct(
@@ -76,7 +80,7 @@ class CommunicationController extends BaseApiController
$teacherName = $this->resolveTeacherName(); $teacherName = $this->resolveTeacherName();
$result = $this->previewService->buildPreview($templateKey, $studentId, $familyId, $vars, $teacherName); $result = $this->previewService->buildPreview($templateKey, $studentId, $familyId, $vars, $teacherName);
if (!$result['ok']) { if (! $result['ok']) {
return response()->json([ return response()->json([
'ok' => false, 'ok' => false,
'message' => $result['message'] ?? 'Preview failed.', 'message' => $result['message'] ?? 'Preview failed.',
@@ -116,13 +120,18 @@ class CommunicationController extends BaseApiController
} }
$student = $this->students->getStudentBasic($studentId); $student = $this->students->getStudentBasic($studentId);
if (!$student) { if (! $student) {
return response()->json([ return response()->json([
'ok' => false, 'ok' => false,
'message' => 'Student not found.', 'message' => 'Student not found.',
], 404); ], 404);
} }
$senderId = $this->authenticatedUserIdOrUnauthorized();
if ($senderId instanceof JsonResponse) {
return $senderId;
}
$result = $this->sendService->send([ $result = $this->sendService->send([
'student_id' => $studentId, 'student_id' => $studentId,
'family_id' => $familyId, 'family_id' => $familyId,
@@ -132,11 +141,11 @@ class CommunicationController extends BaseApiController
'recipients' => $recipients, 'recipients' => $recipients,
'cc' => $cc, 'cc' => $cc,
'bcc' => $bcc, 'bcc' => $bcc,
'student_name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')), 'student_name' => trim(($student['firstname'] ?? '').' '.($student['lastname'] ?? '')),
'sent_by' => (int) (auth()->id() ?? 0), 'sent_by' => $senderId,
]); ]);
if (!$result['ok']) { if (! $result['ok']) {
return response()->json([ return response()->json([
'ok' => false, 'ok' => false,
'message' => 'Failed to send email.', 'message' => 'Failed to send email.',
@@ -170,9 +179,21 @@ class CommunicationController extends BaseApiController
{ {
$user = auth()->user(); $user = auth()->user();
if ($user) { if ($user) {
$name = trim(($user->firstname ?? '') . ' ' . ($user->lastname ?? '')); $name = trim(($user->firstname ?? '').' '.($user->lastname ?? ''));
return $name !== '' ? $name : 'Teacher'; return $name !== '' ? $name : 'Teacher';
} }
return 'Teacher'; return 'Teacher';
} }
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
return $userId;
}
} }
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\CompetitionScores; namespace App\Http\Controllers\Api\CompetitionScores;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Models\Competition; use App\Models\Competition;
use App\Services\CompetitionScores\CompetitionScoresContextService; use App\Services\CompetitionScores\CompetitionScoresContextService;
use App\Services\CompetitionScores\CompetitionScoresQueryService; use App\Services\CompetitionScores\CompetitionScoresQueryService;
@@ -14,8 +14,11 @@ use Illuminate\Http\Request;
class CompetitionScoresController extends BaseApiController class CompetitionScoresController extends BaseApiController
{ {
private CompetitionScoresContextService $contextService; private CompetitionScoresContextService $contextService;
private CompetitionScoresQueryService $queryService; private CompetitionScoresQueryService $queryService;
private CompetitionScoresRosterService $rosterService; private CompetitionScoresRosterService $rosterService;
private CompetitionScoresSaveService $saveService; private CompetitionScoresSaveService $saveService;
public function __construct( public function __construct(
@@ -33,8 +36,12 @@ class CompetitionScoresController extends BaseApiController
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$userId = (int) (auth()->id() ?? 0); $guard = $this->authenticatedUserIdOrUnauthorized();
[$assignments, $schoolYear, $semester] = $this->contextService->getTeacherContext($userId); if ($guard instanceof JsonResponse) {
return $guard;
}
[$assignments, $schoolYear, $semester] = $this->contextService->getTeacherContext($guard);
$activeClassId = $this->contextService->resolveActiveClassId( $activeClassId = $this->contextService->resolveActiveClassId(
$assignments, $assignments,
@@ -80,8 +87,12 @@ class CompetitionScoresController extends BaseApiController
public function edit(Request $request, int $id): JsonResponse public function edit(Request $request, int $id): JsonResponse
{ {
$userId = (int) (auth()->id() ?? 0); $guard = $this->authenticatedUserIdOrUnauthorized();
[$assignments, $schoolYear, $semester] = $this->contextService->getTeacherContext($userId); if ($guard instanceof JsonResponse) {
return $guard;
}
[$assignments, $schoolYear, $semester] = $this->contextService->getTeacherContext($guard);
$allowedClassIds = $this->contextService->getAllowedClassIds($assignments); $allowedClassIds = $this->contextService->getAllowedClassIds($assignments);
if (empty($allowedClassIds)) { if (empty($allowedClassIds)) {
@@ -92,7 +103,7 @@ class CompetitionScoresController extends BaseApiController
} }
$competition = Competition::query()->find($id); $competition = Competition::query()->find($id);
if (!$competition) { if (! $competition) {
return response()->json([ return response()->json([
'ok' => false, 'ok' => false,
'message' => 'Competition not found.', 'message' => 'Competition not found.',
@@ -112,7 +123,7 @@ class CompetitionScoresController extends BaseApiController
], 422); ], 422);
} }
if (!in_array($classSectionId, $allowedClassIds, true)) { if (! in_array($classSectionId, $allowedClassIds, true)) {
return response()->json([ return response()->json([
'ok' => false, 'ok' => false,
'message' => 'You are not assigned to that class.', 'message' => 'You are not assigned to that class.',
@@ -146,8 +157,12 @@ class CompetitionScoresController extends BaseApiController
public function save(Request $request, int $id): JsonResponse public function save(Request $request, int $id): JsonResponse
{ {
$userId = (int) (auth()->id() ?? 0); $guard = $this->authenticatedUserIdOrUnauthorized();
[$assignments] = $this->contextService->getTeacherContext($userId); if ($guard instanceof JsonResponse) {
return $guard;
}
[$assignments] = $this->contextService->getTeacherContext($guard);
$allowedClassIds = $this->contextService->getAllowedClassIds($assignments); $allowedClassIds = $this->contextService->getAllowedClassIds($assignments);
if (empty($allowedClassIds)) { if (empty($allowedClassIds)) {
@@ -158,7 +173,7 @@ class CompetitionScoresController extends BaseApiController
} }
$competition = Competition::query()->find($id); $competition = Competition::query()->find($id);
if (!$competition) { if (! $competition) {
return response()->json([ return response()->json([
'ok' => false, 'ok' => false,
'message' => 'Competition not found.', 'message' => 'Competition not found.',
@@ -182,7 +197,7 @@ class CompetitionScoresController extends BaseApiController
], 422); ], 422);
} }
if (!in_array($classSectionId, $allowedClassIds, true)) { if (! in_array($classSectionId, $allowedClassIds, true)) {
return response()->json([ return response()->json([
'ok' => false, 'ok' => false,
'message' => 'You are not assigned to that class.', 'message' => 'You are not assigned to that class.',
@@ -193,7 +208,7 @@ class CompetitionScoresController extends BaseApiController
$scores = is_array($scores) ? $scores : []; $scores = is_array($scores) ? $scores : [];
[$cleanScores, $invalidScores] = $this->saveService->filterScores($scores); [$cleanScores, $invalidScores] = $this->saveService->filterScores($scores);
if (!empty($invalidScores)) { if (! empty($invalidScores)) {
return response()->json([ return response()->json([
'ok' => false, 'ok' => false,
'message' => 'Scores must be whole numbers (no decimals).', 'message' => 'Scores must be whole numbers (no decimals).',
@@ -209,4 +224,14 @@ class CompetitionScoresController extends BaseApiController
'savedCount' => count($cleanScores), 'savedCount' => count($cleanScores),
]); ]);
} }
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
return $userId;
}
} }
@@ -0,0 +1,305 @@
<?php
namespace App\Http\Controllers\Api\Core;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Contracts\Validation\Validator as ValidatorContract;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\Response;
class BaseApiController extends Controller
{
protected Request $laravelRequest;
protected ?ValidatorContract $validator = null;
public function __construct()
{
$this->laravelRequest = request();
}
protected function success($data = null, string $message = 'Success', int $code = Response::HTTP_OK): JsonResponse
{
return response()->json([
'status' => true,
'message' => $message,
'data' => $data,
], $code);
}
protected function error(string $message = 'An error occurred', int $code = Response::HTTP_BAD_REQUEST, ?array $errors = null): JsonResponse
{
$payload = [
'status' => false,
'message' => $message,
];
if (! empty($errors)) {
$payload['errors'] = $errors;
}
return response()->json($payload, $code);
}
protected function respondSuccess($data = null, string $message = 'Success', int $code = Response::HTTP_OK): JsonResponse
{
return $this->success($data, $message, $code);
}
protected function respondCreated($data = null, string $message = 'Resource created successfully'): JsonResponse
{
return $this->success($data, $message, Response::HTTP_CREATED);
}
protected function respondDeleted($data = null, string $message = 'Resource deleted successfully'): JsonResponse
{
return $this->success($data, $message, Response::HTTP_OK);
}
protected function respondError(string $message = 'An error occurred', int $code = Response::HTTP_BAD_REQUEST): JsonResponse
{
return $this->error($message, $code);
}
protected function respondValidationError(array $errors, string $message = 'Validation failed'): JsonResponse
{
return $this->error($message, Response::HTTP_UNPROCESSABLE_ENTITY, $errors);
}
protected function validateRequest(array $data, array $rules): array
{
$normalized = [];
foreach ($rules as $field => $ruleSet) {
$normalized[$field] = $this->normalizeRules($ruleSet);
}
$validator = Validator::make($data, $normalized);
$this->validator = $validator;
if ($validator->fails()) {
return $validator->errors()->toArray();
}
return [];
}
protected function validate(array $rules): bool
{
$payload = $this->payloadData();
$errors = $this->validateRequest($payload, $rules);
return empty($errors);
}
protected function payloadData(): array
{
$json = json_decode($this->laravelRequest->getContent() ?: '', true);
if (is_array($json)) {
return $json;
}
return array_merge($this->laravelRequest->query->all(), $this->laravelRequest->request->all());
}
protected function normalizeRules(array|string $ruleSet): array|string
{
if (is_array($ruleSet)) {
return $ruleSet;
}
$parts = explode('|', $ruleSet);
$result = [];
foreach ($parts as $part) {
$part = trim($part);
if ($part === '') {
continue;
}
if (preg_match('/^max_length\\[(\\d+)\\]$/', $part, $m)) {
$result[] = 'max:'.$m[1];
continue;
}
if (preg_match('/^min_length\\[(\\d+)\\]$/', $part, $m)) {
$result[] = 'min:'.$m[1];
continue;
}
if (preg_match('/^in_list\\[(.+)\\]$/', $part, $m)) {
$result[] = 'in:'.$m[1];
continue;
}
if (preg_match('/^greater_than_equal_to\\[(.+)\\]$/', $part, $m)) {
$result[] = 'gte:'.$m[1];
continue;
}
if (preg_match('/^less_than_equal_to\\[(.+)\\]$/', $part, $m)) {
$result[] = 'lte:'.$m[1];
continue;
}
if (preg_match('/^valid_date\\[(.+)\\]$/', $part, $m)) {
$result[] = 'date_format:'.$m[1];
continue;
}
if ($part === 'valid_email') {
$result[] = 'email';
continue;
}
if ($part === 'permit_empty') {
$result[] = 'nullable';
continue;
}
if (preg_match('/^is_unique\\[([^\\.]+)\\.([^\\]]+)\\]$/', $part, $m)) {
$result[] = 'unique:'.$m[1].','.$m[2];
continue;
}
if ($part === 'string') {
$result[] = 'string';
continue;
}
if ($part === 'integer') {
$result[] = 'integer';
continue;
}
if ($part === 'numeric') {
$result[] = 'numeric';
continue;
}
if ($part === 'required') {
$result[] = 'required';
continue;
}
if ($part === 'alpha_numeric') {
$result[] = 'alpha_num';
continue;
}
if ($part === 'in_array') {
$result[] = 'in_array';
continue;
}
if ($part === 'valid_url') {
$result[] = 'url';
continue;
}
if (strpos($part, 'max_size') === 0) {
$result[] = str_replace('max_size', 'max', $part);
continue;
}
$result[] = $part;
}
return $result;
}
protected function paginate($source, int $page = 1, int $perPage = 20): array
{
$page = max(1, $page);
$perPage = max(1, $perPage);
$offset = ($page - 1) * $perPage;
if ($source instanceof EloquentBuilder || $source instanceof Builder) {
$total = $source->toBase()->getCountForPagination();
$items = $source->offset($offset)->limit($perPage)->get()->toArray();
} elseif (is_array($source)) {
$total = count($source);
$items = array_slice($source, $offset, $perPage);
} else {
return [
'data' => [],
'pagination' => [
'current_page' => $page,
'per_page' => $perPage,
'total' => 0,
'total_pages' => 0,
],
];
}
return [
'data' => array_values($items),
'pagination' => [
'current_page' => $page,
'per_page' => $perPage,
'total' => $total,
'total_pages' => (int) ceil($total / $perPage),
],
];
}
protected function getCurrentUserId(): ?int
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
$userId = (int) (optional(auth()->user())->id ?? 0);
}
if ($userId <= 0) {
$userId = (int) (optional($this->laravelRequest->user())->id ?? 0);
}
return $userId > 0 ? $userId : null;
}
protected function getCurrentUser(): ?object
{
$userId = $this->getCurrentUserId();
if (! $userId) {
return null;
}
/** @var User|null $row */
$row = User::query()->find($userId);
if (! $row) {
return null;
}
$firstname = (string) ($row->firstname ?? '');
$lastname = (string) ($row->lastname ?? '');
$email = $row->email;
$name = trim($firstname.' '.$lastname);
if ($name === '') {
$name = $email ?? 'User #'.$userId;
}
try {
$roles = DB::table('user_roles as ur')
->join('roles as r', 'r.id', '=', 'ur.role_id')
->where('ur.user_id', $userId)
->whereNull('ur.deleted_at')
->pluck('r.name')
->map(fn ($name) => strtolower((string) $name))
->unique()
->values()
->all();
} catch (\Throwable $e) {
Log::error('Unable to load user roles: '.$e->getMessage());
$roles = [];
}
return (object) [
'id' => (int) $userId,
'name' => $name,
'email' => $email,
'roles' => $roles,
];
}
}
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Discounts; namespace App\Http\Controllers\Api\Discounts;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Discounts\ApplyDiscountVoucherRequest; use App\Http\Requests\Discounts\ApplyDiscountVoucherRequest;
use App\Http\Requests\Discounts\StoreDiscountVoucherRequest; use App\Http\Requests\Discounts\StoreDiscountVoucherRequest;
use App\Http\Requests\Discounts\UpdateDiscountVoucherRequest; use App\Http\Requests\Discounts\UpdateDiscountVoucherRequest;
@@ -17,8 +17,11 @@ use Illuminate\Http\JsonResponse;
class DiscountController extends BaseApiController class DiscountController extends BaseApiController
{ {
private DiscountContextService $context; private DiscountContextService $context;
private DiscountVoucherService $voucherService; private DiscountVoucherService $voucherService;
private DiscountParentService $parentService; private DiscountParentService $parentService;
private DiscountApplyService $applyService; private DiscountApplyService $applyService;
public function __construct( public function __construct(
@@ -50,12 +53,17 @@ class DiscountController extends BaseApiController
public function apply(ApplyDiscountVoucherRequest $request): JsonResponse public function apply(ApplyDiscountVoucherRequest $request): JsonResponse
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated(); $payload = $request->validated();
$result = $this->applyService->applyVoucher( $result = $this->applyService->applyVoucher(
(int) $payload['voucher_id'], (int) $payload['voucher_id'],
$payload['parent_ids'], $payload['parent_ids'],
(bool) ($payload['allow_additional'] ?? false), (bool) ($payload['allow_additional'] ?? false),
(int) (auth()->id() ?? 0) $guard
); );
return response()->json($result, $result['ok'] ? 200 : 422); return response()->json($result, $result['ok'] ? 200 : 422);
@@ -64,6 +72,7 @@ class DiscountController extends BaseApiController
public function listVouchers(): JsonResponse public function listVouchers(): JsonResponse
{ {
$vouchers = $this->voucherService->listAll(); $vouchers = $this->voucherService->listAll();
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'vouchers' => DiscountVoucherResource::collection($vouchers), 'vouchers' => DiscountVoucherResource::collection($vouchers),
@@ -73,6 +82,7 @@ class DiscountController extends BaseApiController
public function storeVoucher(StoreDiscountVoucherRequest $request): JsonResponse public function storeVoucher(StoreDiscountVoucherRequest $request): JsonResponse
{ {
$voucher = $this->voucherService->create($request->validated()); $voucher = $this->voucherService->create($request->validated());
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'voucher' => new DiscountVoucherResource($voucher), 'voucher' => new DiscountVoucherResource($voucher),
@@ -82,6 +92,7 @@ class DiscountController extends BaseApiController
public function updateVoucher(UpdateDiscountVoucherRequest $request, int $id): JsonResponse public function updateVoucher(UpdateDiscountVoucherRequest $request, int $id): JsonResponse
{ {
$voucher = $this->voucherService->update($id, $request->validated()); $voucher = $this->voucherService->update($id, $request->validated());
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'voucher' => new DiscountVoucherResource($voucher), 'voucher' => new DiscountVoucherResource($voucher),
@@ -91,6 +102,7 @@ class DiscountController extends BaseApiController
public function showVoucher(int $id): JsonResponse public function showVoucher(int $id): JsonResponse
{ {
$voucher = $this->voucherService->find($id); $voucher = $this->voucherService->find($id);
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'voucher' => new DiscountVoucherResource($voucher), 'voucher' => new DiscountVoucherResource($voucher),
@@ -100,8 +112,19 @@ class DiscountController extends BaseApiController
public function deleteVoucher(int $id): JsonResponse public function deleteVoucher(int $id): JsonResponse
{ {
$this->voucherService->delete($id); $this->voucherService->delete($id);
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
]); ]);
} }
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
return $userId;
}
} }
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers\Api\Documentation;
use App\Http\Controllers\Controller;
use App\Services\Docs\ApiDocsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
/**
* Authenticated API explorer bootstrap + public read-only variant.
*/
class ApiDocsAdminController extends Controller
{
public function __construct(
private ApiDocsService $apiDocs,
) {}
public function index(): JsonResponse
{
return response()->json(
$this->apiDocs->bootstrap(Auth::user(), false),
);
}
public function public(): JsonResponse
{
return response()->json(
$this->apiDocs->bootstrap(null, true),
);
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Api\Documentation;
use App\Http\Controllers\Controller;
use App\Services\Docs\DocsCatalogService;
use App\Services\Docs\OpenApiRouteExporter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Docs hub + multi-spec Swagger catalog (JSON for SPA).
*/
class DocsCatalogController extends Controller
{
public function __construct(
private DocsCatalogService $catalog,
) {}
public function index(Request $request)
{
if (! $request->expectsJson()) {
return redirect()->to((string) config('docs.client_url', url('/docs')));
}
return response()->json([
'status' => true,
'data' => $this->catalog->indexPayload(),
]);
}
public function ui(): View
{
return view('docs.swagger');
}
public function swagger(): JsonResponse
{
return response()->json([
'status' => true,
'data' => $this->catalog->swaggerSpecsPayload(),
]);
}
/**
* Merged OpenAPI spec (static openapi.json + live route merge).
*/
public function swaggerMergedJson(): JsonResponse
{
$path = resource_path('docs/openapi.json');
abort_unless(is_file($path), 404);
$spec = app(OpenApiRouteExporter::class)->exportFromFile($path);
return response()->json($spec, 200, [
'Content-Type' => 'application/json',
'Cache-Control' => 'no-store, max-age=0',
]);
}
}
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Email; namespace App\Http\Controllers\Api\Email;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Services\BroadcastEmail\BroadcastEmailComposerService; use App\Services\BroadcastEmail\BroadcastEmailComposerService;
use App\Services\BroadcastEmail\BroadcastEmailDispatchService; use App\Services\BroadcastEmail\BroadcastEmailDispatchService;
use App\Services\BroadcastEmail\BroadcastEmailImageService; use App\Services\BroadcastEmail\BroadcastEmailImageService;
@@ -14,9 +14,13 @@ use Illuminate\Http\Request;
class BroadcastEmailController extends BaseApiController class BroadcastEmailController extends BaseApiController
{ {
private BroadcastEmailSenderOptionsService $senderOptions; private BroadcastEmailSenderOptionsService $senderOptions;
private BroadcastEmailRecipientService $recipients; private BroadcastEmailRecipientService $recipients;
private BroadcastEmailComposerService $composer; private BroadcastEmailComposerService $composer;
private BroadcastEmailDispatchService $dispatch; private BroadcastEmailDispatchService $dispatch;
private BroadcastEmailImageService $imageService; private BroadcastEmailImageService $imageService;
public function __construct( public function __construct(
@@ -140,7 +144,7 @@ class BroadcastEmailController extends BaseApiController
public function uploadImage(Request $request): JsonResponse public function uploadImage(Request $request): JsonResponse
{ {
$file = $request->file('image'); $file = $request->file('image');
if (!$file || !$file->isValid()) { if (! $file || ! $file->isValid()) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'error' => 'No image uploaded', 'error' => 'No image uploaded',
@@ -148,7 +152,7 @@ class BroadcastEmailController extends BaseApiController
} }
$result = $this->imageService->store($file); $result = $this->imageService->store($file);
if (!$result['ok']) { if (! $result['ok']) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'error' => $result['error'] ?? 'Upload failed', 'error' => $result['error'] ?? 'Upload failed',
@@ -174,6 +178,7 @@ class BroadcastEmailController extends BaseApiController
foreach (explode(',', $value) as $piece) { foreach (explode(',', $value) as $piece) {
$ids[] = (int) $piece; $ids[] = (int) $piece;
} }
continue; continue;
} }
$ids[] = (int) $value; $ids[] = (int) $value;
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Email; namespace App\Http\Controllers\Api\Email;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Email\EmailSendRequest; use App\Http\Requests\Email\EmailSendRequest;
use App\Http\Resources\Email\EmailSenderResource; use App\Http\Resources\Email\EmailSenderResource;
use App\Services\Email\EmailAttachmentService; use App\Services\Email\EmailAttachmentService;
@@ -46,7 +46,7 @@ class EmailController extends BaseApiController
$attachments $attachments
); );
if (!$ok) { if (! $ok) {
return response()->json([ return response()->json([
'ok' => false, 'ok' => false,
'message' => 'Email failed to send.', 'message' => 'Email failed to send.',
@@ -2,13 +2,12 @@
namespace App\Http\Controllers\Api\Email; namespace App\Http\Controllers\Api\Email;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Email\EmailExtractorCompareRequest; use App\Http\Requests\Email\EmailExtractorCompareRequest;
use App\Http\Resources\Email\EmailExtractorCompareResource; use App\Http\Resources\Email\EmailExtractorCompareResource;
use App\Http\Resources\Email\EmailExtractorEmailsResource; use App\Http\Resources\Email\EmailExtractorEmailsResource;
use App\Services\Email\EmailExtractorService; use App\Services\Email\EmailExtractorService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EmailExtractorController extends BaseApiController class EmailExtractorController extends BaseApiController
{ {
@@ -23,6 +22,8 @@ class EmailExtractorController extends BaseApiController
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'users' => $emails['users'] ?? [],
'parents' => $emails['parents'] ?? [],
'emails' => new EmailExtractorEmailsResource($emails), 'emails' => new EmailExtractorEmailsResource($emails),
]); ]);
} }
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Exams; namespace App\Http\Controllers\Api\Exams;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Exams\ExamDraftAdminLegacyRequest; use App\Http\Requests\Exams\ExamDraftAdminLegacyRequest;
use App\Http\Requests\Exams\ExamDraftAdminReviewRequest; use App\Http\Requests\Exams\ExamDraftAdminReviewRequest;
use App\Http\Requests\Exams\ExamDraftTeacherStoreRequest; use App\Http\Requests\Exams\ExamDraftTeacherStoreRequest;
@@ -22,12 +22,12 @@ class ExamDraftController extends BaseApiController
public function teacherIndex(): JsonResponse public function teacherIndex(): JsonResponse
{ {
$teacherId = (int) (auth()->id() ?? 0); $guard = $this->authenticatedUserIdOrUnauthorized();
if ($teacherId <= 0) { if ($guard instanceof JsonResponse) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); return $guard;
} }
$data = $this->teacherService->listForTeacher($teacherId); $data = $this->teacherService->listForTeacher($guard);
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
@@ -43,13 +43,13 @@ class ExamDraftController extends BaseApiController
public function teacherStore(ExamDraftTeacherStoreRequest $request): JsonResponse public function teacherStore(ExamDraftTeacherStoreRequest $request): JsonResponse
{ {
$teacherId = (int) (auth()->id() ?? 0); $guard = $this->authenticatedUserIdOrUnauthorized();
if ($teacherId <= 0) { if ($guard instanceof JsonResponse) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); return $guard;
} }
$result = $this->teacherService->store($teacherId, $request->validated(), $request->file('draft_file')); $result = $this->teacherService->store($guard, $request->validated(), $request->file('draft_file'));
if (!$result['ok']) { if (! $result['ok']) {
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422); return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422);
} }
@@ -79,13 +79,13 @@ class ExamDraftController extends BaseApiController
public function adminUploadLegacy(ExamDraftAdminLegacyRequest $request): JsonResponse public function adminUploadLegacy(ExamDraftAdminLegacyRequest $request): JsonResponse
{ {
$adminId = (int) (auth()->id() ?? 0); $guard = $this->authenticatedUserIdOrUnauthorized();
if ($adminId <= 0) { if ($guard instanceof JsonResponse) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); return $guard;
} }
$result = $this->adminService->uploadLegacy($adminId, $request->validated(), $request->file('old_exam_file')); $result = $this->adminService->uploadLegacy($guard, $request->validated(), $request->file('old_exam_file'));
if (!$result['ok']) { if (! $result['ok']) {
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422); return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422);
} }
@@ -97,13 +97,13 @@ class ExamDraftController extends BaseApiController
public function adminReview(ExamDraftAdminReviewRequest $request): JsonResponse public function adminReview(ExamDraftAdminReviewRequest $request): JsonResponse
{ {
$adminId = (int) (auth()->id() ?? 0); $guard = $this->authenticatedUserIdOrUnauthorized();
if ($adminId <= 0) { if ($guard instanceof JsonResponse) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); return $guard;
} }
$result = $this->adminService->review($adminId, $request->validated(), $request->file('final_file')); $result = $this->adminService->review($guard, $request->validated(), $request->file('final_file'));
if (!$result['ok']) { if (! $result['ok']) {
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422); return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422);
} }
@@ -112,4 +112,14 @@ class ExamDraftController extends BaseApiController
'draft' => new ExamDraftResource($result['draft']), 'draft' => new ExamDraftResource($result['draft']),
]); ]);
} }
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
return $userId;
}
} }
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Expenses; namespace App\Http\Controllers\Api\Expenses;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Expenses\StoreExpenseRequest; use App\Http\Requests\Expenses\StoreExpenseRequest;
use App\Http\Requests\Expenses\UpdateExpenseRequest; use App\Http\Requests\Expenses\UpdateExpenseRequest;
use App\Http\Requests\Expenses\UpdateExpenseStatusRequest; use App\Http\Requests\Expenses\UpdateExpenseStatusRequest;
@@ -51,6 +51,7 @@ class ExpenseController extends BaseApiController
$rows = $this->queryService->listAll(); $rows = $this->queryService->listAll();
$rows = array_map(function ($row) { $rows = array_map(function ($row) {
$row['receipt_url'] = $this->receiptService->receiptUrl($row['receipt_path'] ?? null); $row['receipt_url'] = $this->receiptService->receiptUrl($row['receipt_path'] ?? null);
return $row; return $row;
}, $rows); }, $rows);
@@ -63,7 +64,7 @@ class ExpenseController extends BaseApiController
public function show(int $id): JsonResponse public function show(int $id): JsonResponse
{ {
$row = $this->queryService->findById($id); $row = $this->queryService->findById($id);
if (!$row) { if (! $row) {
return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404); return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404);
} }
@@ -77,8 +78,12 @@ class ExpenseController extends BaseApiController
public function store(StoreExpenseRequest $request): JsonResponse public function store(StoreExpenseRequest $request): JsonResponse
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated(); $payload = $request->validated();
$userId = (int) (auth()->id() ?? 0);
$receiptName = null; $receiptName = null;
if ($request->hasFile('receipt')) { if ($request->hasFile('receipt')) {
@@ -95,10 +100,10 @@ class ExpenseController extends BaseApiController
'retailor' => $payload['retailor'] ?? null, 'retailor' => $payload['retailor'] ?? null,
'date_of_purchase' => $payload['date_of_purchase'], 'date_of_purchase' => $payload['date_of_purchase'],
'purchased_by' => (int) $payload['purchased_by'], 'purchased_by' => (int) $payload['purchased_by'],
'added_by' => $userId, 'added_by' => $guard,
'status' => $isDonation ? 'approved' : 'pending', 'status' => $isDonation ? 'approved' : 'pending',
'status_reason' => $isDonation ? 'Marked as Donation (non-reimbursable).' : null, 'status_reason' => $isDonation ? 'Marked as Donation (non-reimbursable).' : null,
'approved_by' => $isDonation ? $userId : null, 'approved_by' => $isDonation ? $guard : null,
'school_year' => $payload['school_year'] ?? null, 'school_year' => $payload['school_year'] ?? null,
'semester' => $payload['semester'] ?? null, 'semester' => $payload['semester'] ?? null,
]); ]);
@@ -111,19 +116,23 @@ class ExpenseController extends BaseApiController
public function update(UpdateExpenseRequest $request, int $id): JsonResponse public function update(UpdateExpenseRequest $request, int $id): JsonResponse
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$expense = Expense::query()->find($id); $expense = Expense::query()->find($id);
if (!$expense) { if (! $expense) {
return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404); return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404);
} }
$payload = $request->validated(); $payload = $request->validated();
$userId = (int) (auth()->id() ?? 0);
$receiptName = $expense->receipt_path; $receiptName = $expense->receipt_path;
if ($request->hasFile('receipt')) { if ($request->hasFile('receipt')) {
$receiptName = $this->receiptService->storeReceipt($request->file('receipt')); $receiptName = $this->receiptService->storeReceipt($request->file('receipt'));
} }
if (!empty($payload['remove_receipt'])) { if (! empty($payload['remove_receipt'])) {
$receiptName = null; $receiptName = null;
} }
@@ -138,13 +147,13 @@ class ExpenseController extends BaseApiController
'date_of_purchase' => $payload['date_of_purchase'], 'date_of_purchase' => $payload['date_of_purchase'],
'purchased_by' => (int) $payload['purchased_by'], 'purchased_by' => (int) $payload['purchased_by'],
'receipt_path' => $receiptName, 'receipt_path' => $receiptName,
'updated_by' => $userId, 'updated_by' => $guard,
]; ];
if ($isDonation) { if ($isDonation) {
$updateData['status'] = 'approved'; $updateData['status'] = 'approved';
$updateData['status_reason'] = 'Marked as Donation (non-reimbursable).'; $updateData['status_reason'] = 'Marked as Donation (non-reimbursable).';
$updateData['approved_by'] = $userId ?: null; $updateData['approved_by'] = $guard ?: null;
$updateData['reimbursement_id'] = null; $updateData['reimbursement_id'] = null;
} elseif (($expense->category ?? '') === 'Donation') { } elseif (($expense->category ?? '') === 'Donation') {
$updateData['status_reason'] = null; $updateData['status_reason'] = null;
@@ -162,19 +171,33 @@ class ExpenseController extends BaseApiController
public function updateStatus(UpdateExpenseStatusRequest $request, int $id): JsonResponse public function updateStatus(UpdateExpenseStatusRequest $request, int $id): JsonResponse
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$expense = Expense::query()->find($id); $expense = Expense::query()->find($id);
if (!$expense) { if (! $expense) {
return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404); return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404);
} }
$payload = $request->validated(); $payload = $request->validated();
$userId = (int) (auth()->id() ?? 0);
$updated = $this->statusService->updateStatus($expense, $payload['status'], $payload['reason'] ?? '', $userId); $updated = $this->statusService->updateStatus($expense, $payload['status'], $payload['reason'] ?? '', $guard);
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'expense' => new ExpenseResource($updated->toArray()), 'expense' => new ExpenseResource($updated->toArray()),
]); ]);
} }
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
return $userId;
}
} }
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\ExtraCharges; namespace App\Http\Controllers\Api\ExtraCharges;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\ExtraCharges\StoreExtraChargeRequest; use App\Http\Requests\ExtraCharges\StoreExtraChargeRequest;
use App\Http\Requests\ExtraCharges\UpdateExtraChargeRequest; use App\Http\Requests\ExtraCharges\UpdateExtraChargeRequest;
use App\Http\Resources\ExtraCharges\ExtraChargeInvoiceOptionResource; use App\Http\Resources\ExtraCharges\ExtraChargeInvoiceOptionResource;
@@ -87,8 +87,13 @@ class ExtraChargesController extends BaseApiController
public function store(StoreExtraChargeRequest $request): JsonResponse public function store(StoreExtraChargeRequest $request): JsonResponse
{ {
$userId = $this->authenticatedUserIdOrUnauthorized();
if ($userId instanceof JsonResponse) {
return $userId;
}
$payload = $request->validated(); $payload = $request->validated();
$payload['created_by'] = (int) (auth()->id() ?? 0); $payload['created_by'] = $userId;
$result = $this->chargeService->createCharge($payload); $result = $this->chargeService->createCharge($payload);
@@ -98,12 +103,17 @@ class ExtraChargesController extends BaseApiController
public function update(UpdateExtraChargeRequest $request, int $id): JsonResponse public function update(UpdateExtraChargeRequest $request, int $id): JsonResponse
{ {
$charge = AdditionalCharge::query()->find($id); $charge = AdditionalCharge::query()->find($id);
if (!$charge) { if (! $charge) {
return response()->json(['ok' => false, 'error' => 'Charge not found'], 404); return response()->json(['ok' => false, 'error' => 'Charge not found'], 404);
} }
$userId = $this->authenticatedUserIdOrUnauthorized();
if ($userId instanceof JsonResponse) {
return $userId;
}
$payload = $request->validated(); $payload = $request->validated();
$payload['updated_by'] = (int) (auth()->id() ?? 0); $payload['updated_by'] = $userId;
$ok = $this->chargeService->updateCharge($charge, $payload); $ok = $this->chargeService->updateCharge($charge, $payload);
@@ -116,7 +126,7 @@ class ExtraChargesController extends BaseApiController
public function void(int $id): JsonResponse public function void(int $id): JsonResponse
{ {
$charge = AdditionalCharge::query()->find($id); $charge = AdditionalCharge::query()->find($id);
if (!$charge) { if (! $charge) {
return response()->json(['ok' => false, 'error' => 'Charge not found'], 404); return response()->json(['ok' => false, 'error' => 'Charge not found'], 404);
} }
@@ -130,7 +140,7 @@ class ExtraChargesController extends BaseApiController
public function reverse(int $id): JsonResponse public function reverse(int $id): JsonResponse
{ {
$charge = AdditionalCharge::query()->find($id); $charge = AdditionalCharge::query()->find($id);
if (!$charge) { if (! $charge) {
return response()->json(['ok' => false, 'error' => 'Charge not found'], 404); return response()->json(['ok' => false, 'error' => 'Charge not found'], 404);
} }
@@ -140,4 +150,14 @@ class ExtraChargesController extends BaseApiController
'ok' => $ok, 'ok' => $ok,
], $ok ? 200 : 422); ], $ok ? 200 : 422);
} }
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
return $userId;
}
} }
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Family; namespace App\Http\Controllers\Api\Family;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Families\FamilyAdminCardRequest; use App\Http\Requests\Families\FamilyAdminCardRequest;
use App\Http\Requests\Families\FamilyAdminIndexRequest; use App\Http\Requests\Families\FamilyAdminIndexRequest;
use App\Http\Requests\Families\FamilyAdminSearchRequest; use App\Http\Requests\Families\FamilyAdminSearchRequest;
@@ -72,7 +72,7 @@ class FamilyAdminController extends BaseApiController
$schoolYear $schoolYear
); );
if (!$family) { if (! $family) {
return $this->error('Family not found.', Response::HTTP_NOT_FOUND); return $this->error('Family not found.', Response::HTTP_NOT_FOUND);
} }
@@ -92,8 +92,9 @@ class FamilyAdminController extends BaseApiController
$payload['reply_to_name'] ?? null $payload['reply_to_name'] ?? null
); );
if (!$ok) { if (! $ok) {
Log::warning('Compose email failed for family admin', ['recipient' => $payload['recipient']]); Log::warning('Compose email failed for family admin', ['recipient' => $payload['recipient']]);
return $this->error('Unable to send email.', Response::HTTP_INTERNAL_SERVER_ERROR); return $this->error('Unable to send email.', Response::HTTP_INTERNAL_SERVER_ERROR);
} }
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Family; namespace App\Http\Controllers\Api\Family;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Families\FamiliesByStudentRequest; use App\Http\Requests\Families\FamiliesByStudentRequest;
use App\Http\Requests\Families\FamilyAttachSecondByEmailRequest; use App\Http\Requests\Families\FamilyAttachSecondByEmailRequest;
use App\Http\Requests\Families\FamilyAttachSecondByUserRequest; use App\Http\Requests\Families\FamilyAttachSecondByUserRequest;
@@ -53,7 +53,8 @@ class FamilyController extends BaseApiController
try { try {
$result = $this->mutationService->bootstrapFamilies(); $result = $this->mutationService->bootstrapFamilies();
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('Family bootstrap failed: ' . $e->getMessage()); Log::error('Family bootstrap failed: '.$e->getMessage());
return $this->error('Family bootstrap failed.', Response::HTTP_INTERNAL_SERVER_ERROR); return $this->error('Family bootstrap failed.', Response::HTTP_INTERNAL_SERVER_ERROR);
} }
@@ -69,7 +70,7 @@ class FamilyController extends BaseApiController
(string) ($payload['relation'] ?? 'secondary') (string) ($payload['relation'] ?? 'secondary')
); );
if (!($result['ok'] ?? false)) { if (! ($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Unable to attach guardian.', Response::HTTP_UNPROCESSABLE_ENTITY); return $this->error($result['message'] ?? 'Unable to attach guardian.', Response::HTTP_UNPROCESSABLE_ENTITY);
} }
@@ -88,11 +89,12 @@ class FamilyController extends BaseApiController
(string) ($payload['relation'] ?? 'secondary') (string) ($payload['relation'] ?? 'secondary')
); );
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('Attach guardian by email failed: ' . $e->getMessage()); Log::error('Attach guardian by email failed: '.$e->getMessage());
return $this->error('Unable to attach guardian.', Response::HTTP_INTERNAL_SERVER_ERROR); return $this->error('Unable to attach guardian.', Response::HTTP_INTERNAL_SERVER_ERROR);
} }
if (!($result['ok'] ?? false)) { if (! ($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Unable to attach guardian.', Response::HTTP_UNPROCESSABLE_ENTITY); return $this->error($result['message'] ?? 'Unable to attach guardian.', Response::HTTP_UNPROCESSABLE_ENTITY);
} }
@@ -156,11 +158,12 @@ class FamilyController extends BaseApiController
try { try {
$result = $this->mutationService->importSecondParentsFromLegacy(); $result = $this->mutationService->importSecondParentsFromLegacy();
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('Legacy second parent import failed: ' . $e->getMessage()); Log::error('Legacy second parent import failed: '.$e->getMessage());
return $this->error('Legacy import failed.', Response::HTTP_INTERNAL_SERVER_ERROR); return $this->error('Legacy import failed.', Response::HTTP_INTERNAL_SERVER_ERROR);
} }
if (!($result['ok'] ?? true)) { if (! ($result['ok'] ?? true)) {
return $this->error($result['message'] ?? 'Legacy import failed.', Response::HTTP_UNPROCESSABLE_ENTITY); return $this->error($result['message'] ?? 'Legacy import failed.', Response::HTTP_UNPROCESSABLE_ENTITY);
} }
@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Controller;
use App\Http\Requests\Finance\CarryforwardAdjustmentRequest;
use App\Http\Requests\Finance\CarryforwardFinalizeRequest;
use App\Http\Requests\Finance\CarryforwardPreviewRequest;
use App\Services\Finance\PriorYearBalanceCarryforwardService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class BalanceCarryforwardController extends Controller
{
public function __construct(private PriorYearBalanceCarryforwardService $service) {}
public function preview(CarryforwardPreviewRequest $request): JsonResponse
{
return response()->json(['ok' => true, 'preview' => $this->service->preview($request->validated())]);
}
public function storeDraft(CarryforwardFinalizeRequest $request): JsonResponse
{
return response()->json(['ok' => true, 'result' => $this->service->storeDrafts($request->validated(), optional($request->user())->id)]);
}
public function approve(Request $request, int $carryforward): JsonResponse
{
return response()->json(['ok' => true, 'carryforward' => $this->service->approve($carryforward, optional($request->user())->id)]);
}
public function postToNewYear(Request $request, int $carryforward): JsonResponse
{
return response()->json(['ok' => true, 'carryforward' => $this->service->postToNewYear($carryforward, optional($request->user())->id)]);
}
public function waive(CarryforwardAdjustmentRequest $request, int $carryforward): JsonResponse
{
$data = $request->validated();
return response()->json(['ok' => true, 'carryforward' => $this->service->waive($carryforward, (float) $data['amount'], $data['reason'] ?? null, optional($request->user())->id)]);
}
public function adjust(CarryforwardAdjustmentRequest $request, int $carryforward): JsonResponse
{
$data = $request->validated();
return response()->json(['ok' => true, 'carryforward' => $this->service->adjust($carryforward, (float) $data['amount'], $data['reason'] ?? null)]);
}
public function report(Request $request): JsonResponse
{
return response()->json(['ok' => true, 'report' => $this->service->report($request->query())]);
}
public function reportCsv(Request $request): StreamedResponse
{
$rows = $this->service->csvRows($this->service->report($request->query()));
return response()->streamDownload(function () use ($rows) {
$out = fopen('php://output', 'w');
foreach ($rows as $row) {
fputcsv($out, $row);
}
fclose($out);
}, 'carryforward_report_'.date('Ymd_His').'.csv', ['Content-Type' => 'text/csv']);
}
}
@@ -0,0 +1,155 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Finance\ApplyExtraChargeRequest;
use App\Http\Requests\Finance\ChargeListRequest;
use App\Http\Requests\Finance\MarkEventChargePaidRequest;
use App\Http\Requests\Finance\StoreEventChargeRequest;
use App\Http\Requests\Finance\StoreExtraChargeRequest;
use App\Http\Resources\Fees\ChargeActionResource;
use App\Http\Resources\Fees\ChargeListResource;
use App\Services\Billing\ChargeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class ChargeController extends BaseApiController
{
public function __construct(private ChargeService $charges)
{
parent::__construct();
}
public function index(ChargeListRequest $request): JsonResponse
{
$payload = $request->validated();
$result = $this->charges->listForParent(
(int) $payload['parent_id'],
$payload['school_year'] ?? null
);
return response()->json([
'ok' => true,
'charges' => new ChargeListResource($result),
]);
}
public function storeExtra(StoreExtraChargeRequest $request): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated();
$payload['created_by'] = $guard;
try {
$result = $this->charges->createExtraCharge($payload);
} catch (\Throwable $e) {
Log::error('Store extra charge failed', ['exception' => $e]);
return response()->json(['ok' => false, 'message' => 'Unable to create extra charge.'], 500);
}
$status = ($result['ok'] ?? false)
? (isset($result['error']) && $result['error'] === 'duplicate_charge' ? 409 : 201)
: 422;
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'charge' => new ChargeActionResource($result),
], $status);
}
public function storeEvent(StoreEventChargeRequest $request): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated();
$payload['created_by'] = $guard;
try {
$result = $this->charges->createEventCharge($payload);
} catch (\Throwable $e) {
Log::error('Store event charge failed', ['exception' => $e]);
return response()->json(['ok' => false, 'message' => 'Unable to create event charge.'], 500);
}
$status = ($result['ok'] ?? false)
? (isset($result['error']) && $result['error'] === 'duplicate_charge' ? 409 : 201)
: 422;
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'charge' => new ChargeActionResource($result),
], $status);
}
public function cancelExtra(int $id): JsonResponse
{
$result = $this->charges->cancelExtraCharge($id);
$status = ($result['ok'] ?? false) ? 200 : 422;
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'charge' => new ChargeActionResource($result),
], $status);
}
public function applyExtra(ApplyExtraChargeRequest $request, int $id): JsonResponse
{
$payload = $request->validated();
$result = $this->charges->applyExtraCharge($id, (int) $payload['invoice_id']);
$status = ($result['ok'] ?? false) ? 200 : 422;
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'charge' => new ChargeActionResource($result),
], $status);
}
public function cancelEvent(Request $request, int $id): JsonResponse
{
$issueCredit = $request->boolean('issue_credit', true);
$result = $this->charges->cancelEventCharge($id, $issueCredit);
$status = ($result['ok'] ?? false) ? 200 : 422;
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'charge' => new ChargeActionResource($result),
], $status);
}
public function markEventPaid(MarkEventChargePaidRequest $request, int $id): JsonResponse
{
$payload = $request->validated();
$result = $this->charges->markEventChargePaid(
$id,
(bool) $payload['paid'],
isset($payload['payment_id']) ? (int) $payload['payment_id'] : null
);
$status = ($result['ok'] ?? false) ? 200 : 422;
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'charge' => new ChargeActionResource($result),
], $status);
}
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
return $userId;
}
}
@@ -0,0 +1,126 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Controller;
use App\Http\Requests\Finance\EventChargeLifecycleRequest;
use App\Models\EventCharge;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class EventChargeController extends Controller
{
public function index(Request $request): JsonResponse
{
$q = EventCharge::query();
foreach (['parent_id', 'student_id', 'event_id', 'school_year', 'semester'] as $field) {
if ($request->filled($field)) {
$q->where($field, $request->query($field));
}
}
if ($request->filled('status')) {
$status = $request->query('status');
if (Schema::hasColumn('event_charges', 'status')) {
$q->where('status', $status);
} elseif ($status === 'paid') {
$q->where('event_paid', 1);
} elseif ($status === 'pending') {
$q->where(function ($qq) {
$qq->whereNull('event_paid')->orWhere('event_paid', 0);
});
}
}
return response()->json(['ok' => true, 'event_charges' => $q->orderByDesc('id')->paginate((int) $request->query('per_page', 25))]);
}
public function store(EventChargeLifecycleRequest $request): JsonResponse
{
$data = $this->normalizePayload($request->validated());
$data['created_by'] = optional($request->user())->id;
if (Schema::hasColumn('event_charges', 'status')) {
$data['status'] = $data['status'] ?? 'draft';
}
$charge = EventCharge::query()->create($data);
return response()->json(['ok' => true, 'event_charge' => $charge], 201);
}
public function show(int $eventCharge): JsonResponse
{
return response()->json(['ok' => true, 'event_charge' => EventCharge::query()->findOrFail($eventCharge)]);
}
public function update(EventChargeLifecycleRequest $request, int $eventCharge): JsonResponse
{
$charge = EventCharge::query()->findOrFail($eventCharge);
$charge->fill($this->normalizePayload($request->validated()))->save();
return response()->json(['ok' => true, 'event_charge' => $charge->fresh()]);
}
public function destroy(int $eventCharge): JsonResponse
{
return $this->void($eventCharge);
}
public function approve(int $eventCharge): JsonResponse
{
$charge = EventCharge::query()->findOrFail($eventCharge);
if (Schema::hasColumn('event_charges', 'status')) {
$charge->status = 'approved';
}
if (Schema::hasColumn('event_charges', 'charged')) {
$charge->charged = 1;
}
$charge->save();
return response()->json(['ok' => true, 'event_charge' => $charge->fresh()]);
}
public function void(int $eventCharge): JsonResponse
{
$charge = EventCharge::query()->findOrFail($eventCharge);
if (Schema::hasColumn('event_charges', 'status')) {
$charge->status = 'voided';
}
if (Schema::hasColumn('event_charges', 'charged')) {
$charge->charged = 0;
}
$charge->save();
return response()->json(['ok' => true, 'event_charge' => $charge->fresh()]);
}
public function attachToInvoice(Request $request, int $eventCharge): JsonResponse
{
$request->validate(['invoice_id' => ['required', 'integer']]);
$charge = EventCharge::query()->findOrFail($eventCharge);
$invoiceId = (int) $request->input('invoice_id');
abort_if(! DB::table('invoices')->where('id', $invoiceId)->exists(), 404, 'Invoice not found.');
if (Schema::hasColumn('event_charges', 'invoice_id')) {
$charge->invoice_id = $invoiceId;
}
if (Schema::hasColumn('event_charges', 'status')) {
$charge->status = 'invoiced';
}
if (Schema::hasColumn('event_charges', 'charged')) {
$charge->charged = 1;
}
$charge->save();
return response()->json(['ok' => true, 'event_charge' => $charge->fresh(), 'warning' => Schema::hasColumn('event_charges', 'invoice_id') ? null : 'invoice_id column does not exist; charge marked as charged only.']);
}
private function normalizePayload(array $data): array
{
if (isset($data['title']) && ! isset($data['event_name'])) {
$data['event_name'] = $data['title'];
}
unset($data['title'], $data['invoice_id']);
return array_filter($data, fn ($v) => $v !== null);
}
}
@@ -2,13 +2,19 @@
namespace App\Http\Controllers\Api\Finance; namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Finance\FeeFamilyBalanceRequest;
use App\Http\Requests\Finance\FeeRefundRequest; use App\Http\Requests\Finance\FeeRefundRequest;
use App\Http\Requests\Finance\FeeTuitionBreakdownRequest;
use App\Http\Requests\Finance\FeeTuitionTotalRequest; use App\Http\Requests\Finance\FeeTuitionTotalRequest;
use App\Http\Resources\Fees\FeeFamilyBalanceResource;
use App\Http\Resources\Fees\FeeRefundResource; use App\Http\Resources\Fees\FeeRefundResource;
use App\Http\Resources\Fees\FeeTuitionBreakdownResource;
use App\Http\Resources\Fees\FeeTuitionTotalResource; use App\Http\Resources\Fees\FeeTuitionTotalResource;
use App\Services\Billing\BalanceCalculationService;
use App\Services\Fees\FeeRefundCalculatorService; use App\Services\Fees\FeeRefundCalculatorService;
use App\Services\Fees\FeeStudentFeeService; use App\Services\Fees\FeeStudentFeeService;
use App\Services\Fees\TuitionCalculationService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -16,7 +22,9 @@ class FeeCalculationController extends BaseApiController
{ {
public function __construct( public function __construct(
private FeeRefundCalculatorService $refunds, private FeeRefundCalculatorService $refunds,
private FeeStudentFeeService $tuition private FeeStudentFeeService $tuition,
private TuitionCalculationService $tuitionCalculator,
private BalanceCalculationService $balance
) { ) {
parent::__construct(); parent::__construct();
} }
@@ -69,4 +77,55 @@ class FeeCalculationController extends BaseApiController
]), ]),
]); ]);
} }
public function tuitionBreakdown(FeeTuitionBreakdownRequest $request): JsonResponse
{
$payload = $request->validated();
try {
$result = $this->tuitionCalculator->calculate($payload['students']);
} catch (\Throwable $e) {
Log::error('Tuition breakdown calculation failed', [
'exception' => $e,
]);
return response()->json([
'ok' => false,
'message' => 'Unable to calculate tuition breakdown.',
], 500);
}
return response()->json([
'ok' => true,
'tuition' => new FeeTuitionBreakdownResource($result),
]);
}
public function familyBalance(FeeFamilyBalanceRequest $request): JsonResponse
{
$payload = $request->validated();
try {
$result = $this->balance->calculateFamilyAccount(
$payload['students'],
(int) $payload['parent_id'],
$payload['school_year'] ?? null
);
} catch (\Throwable $e) {
Log::error('Family balance calculation failed', [
'parent_id' => $payload['parent_id'] ?? null,
'exception' => $e,
]);
return response()->json([
'ok' => false,
'message' => 'Unable to calculate family balance.',
], 500);
}
return response()->json([
'ok' => true,
'account' => new FeeFamilyBalanceResource($result),
]);
}
} }
@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Controller;
use App\Http\Requests\Finance\FinanceNotificationLogRequest;
use App\Services\Finance\FinanceNotificationLogService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class FinanceNotificationController extends Controller
{
public function __construct(private FinanceNotificationLogService $service) {}
public function sendPaymentReceipt(Request $request, int $payment): JsonResponse
{
$p = DB::table('payments')->where('id', $payment)->first();
abort_if(! $p, 404, 'Payment not found.');
$receipt = $this->service->nextReceiptNumber($p->school_year ?? date('Y'));
$log = $this->service->log([
'parent_id' => $p->parent_id ?? null,
'invoice_id' => $p->invoice_id ?? null,
'payment_id' => $payment,
'notification_type' => 'payment_receipt',
'channel' => 'database',
'recipient' => (string) ($p->parent_id ?? ''),
'subject' => 'Payment receipt '.$receipt,
], optional($request->user())->id);
return response()->json(['ok' => true, 'receipt_number' => $receipt, 'notification_log' => $log]);
}
public function sendRefundReceipt(Request $request, int $refund): JsonResponse
{
$log = $this->service->log(['refund_id' => $refund, 'notification_type' => 'refund_receipt', 'channel' => 'database', 'subject' => 'Refund receipt'], optional($request->user())->id);
return response()->json(['ok' => true, 'notification_log' => $log]);
}
public function sendInvoiceStatement(Request $request, int $invoice): JsonResponse
{
$inv = DB::table('invoices')->where('id', $invoice)->first();
abort_if(! $inv, 404, 'Invoice not found.');
$log = $this->service->log(['parent_id' => $inv->parent_id ?? null, 'invoice_id' => $invoice, 'notification_type' => 'statement_available', 'channel' => 'database', 'subject' => 'Statement available'], optional($request->user())->id);
return response()->json(['ok' => true, 'notification_log' => $log]);
}
public function sendOverdueReminder(Request $request, int $parent): JsonResponse
{
$log = $this->service->log(['parent_id' => $parent, 'notification_type' => 'overdue_balance_reminder', 'channel' => 'database', 'subject' => 'Overdue balance reminder'], optional($request->user())->id);
return response()->json(['ok' => true, 'notification_log' => $log]);
}
public function sendInstallmentReminder(Request $request, int $installment): JsonResponse
{
$log = $this->service->log(['installment_id' => $installment, 'notification_type' => 'installment_reminder', 'channel' => 'database', 'subject' => 'Installment reminder'], optional($request->user())->id);
return response()->json(['ok' => true, 'notification_log' => $log]);
}
public function logs(FinanceNotificationLogRequest $request): JsonResponse
{
return response()->json(['ok' => true, 'logs' => $this->service->list($request->validated())]);
}
}
@@ -2,10 +2,13 @@
namespace App\Http\Controllers\Api\Finance; namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Finance\FinancialReportRequest; use App\Http\Requests\Finance\FinancialReportRequest;
use App\Http\Requests\Finance\FinancialSummaryRequest; use App\Http\Requests\Finance\FinancialSummaryRequest;
use App\Http\Requests\Finance\FinancialUnpaidParentsRequest; use App\Http\Requests\Finance\FinancialUnpaidParentsRequest;
use App\Http\Requests\Finance\FollowUpNoteRequest;
use App\Http\Requests\Finance\ParentPaymentFollowUpRequest;
use App\Http\Requests\Finance\StakeholderFinancialAnalysisRequest;
use App\Http\Resources\Finance\FinancialReportResource; use App\Http\Resources\Finance\FinancialReportResource;
use App\Http\Resources\Finance\FinancialSummaryResource; use App\Http\Resources\Finance\FinancialSummaryResource;
use App\Http\Resources\Finance\FinancialUnpaidParentResource; use App\Http\Resources\Finance\FinancialUnpaidParentResource;
@@ -14,6 +17,8 @@ use App\Services\Finance\FinancialReportService;
use App\Services\Finance\FinancialSchoolYearService; use App\Services\Finance\FinancialSchoolYearService;
use App\Services\Finance\FinancialSummaryService; use App\Services\Finance\FinancialSummaryService;
use App\Services\Finance\FinancialUnpaidParentsService; use App\Services\Finance\FinancialUnpaidParentsService;
use App\Services\Finance\ParentPaymentFollowUpService;
use App\Services\Finance\StakeholderFinancialAnalysisService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -24,7 +29,9 @@ class FinancialController extends BaseApiController
private FinancialSummaryService $summaryService, private FinancialSummaryService $summaryService,
private FinancialUnpaidParentsService $unpaidParentsService, private FinancialUnpaidParentsService $unpaidParentsService,
private FinancialSchoolYearService $schoolYears, private FinancialSchoolYearService $schoolYears,
private FinancialPdfReportService $pdfService private FinancialPdfReportService $pdfService,
private StakeholderFinancialAnalysisService $stakeholderAnalysisService,
private ParentPaymentFollowUpService $parentPaymentFollowUpService
) { ) {
parent::__construct(); parent::__construct();
} }
@@ -75,6 +82,95 @@ class FinancialController extends BaseApiController
]); ]);
} }
public function parentPaymentFollowups(ParentPaymentFollowUpRequest $request): JsonResponse
{
return response()->json([
'ok' => true,
'followups' => $this->parentPaymentFollowUpService->report($request->validated()),
]);
}
public function parentPaymentFollowupsCsv(ParentPaymentFollowUpRequest $request): StreamedResponse
{
$report = $this->parentPaymentFollowUpService->report($request->validated());
$rows = $this->parentPaymentFollowUpService->csvRows($report);
return response()->streamDownload(function () use ($rows) {
$out = fopen('php://output', 'w');
foreach ($rows as $row) {
fputcsv($out, $row);
}
fclose($out);
}, 'parent_payment_followups_'.($report['schoolYear'] ?? 'report').'_'.date('Ymd_His').'.csv', ['Content-Type' => 'text/csv']);
}
public function storeParentFollowUpNote(FollowUpNoteRequest $request, int $parent): JsonResponse
{
return response()->json([
'ok' => true,
'note' => $this->parentPaymentFollowUpService->storeNote($parent, $request->validated(), optional($request->user())->id),
], 201);
}
public function markParentContacted(FollowUpNoteRequest $request, int $parent): JsonResponse
{
$payload = array_merge($request->validated(), ['status' => $request->input('status', 'contacted')]);
return response()->json(['ok' => true, 'note' => $this->parentPaymentFollowUpService->storeNote($parent, $payload, optional($request->user())->id)], 201);
}
public function storePromiseToPay(FollowUpNoteRequest $request, int $parent): JsonResponse
{
$payload = array_merge($request->validated(), ['status' => 'promise_to_pay']);
return response()->json(['ok' => true, 'note' => $this->parentPaymentFollowUpService->storeNote($parent, $payload, optional($request->user())->id)], 201);
}
public function resolveParentFollowUp(FollowUpNoteRequest $request, int $parent): JsonResponse
{
$payload = array_merge($request->validated(), ['status' => 'resolved']);
return response()->json(['ok' => true, 'note' => $this->parentPaymentFollowUpService->storeNote($parent, $payload, optional($request->user())->id)], 201);
}
public function stakeholderAnalysis(StakeholderFinancialAnalysisRequest $request): JsonResponse
{
$payload = $request->validated();
$analysis = $this->stakeholderAnalysisService->analyze(
$payload['date_from'] ?? null,
$payload['date_to'] ?? null,
$payload['school_year'] ?? null,
$payload['compare_school_year'] ?? null
);
return response()->json([
'ok' => true,
'analysis' => $analysis,
]);
}
public function stakeholderAnalysisCsv(StakeholderFinancialAnalysisRequest $request): StreamedResponse
{
$payload = $request->validated();
$analysis = $this->stakeholderAnalysisService->analyze(
$payload['date_from'] ?? null,
$payload['date_to'] ?? null,
$payload['school_year'] ?? null,
$payload['compare_school_year'] ?? null
);
$rows = $this->stakeholderAnalysisService->csvRows($analysis);
$schoolYear = $analysis['schoolYear'] ?? 'report';
return response()->streamDownload(function () use ($rows) {
$out = fopen('php://output', 'w');
foreach ($rows as $row) {
fputcsv($out, $row);
}
fclose($out);
}, 'stakeholder_financial_analysis_'.$schoolYear.'_'.date('Ymd_His').'.csv', ['Content-Type' => 'text/csv']);
}
public function downloadCsv(FinancialReportRequest $request): StreamedResponse public function downloadCsv(FinancialReportRequest $request): StreamedResponse
{ {
$payload = $request->validated(); $payload = $request->validated();
@@ -104,7 +200,7 @@ class FinancialController extends BaseApiController
$breakdown = $report['paymentBreakdown'] ?? []; $breakdown = $report['paymentBreakdown'] ?? [];
$filename = 'financial_report_' . date('Ymd_His') . '.csv'; $filename = 'financial_report_'.date('Ymd_His').'.csv';
return response()->streamDownload(function () use ($report, $paymentsMap, $refundsMap, $discountsMap, $breakdown) { return response()->streamDownload(function () use ($report, $paymentsMap, $refundsMap, $discountsMap, $breakdown) {
$out = fopen('php://output', 'w'); $out = fopen('php://output', 'w');
@@ -213,6 +309,6 @@ class FinancialController extends BaseApiController
return response()->streamDownload(function () use ($pdfBytes) { return response()->streamDownload(function () use ($pdfBytes) {
echo $pdfBytes; echo $pdfBytes;
}, 'Financial_Report_' . $schoolYear . '.pdf', ['Content-Type' => 'application/pdf']); }, 'Financial_Report_'.$schoolYear.'.pdf', ['Content-Type' => 'application/pdf']);
} }
} }
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Controller;
use App\Http\Requests\Finance\InstallmentPlanRequest;
use App\Http\Requests\Finance\PaymentAllocationRequest;
use App\Services\Finance\InstallmentPlanService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class InstallmentPlanController extends Controller
{
public function __construct(private InstallmentPlanService $service) {}
public function index(Request $request): JsonResponse
{
return response()->json(['ok' => true, 'plans' => $this->service->list($request->query())]);
}
public function store(InstallmentPlanRequest $request, int $invoice): JsonResponse
{
return response()->json(['ok' => true, 'plan' => $this->service->createForInvoice($invoice, $request->validated(), optional($request->user())->id)], 201);
}
public function show(int $plan): JsonResponse
{
return response()->json(['ok' => true, 'plan' => $this->service->show($plan)]);
}
public function activate(Request $request, int $plan): JsonResponse
{
return response()->json(['ok' => true, 'plan' => $this->service->activate($plan, optional($request->user())->id)]);
}
public function cancel(int $plan): JsonResponse
{
return response()->json(['ok' => true, 'plan' => $this->service->cancel($plan)]);
}
public function restructure(InstallmentPlanRequest $request, int $plan): JsonResponse
{
$old = $this->service->cancel($plan);
return response()->json(['ok' => true, 'old_plan' => $old, 'message' => 'Old plan cancelled. Create a replacement plan against the invoice to preserve audit history.']);
}
public function allocatePayment(PaymentAllocationRequest $request, int $payment): JsonResponse
{
return response()->json(['ok' => true, 'result' => $this->service->allocatePayment($payment, $request->validated())]);
}
public function due(): JsonResponse
{
return response()->json(['ok' => true, 'installments' => $this->service->due(false)]);
}
public function overdue(): JsonResponse
{
$this->service->markOverdue();
return response()->json(['ok' => true, 'installments' => $this->service->due(true)]);
}
}
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Finance; namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Invoices\InvoiceManagementRequest; use App\Http\Requests\Invoices\InvoiceManagementRequest;
use App\Http\Requests\Invoices\InvoiceParentRequest; use App\Http\Requests\Invoices\InvoiceParentRequest;
use App\Http\Requests\Invoices\InvoiceStatusRequest; use App\Http\Requests\Invoices\InvoiceStatusRequest;
@@ -88,9 +88,9 @@ class InvoiceController extends BaseApiController
public function parentPayment(InvoiceParentRequest $request): JsonResponse public function parentPayment(InvoiceParentRequest $request): JsonResponse
{ {
$parentId = (int) (auth()->id() ?? 0); $parentId = $this->authenticatedUserIdOrUnauthorized();
if ($parentId <= 0) { if ($parentId instanceof JsonResponse) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); return $parentId;
} }
$schoolYear = $request->validated()['school_year'] ?? null; $schoolYear = $request->validated()['school_year'] ?? null;
@@ -111,7 +111,7 @@ class InvoiceController extends BaseApiController
$status = $request->validated()['status']; $status = $request->validated()['status'];
$updated = Invoice::updateInvoiceStatus($invoiceId, $status); $updated = Invoice::updateInvoiceStatus($invoiceId, $status);
if (!$updated) { if (! $updated) {
return response()->json(['ok' => false, 'message' => 'Invoice not found.'], 404); return response()->json(['ok' => false, 'message' => 'Invoice not found.'], 404);
} }
@@ -128,12 +128,39 @@ class InvoiceController extends BaseApiController
]); ]);
} }
public function preview(int $invoiceId): JsonResponse
{
$data = $this->pdfService->previewData($invoiceId);
if (isset($data['error'])) {
return response()->json([
'ok' => false,
'message' => $data['error'],
], 404);
}
return response()->json([
'ok' => true,
...$data,
]);
}
public function pdf(int $invoiceId): StreamedResponse public function pdf(int $invoiceId): StreamedResponse
{ {
$pdfBytes = $this->pdfService->buildPdf($invoiceId); $pdfBytes = $this->pdfService->buildPdf($invoiceId);
return response()->streamDownload(function () use ($pdfBytes) { return response()->streamDownload(function () use ($pdfBytes) {
echo $pdfBytes; echo $pdfBytes;
}, 'invoice_' . $invoiceId . '.pdf', ['Content-Type' => 'application/pdf']); }, 'invoice_'.$invoiceId.'.pdf', ['Content-Type' => 'application/pdf']);
}
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
return $userId;
} }
} }
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Finance; namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Payments\PaymentByParentRequest; use App\Http\Requests\Payments\PaymentByParentRequest;
use App\Http\Resources\Payments\PaymentResource; use App\Http\Resources\Payments\PaymentResource;
use App\Services\Payments\PaymentBalanceService; use App\Services\Payments\PaymentBalanceService;
@@ -82,7 +82,7 @@ class PaymentController extends BaseApiController
$payload = $validator->validated(); $payload = $validator->validated();
$updated = $this->balanceService->updateBalance($paymentId, (float) $payload['paid_amount']); $updated = $this->balanceService->updateBalance($paymentId, (float) $payload['paid_amount']);
if (!$updated) { if (! $updated) {
return response()->json(['ok' => false, 'message' => 'Payment not found.'], 404); return response()->json(['ok' => false, 'message' => 'Payment not found.'], 404);
} }
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Finance; namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Payments\PaymentEventChargesListRequest; use App\Http\Requests\Payments\PaymentEventChargesListRequest;
use App\Services\Payments\PaymentEventChargesService; use App\Services\Payments\PaymentEventChargesService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -26,6 +26,11 @@ class PaymentEventChargesController extends BaseApiController
public function store(Request $request): JsonResponse public function store(Request $request): JsonResponse
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$data = array_merge($request->query->all(), $request->all(), $request->json()->all()); $data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [ $validator = Validator::make($data, [
'parent_id' => ['required', 'integer', 'min:1'], 'parent_id' => ['required', 'integer', 'min:1'],
@@ -47,9 +52,8 @@ class PaymentEventChargesController extends BaseApiController
} }
$payload = $validator->validated(); $payload = $validator->validated();
$userId = (int) (auth()->id() ?? 0);
$count = $this->service->addCharges($payload, $userId); $count = $this->service->addCharges($payload, $guard);
if ($count === 0) { if ($count === 0) {
return response()->json(['ok' => false, 'message' => 'No student selected.'], 422); return response()->json(['ok' => false, 'message' => 'No student selected.'], 422);
} }
@@ -64,4 +68,14 @@ class PaymentEventChargesController extends BaseApiController
return response()->json(['ok' => true, 'students' => $students]); return response()->json(['ok' => true, 'students' => $students]);
} }
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
return $userId;
}
} }
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Finance; namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Payments\PaymentManualEditRequest; use App\Http\Requests\Payments\PaymentManualEditRequest;
use App\Http\Requests\Payments\PaymentManualUpdateRequest; use App\Http\Requests\Payments\PaymentManualUpdateRequest;
use App\Services\Payments\PaymentManualService; use App\Services\Payments\PaymentManualService;
@@ -63,6 +63,11 @@ class PaymentManualController extends BaseApiController
public function record(PaymentManualUpdateRequest $request, int $invoiceId): JsonResponse public function record(PaymentManualUpdateRequest $request, int $invoiceId): JsonResponse
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated(); $payload = $request->validated();
$result = $this->service->recordPayment( $result = $this->service->recordPayment(
@@ -75,7 +80,7 @@ class PaymentManualController extends BaseApiController
$request->file('payment_file'), $request->file('payment_file'),
$payload['school_year'] ?? null, $payload['school_year'] ?? null,
$payload['semester'] ?? null, $payload['semester'] ?? null,
(int) (auth()->id() ?? 0) $guard
); );
return response()->json(['ok' => true] + $result); return response()->json(['ok' => true] + $result);
@@ -83,6 +88,11 @@ class PaymentManualController extends BaseApiController
public function edit(PaymentManualEditRequest $request, int $paymentId): JsonResponse public function edit(PaymentManualEditRequest $request, int $paymentId): JsonResponse
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated(); $payload = $request->validated();
$this->service->editPayment( $this->service->editPayment(
@@ -91,7 +101,7 @@ class PaymentManualController extends BaseApiController
(string) $payload['payment_method'], (string) $payload['payment_method'],
$payload['check_number'] ?? null, $payload['check_number'] ?? null,
$request->file('payment_file'), $request->file('payment_file'),
(int) (auth()->id() ?? 0) $guard
); );
return response()->json(['ok' => true]); return response()->json(['ok' => true]);
@@ -100,6 +110,17 @@ class PaymentManualController extends BaseApiController
public function file(Request $request, string $filename) public function file(Request $request, string $filename)
{ {
$mode = (string) ($request->query('mode') ?? 'download'); $mode = (string) ($request->query('mode') ?? 'download');
return $this->service->serveCheckFile($filename, $mode); return $this->service->serveCheckFile($filename, $mode);
} }
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
return $userId;
}
} }
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Finance; namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Payments\PaymentNotificationListRequest; use App\Http\Requests\Payments\PaymentNotificationListRequest;
use App\Http\Requests\Payments\PaymentNotificationSendRequest; use App\Http\Requests\Payments\PaymentNotificationSendRequest;
use App\Http\Resources\Payments\PaymentNotificationLogResource; use App\Http\Resources\Payments\PaymentNotificationLogResource;
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Finance; namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Resources\Payments\PaymentTransactionResource; use App\Http\Resources\Payments\PaymentTransactionResource;
use App\Services\Payments\PaymentTransactionService; use App\Services\Payments\PaymentTransactionService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -83,7 +83,7 @@ class PaymentTransactionController extends BaseApiController
$updated = $this->service->updateStatus($transactionId, (string) $status); $updated = $this->service->updateStatus($transactionId, (string) $status);
if (!$updated) { if (! $updated) {
return response()->json(['ok' => false, 'message' => 'Transaction not found.'], 404); return response()->json(['ok' => false, 'message' => 'Transaction not found.'], 404);
} }
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Finance; namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Payments\PaypalExecuteRequest; use App\Http\Requests\Payments\PaypalExecuteRequest;
use App\Services\Payments\PaypalPaymentService; use App\Services\Payments\PaypalPaymentService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Finance; namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Payments\PaypalTransactionsListRequest; use App\Http\Requests\Payments\PaypalTransactionsListRequest;
use App\Http\Resources\Payments\PaypalTransactionResource; use App\Http\Resources\Payments\PaypalTransactionResource;
use App\Services\Payments\PaypalTransactionsService; use App\Services\Payments\PaypalTransactionsService;
@@ -39,7 +39,7 @@ class PaypalTransactionsController extends BaseApiController
$payload = $request->validated(); $payload = $request->validated();
$rows = $this->service->listAll($payload['q'] ?? null); $rows = $this->service->listAll($payload['q'] ?? null);
$filename = 'paypal_transactions_' . date('Ymd_His') . '.csv'; $filename = 'paypal_transactions_'.date('Ymd_His').'.csv';
return response()->streamDownload(function () use ($rows) { return response()->streamDownload(function () use ($rows) {
$out = fopen('php://output', 'w'); $out = fopen('php://output', 'w');
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Finance; namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\PurchaseOrders\PurchaseOrderIndexRequest; use App\Http\Requests\PurchaseOrders\PurchaseOrderIndexRequest;
use App\Http\Resources\PurchaseOrders\PurchaseOrderItemResource; use App\Http\Resources\PurchaseOrders\PurchaseOrderItemResource;
use App\Http\Resources\PurchaseOrders\PurchaseOrderResource; use App\Http\Resources\PurchaseOrders\PurchaseOrderResource;
@@ -52,7 +52,7 @@ class PurchaseOrderController extends BaseApiController
public function show(int $id): JsonResponse public function show(int $id): JsonResponse
{ {
$data = $this->queryService->find($id); $data = $this->queryService->find($id);
if (!$data) { if (! $data) {
return response()->json(['ok' => false, 'message' => 'Purchase order not found.'], 404); return response()->json(['ok' => false, 'message' => 'Purchase order not found.'], 404);
} }
@@ -69,7 +69,7 @@ class PurchaseOrderController extends BaseApiController
$validator = Validator::make($data, [ $validator = Validator::make($data, [
'po_number' => ['required', 'string', 'max:60'], 'po_number' => ['required', 'string', 'max:60'],
'supplier_id' => ['required', 'integer', 'min:1', 'exists:suppliers,id'], 'supplier_id' => ['required', 'integer', 'min:1', 'exists:suppliers,id'],
'status' => ['nullable', 'string', 'in:' . implode(',', PurchaseOrder::allowedStatuses())], 'status' => ['nullable', 'string', 'in:'.implode(',', PurchaseOrder::allowedStatuses())],
'order_date' => ['nullable', 'date'], 'order_date' => ['nullable', 'date'],
'expected_date' => ['nullable', 'date'], 'expected_date' => ['nullable', 'date'],
'notes' => ['nullable', 'string', 'max:5000'], 'notes' => ['nullable', 'string', 'max:5000'],
@@ -88,7 +88,7 @@ class PurchaseOrderController extends BaseApiController
} }
$payload = $validator->validated(); $payload = $validator->validated();
if (!empty($payload['order_date']) && !empty($payload['expected_date'])) { if (! empty($payload['order_date']) && ! empty($payload['expected_date'])) {
$orderTs = strtotime((string) $payload['order_date']); $orderTs = strtotime((string) $payload['order_date']);
$expectedTs = strtotime((string) $payload['expected_date']); $expectedTs = strtotime((string) $payload['expected_date']);
if ($orderTs !== false && $expectedTs !== false && $expectedTs < $orderTs) { if ($orderTs !== false && $expectedTs !== false && $expectedTs < $orderTs) {
@@ -99,6 +99,10 @@ class PurchaseOrderController extends BaseApiController
} }
} }
if (auth()->user() === null) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
try { try {
$po = $this->createService->create($payload); $po = $this->createService->create($payload);
} catch (RuntimeException $e) { } catch (RuntimeException $e) {
@@ -132,12 +136,16 @@ class PurchaseOrderController extends BaseApiController
$payload = $validator->validated(); $payload = $validator->validated();
$po = PurchaseOrder::query()->find($id); $po = PurchaseOrder::query()->find($id);
if (!$po) { if (! $po) {
return response()->json(['ok' => false, 'message' => 'Purchase order not found.'], 404); return response()->json(['ok' => false, 'message' => 'Purchase order not found.'], 404);
} }
$user = auth()->user(); $user = auth()->user();
$issuedBy = $user ? (string) ($user->email ?? $user->username ?? $user->id) : 'system'; if ($user === null) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
$issuedBy = (string) ($user->email ?? $user->username ?? $user->id);
try { try {
$result = $this->receiveService->receive($id, $payload['items'], $issuedBy); $result = $this->receiveService->receive($id, $payload['items'], $issuedBy);
@@ -156,8 +164,12 @@ class PurchaseOrderController extends BaseApiController
public function cancel(int $id): JsonResponse public function cancel(int $id): JsonResponse
{ {
if (auth()->user() === null) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
$po = PurchaseOrder::query()->find($id); $po = PurchaseOrder::query()->find($id);
if (!$po) { if (! $po) {
return response()->json(['ok' => false, 'message' => 'Purchase order not found.'], 404); return response()->json(['ok' => false, 'message' => 'Purchase order not found.'], 404);
} }
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Finance; namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Refunds\RefundDecisionRequest; use App\Http\Requests\Refunds\RefundDecisionRequest;
use App\Http\Requests\Refunds\RefundIndexRequest; use App\Http\Requests\Refunds\RefundIndexRequest;
use App\Http\Requests\Refunds\RefundPaymentRequest; use App\Http\Requests\Refunds\RefundPaymentRequest;
@@ -48,7 +48,7 @@ class RefundController extends BaseApiController
public function show(int $refundId): JsonResponse public function show(int $refundId): JsonResponse
{ {
$refund = $this->queryService->find($refundId); $refund = $this->queryService->find($refundId);
if (!$refund) { if (! $refund) {
return response()->json(['ok' => false, 'message' => 'Refund not found.'], 404); return response()->json(['ok' => false, 'message' => 'Refund not found.'], 404);
} }
@@ -60,13 +60,18 @@ class RefundController extends BaseApiController
public function store(RefundStoreRequest $request): JsonResponse public function store(RefundStoreRequest $request): JsonResponse
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated(); $payload = $request->validated();
$actorId = (int) (auth()->id() ?? 0);
try { try {
$result = $this->requestService->requestRefund($payload, $actorId); $result = $this->requestService->requestRefund($payload, $guard);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('Refund request failed.', ['error' => $e->getMessage()]); Log::error('Refund request failed.', ['error' => $e->getMessage()]);
return response()->json(['ok' => false, 'message' => 'Failed to submit refund request.'], 500); return response()->json(['ok' => false, 'message' => 'Failed to submit refund request.'], 500);
} }
@@ -88,14 +93,18 @@ class RefundController extends BaseApiController
public function update(RefundDecisionRequest $request, int $refundId): JsonResponse public function update(RefundDecisionRequest $request, int $refundId): JsonResponse
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated(); $payload = $request->validated();
$actorId = (int) (auth()->id() ?? 0);
$result = $this->decisionService->updateDecision( $result = $this->decisionService->updateDecision(
$refundId, $refundId,
(string) $payload['status'], (string) $payload['status'],
(string) $payload['reason'], (string) $payload['reason'],
$actorId $guard
); );
if (empty($result['ok'])) { if (empty($result['ok'])) {
@@ -107,8 +116,12 @@ class RefundController extends BaseApiController
public function destroy(int $refundId): JsonResponse public function destroy(int $refundId): JsonResponse
{ {
$actorId = (int) (auth()->id() ?? 0); $guard = $this->authenticatedUserIdOrUnauthorized();
$result = $this->decisionService->cancel($refundId, $actorId); if ($guard instanceof JsonResponse) {
return $guard;
}
$result = $this->decisionService->cancel($refundId, $guard);
if (empty($result['ok'])) { if (empty($result['ok'])) {
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to cancel refund.'], 404); return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to cancel refund.'], 404);
@@ -119,8 +132,12 @@ class RefundController extends BaseApiController
public function approve(int $refundId): JsonResponse public function approve(int $refundId): JsonResponse
{ {
$actorId = (int) (auth()->id() ?? 0); $guard = $this->authenticatedUserIdOrUnauthorized();
$result = $this->decisionService->approve($refundId, $actorId); if ($guard instanceof JsonResponse) {
return $guard;
}
$result = $this->decisionService->approve($refundId, $guard);
if (empty($result['ok'])) { if (empty($result['ok'])) {
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Approve failed.'], 422); return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Approve failed.'], 422);
@@ -131,9 +148,13 @@ class RefundController extends BaseApiController
public function reject(RefundRejectRequest $request, int $refundId): JsonResponse public function reject(RefundRejectRequest $request, int $refundId): JsonResponse
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated(); $payload = $request->validated();
$actorId = (int) (auth()->id() ?? 0); $result = $this->decisionService->reject($refundId, (string) $payload['reason'], $guard);
$result = $this->decisionService->reject($refundId, (string) $payload['reason'], $actorId);
if (empty($result['ok'])) { if (empty($result['ok'])) {
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Reject failed.'], 422); return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Reject failed.'], 422);
@@ -144,11 +165,15 @@ class RefundController extends BaseApiController
public function recordPayment(RefundPaymentRequest $request, int $refundId): JsonResponse public function recordPayment(RefundPaymentRequest $request, int $refundId): JsonResponse
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated(); $payload = $request->validated();
$payload['check_file'] = $request->file('check_file'); $payload['check_file'] = $request->file('check_file');
$actorId = (int) (auth()->id() ?? 0);
$result = $this->payoutService->recordPayment($refundId, $payload, $actorId); $result = $this->payoutService->recordPayment($refundId, $payload, $guard);
if (empty($result['ok'])) { if (empty($result['ok'])) {
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to update payment.'], 422); return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to update payment.'], 422);
} }
@@ -158,10 +183,14 @@ class RefundController extends BaseApiController
public function recalculateOverpayments(RefundRecalculateRequest $request): JsonResponse public function recalculateOverpayments(RefundRecalculateRequest $request): JsonResponse
{ {
$payload = $request->validated(); $guard = $this->authenticatedUserIdOrUnauthorized();
$actorId = (int) (auth()->id() ?? 0); if ($guard instanceof JsonResponse) {
return $guard;
}
$result = $this->overpaymentService->recalculate(true, $payload['invoice_number'] ?? null, $actorId); $payload = $request->validated();
$result = $this->overpaymentService->recalculate(true, $payload['invoice_number'] ?? null, $guard);
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
@@ -183,4 +212,14 @@ class RefundController extends BaseApiController
'summary' => $summary, 'summary' => $summary,
]); ]);
} }
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
return $userId;
}
} }
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Finance; namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Files\FileNameRequest; use App\Http\Requests\Files\FileNameRequest;
use App\Http\Requests\Reimbursements\ReimbursementBatchAdminFileRequest; use App\Http\Requests\Reimbursements\ReimbursementBatchAdminFileRequest;
use App\Http\Requests\Reimbursements\ReimbursementBatchAssignmentRequest; use App\Http\Requests\Reimbursements\ReimbursementBatchAssignmentRequest;
@@ -35,6 +35,7 @@ use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use RuntimeException; use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ReimbursementController extends BaseApiController class ReimbursementController extends BaseApiController
{ {
@@ -68,6 +69,11 @@ class ReimbursementController extends BaseApiController
public function markDonation(Request $request): JsonResponse public function markDonation(Request $request): JsonResponse
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$data = array_merge($request->query->all(), $request->all(), $request->json()->all()); $data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [ $validator = Validator::make($data, [
'expense_id' => ['required', 'integer', 'min:1'], 'expense_id' => ['required', 'integer', 'min:1'],
@@ -81,13 +87,13 @@ class ReimbursementController extends BaseApiController
} }
$payload = $validator->validated(); $payload = $validator->validated();
$userId = (int) (auth()->id() ?? 0);
try { try {
$this->donationService->markDonation((int) $payload['expense_id'], $userId); $this->donationService->markDonation((int) $payload['expense_id'], $guard);
} catch (RuntimeException $e) { } catch (RuntimeException $e) {
$message = $e->getMessage(); $message = $e->getMessage();
$status = str_contains($message, 'not found') ? 404 : (str_contains($message, 'already') ? 409 : 422); $status = str_contains($message, 'not found') ? 404 : (str_contains($message, 'already') ? 409 : 422);
return response()->json(['ok' => false, 'message' => $message], $status); return response()->json(['ok' => false, 'message' => $message], $status);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return response()->json(['ok' => false, 'message' => 'Unable to mark donation right now.'], 500); return response()->json(['ok' => false, 'message' => 'Unable to mark donation right now.'], 500);
@@ -98,6 +104,11 @@ class ReimbursementController extends BaseApiController
public function createBatch(Request $request): JsonResponse public function createBatch(Request $request): JsonResponse
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$data = array_merge($request->query->all(), $request->all(), $request->json()->all()); $data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [ $validator = Validator::make($data, [
'title' => ['nullable', 'string', 'max:255'], 'title' => ['nullable', 'string', 'max:255'],
@@ -111,11 +122,10 @@ class ReimbursementController extends BaseApiController
} }
$payload = $validator->validated(); $payload = $validator->validated();
$userId = (int) (auth()->id() ?? 0);
$schoolYear = $this->context->getSchoolYear(); $schoolYear = $this->context->getSchoolYear();
$semester = $this->context->getSemester(); $semester = $this->context->getSemester();
$batch = $this->batchService->createBatch($payload['title'] ?? null, $userId, $schoolYear, $semester); $batch = $this->batchService->createBatch($payload['title'] ?? null, $guard, $schoolYear, $semester);
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
@@ -135,7 +145,7 @@ class ReimbursementController extends BaseApiController
$adminIdRaw = $payload['admin_id'] ?? null; $adminIdRaw = $payload['admin_id'] ?? null;
$adminId = ($adminIdRaw === '' || $adminIdRaw === null) ? null : (int) $adminIdRaw; $adminId = ($adminIdRaw === '' || $adminIdRaw === null) ? null : (int) $adminIdRaw;
$reimbursementId = !empty($payload['reimbursement_id']) ? (int) $payload['reimbursement_id'] : null; $reimbursementId = ! empty($payload['reimbursement_id']) ? (int) $payload['reimbursement_id'] : null;
try { try {
$result = $this->assignmentService->updateAssignment( $result = $this->assignmentService->updateAssignment(
@@ -158,19 +168,24 @@ class ReimbursementController extends BaseApiController
public function lockBatch(ReimbursementBatchLockRequest $request): JsonResponse public function lockBatch(ReimbursementBatchLockRequest $request): JsonResponse
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated(); $payload = $request->validated();
$userId = (int) (auth()->id() ?? 0);
try { try {
$this->batchService->lockBatch( $this->batchService->lockBatch(
(int) $payload['batch_id'], (int) $payload['batch_id'],
$userId, $guard,
$this->context->getSchoolYear(), $this->context->getSchoolYear(),
$this->context->getSemester() $this->context->getSemester()
); );
} catch (RuntimeException $e) { } catch (RuntimeException $e) {
$message = $e->getMessage(); $message = $e->getMessage();
$status = str_contains($message, 'check file') ? 409 : 404; $status = str_contains($message, 'check file') ? 409 : 404;
return response()->json(['ok' => false, 'message' => $message], $status); return response()->json(['ok' => false, 'message' => $message], $status);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return response()->json(['ok' => false, 'message' => 'Unable to lock batch right now.'], 500); return response()->json(['ok' => false, 'message' => 'Unable to lock batch right now.'], 500);
@@ -181,12 +196,17 @@ class ReimbursementController extends BaseApiController
public function uploadBatchAdminFile(ReimbursementBatchAdminFileRequest $request): JsonResponse public function uploadBatchAdminFile(ReimbursementBatchAdminFileRequest $request): JsonResponse
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated(); $payload = $request->validated();
$batchId = (int) $payload['batch_id']; $batchId = (int) $payload['batch_id'];
$adminId = (int) $payload['admin_id']; $adminId = (int) $payload['admin_id'];
$batch = ReimbursementBatch::query()->find($batchId); $batch = ReimbursementBatch::query()->find($batchId);
if (!$batch) { if (! $batch) {
return response()->json(['ok' => false, 'message' => 'Batch not found.'], 404); return response()->json(['ok' => false, 'message' => 'Batch not found.'], 404);
} }
if (strtolower((string) ($batch->status ?? '')) !== ReimbursementBatch::STATUS_OPEN) { if (strtolower((string) ($batch->status ?? '')) !== ReimbursementBatch::STATUS_OPEN) {
@@ -194,7 +214,7 @@ class ReimbursementController extends BaseApiController
} }
try { try {
$file = $this->fileService->storeAdminFile($batchId, $adminId, $request->file('check_file'), (int) (auth()->id() ?? 0)); $file = $this->fileService->storeAdminFile($batchId, $adminId, $request->file('check_file'), $guard);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return response()->json(['ok' => false, 'message' => 'Unable to store the uploaded file.'], 500); return response()->json(['ok' => false, 'message' => 'Unable to store the uploaded file.'], 500);
} }
@@ -213,7 +233,7 @@ class ReimbursementController extends BaseApiController
return response(file_get_contents($meta['path']), 200, [ return response(file_get_contents($meta['path']), 200, [
'Content-Type' => $meta['mime'], 'Content-Type' => $meta['mime'],
'Content-Disposition' => $disposition . '; filename="' . $meta['download_name'] . '"', 'Content-Disposition' => $disposition.'; filename="'.$meta['download_name'].'"',
'Content-Length' => (string) $meta['size'], 'Content-Length' => (string) $meta['size'],
'ETag' => $meta['etag'], 'ETag' => $meta['etag'],
'Last-Modified' => $meta['last_modified'], 'Last-Modified' => $meta['last_modified'],
@@ -263,14 +283,14 @@ class ReimbursementController extends BaseApiController
return response()->json(['ok' => false, 'message' => $e->getMessage()], 404); return response()->json(['ok' => false, 'message' => $e->getMessage()], 404);
} }
if (!$ok) { if (! $ok) {
return response()->json(['ok' => false, 'message' => 'Failed to send email. Please try again.'], 500); return response()->json(['ok' => false, 'message' => 'Failed to send email. Please try again.'], 500);
} }
return response()->json(['ok' => true, 'message' => 'Email sent successfully.']); return response()->json(['ok' => true, 'message' => 'Email sent successfully.']);
} }
public function export(ReimbursementExportRequest $request): Response public function export(ReimbursementExportRequest $request): StreamedResponse
{ {
$payload = $request->validated(); $payload = $request->validated();
$type = $payload['type'] ?? 'processed'; $type = $payload['type'] ?? 'processed';
@@ -283,7 +303,7 @@ class ReimbursementController extends BaseApiController
return response()->streamDownload(function () use ($csv) { return response()->streamDownload(function () use ($csv) {
$out = fopen('php://output', 'w'); $out = fopen('php://output', 'w');
fprintf($out, chr(0xEF) . chr(0xBB) . chr(0xBF)); fprintf($out, chr(0xEF).chr(0xBB).chr(0xBF));
foreach ($csv['rows'] as $row) { foreach ($csv['rows'] as $row) {
fputcsv($out, $row); fputcsv($out, $row);
} }
@@ -293,14 +313,14 @@ class ReimbursementController extends BaseApiController
]); ]);
} }
public function exportBatch(ReimbursementExportBatchRequest $request): Response public function exportBatch(ReimbursementExportBatchRequest $request): StreamedResponse
{ {
$payload = $request->validated(); $payload = $request->validated();
$csv = $this->exportService->buildBatchCsv((int) $payload['batch_id']); $csv = $this->exportService->buildBatchCsv((int) $payload['batch_id']);
return response()->streamDownload(function () use ($csv) { return response()->streamDownload(function () use ($csv) {
$out = fopen('php://output', 'w'); $out = fopen('php://output', 'w');
fprintf($out, chr(0xEF) . chr(0xBB) . chr(0xBF)); fprintf($out, chr(0xEF).chr(0xBB).chr(0xBF));
foreach ($csv['rows'] as $row) { foreach ($csv['rows'] as $row) {
fputcsv($out, $row); fputcsv($out, $row);
} }
@@ -312,6 +332,11 @@ class ReimbursementController extends BaseApiController
public function store(Request $request): JsonResponse public function store(Request $request): JsonResponse
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$data = array_merge($request->query->all(), $request->all(), $request->json()->all()); $data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [ $validator = Validator::make($data, [
'amount' => ['required', 'numeric', 'gt:0'], 'amount' => ['required', 'numeric', 'gt:0'],
@@ -333,7 +358,6 @@ class ReimbursementController extends BaseApiController
} }
$payload = $validator->validated(); $payload = $validator->validated();
$userId = (int) (auth()->id() ?? 0);
$receiptName = null; $receiptName = null;
if ($request->hasFile('receipt')) { if ($request->hasFile('receipt')) {
@@ -343,7 +367,7 @@ class ReimbursementController extends BaseApiController
try { try {
$reimb = $this->crudService->store( $reimb = $this->crudService->store(
$payload, $payload,
$userId, $guard,
$this->context->getSchoolYear(), $this->context->getSchoolYear(),
$this->context->getSemester(), $this->context->getSemester(),
$receiptName $receiptName
@@ -363,8 +387,12 @@ class ReimbursementController extends BaseApiController
public function process(ReimbursementProcessRequest $request): JsonResponse public function process(ReimbursementProcessRequest $request): JsonResponse
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated(); $payload = $request->validated();
$userId = (int) (auth()->id() ?? 0);
$receiptName = null; $receiptName = null;
if ($request->hasFile('receipt')) { if ($request->hasFile('receipt')) {
@@ -374,7 +402,7 @@ class ReimbursementController extends BaseApiController
try { try {
$reimb = $this->crudService->process( $reimb = $this->crudService->process(
$payload, $payload,
$userId, $guard,
$this->context->getSchoolYear(), $this->context->getSchoolYear(),
$this->context->getSemester(), $this->context->getSemester(),
$receiptName $receiptName
@@ -404,8 +432,13 @@ class ReimbursementController extends BaseApiController
public function update(Request $request, int $id): JsonResponse public function update(Request $request, int $id): JsonResponse
{ {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$reimb = Reimbursement::query()->find($id); $reimb = Reimbursement::query()->find($id);
if (!$reimb) { if (! $reimb) {
return response()->json(['ok' => false, 'message' => 'Reimbursement not found.'], 404); return response()->json(['ok' => false, 'message' => 'Reimbursement not found.'], 404);
} }
@@ -428,17 +461,16 @@ class ReimbursementController extends BaseApiController
} }
$payload = $validator->validated(); $payload = $validator->validated();
$userId = (int) (auth()->id() ?? 0);
$receiptName = $reimb->receipt_path; $receiptName = $reimb->receipt_path;
if ($request->hasFile('receipt')) { if ($request->hasFile('receipt')) {
$receiptName = $this->fileService->storeReceipt($request->file('receipt')); $receiptName = $this->fileService->storeReceipt($request->file('receipt'));
} }
if (!empty($payload['remove_receipt'])) { if (! empty($payload['remove_receipt'])) {
$receiptName = null; $receiptName = null;
} }
$updated = $this->crudService->update($reimb, $payload, $userId, $receiptName); $updated = $this->crudService->update($reimb, $payload, $guard, $receiptName);
$data = $updated->toArray(); $data = $updated->toArray();
$data['receipt_url'] = $this->fileService->receiptUrl($updated->receipt_path ?? null); $data['receipt_url'] = $this->fileService->receiptUrl($updated->receipt_path ?? null);
@@ -447,4 +479,14 @@ class ReimbursementController extends BaseApiController
'reimbursement' => new ReimbursementResource($data), 'reimbursement' => new ReimbursementResource($data),
]); ]);
} }
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
return $userId;
}
} }
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Frontend; namespace App\Http\Controllers\Api\Frontend;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Resources\Frontend\FrontendPageResource; use App\Http\Resources\Frontend\FrontendPageResource;
use App\Http\Resources\Frontend\FrontendUserResource; use App\Http\Resources\Frontend\FrontendUserResource;
use App\Services\Frontend\FrontendPageService; use App\Services\Frontend\FrontendPageService;
@@ -61,7 +61,7 @@ class FrontendController extends BaseApiController
public function fetchUser(): JsonResponse public function fetchUser(): JsonResponse
{ {
$user = auth()->user(); $user = auth()->user();
if (!$user) { if (! $user) {
return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED);
} }
@@ -2,7 +2,7 @@
namespace App\Http\Controllers\Api\Frontend; namespace App\Http\Controllers\Api\Frontend;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Resources\Frontend\ProfileIconResource; use App\Http\Resources\Frontend\ProfileIconResource;
use App\Services\Frontend\ProfileIconService; use App\Services\Frontend\ProfileIconService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -17,15 +17,25 @@ class InfoIconController extends BaseApiController
public function profileIcon(): JsonResponse public function profileIcon(): JsonResponse
{ {
$userId = (int) (auth()->id() ?? 0); $guard = $this->authenticatedUserIdOrUnauthorized();
if ($userId <= 0) { if ($guard instanceof JsonResponse) {
return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED); return $guard;
} }
$payload = $this->service->buildForUser($userId); $payload = $this->service->buildForUser($guard);
return $this->success([ return $this->success([
'profile' => new ProfileIconResource($payload), 'profile' => new ProfileIconResource($payload),
]); ]);
} }
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED);
}
return $userId;
}
} }
@@ -2,11 +2,12 @@
namespace App\Http\Controllers\Api\Frontend; namespace App\Http\Controllers\Api\Frontend;
use App\Http\Controllers\Api\BaseApiController; use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Frontend\LandingTeacherRequest; use App\Http\Requests\Frontend\LandingTeacherRequest;
use App\Http\Resources\Frontend\LandingPageResource; use App\Http\Resources\Frontend\LandingPageResource;
use App\Services\Frontend\LandingPageService; use App\Services\Frontend\LandingPageService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class LandingPageController extends BaseApiController class LandingPageController extends BaseApiController
{ {
@@ -17,8 +18,13 @@ class LandingPageController extends BaseApiController
public function index(LandingTeacherRequest $request): JsonResponse public function index(LandingTeacherRequest $request): JsonResponse
{ {
$user = $request->user();
if (! $user) {
return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->service->dashboardForUser( $payload = $this->service->dashboardForUser(
$request->user(), $user,
$request->validated()['class_section_id'] ?? null $request->validated()['class_section_id'] ?? null
); );
@@ -29,8 +35,13 @@ class LandingPageController extends BaseApiController
public function teacher(LandingTeacherRequest $request): JsonResponse public function teacher(LandingTeacherRequest $request): JsonResponse
{ {
$user = $request->user();
if (! $user) {
return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->service->teacher( $payload = $this->service->teacher(
$request->user(), $user,
$request->validated()['class_section_id'] ?? null $request->validated()['class_section_id'] ?? null
); );
@@ -41,7 +52,12 @@ class LandingPageController extends BaseApiController
public function parent(): JsonResponse public function parent(): JsonResponse
{ {
$payload = $this->service->parent(auth()->user()); $user = auth()->user();
if (! $user) {
return $this->error('Unauthorized.', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->service->parent($user);
return $this->success([ return $this->success([
'landing' => new LandingPageResource($payload), 'landing' => new LandingPageResource($payload),

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