79 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
root 0f39dbee62 fix endpoints 2026-03-11 23:32:54 -04:00
root 863e330dd8 fix some responses 2026-03-11 18:44:16 -04:00
root 2ef71cc92b add all controllers logic 2026-03-11 17:53:15 -04:00
root 3e6c577085 add services logic 2026-03-11 01:38:20 -04:00
root 182036cc41 add notifications logic and add support of both JWT and Sanctum 2026-03-11 01:20:31 -04:00
root f6be51576c add refund and event logic 2026-03-10 23:12:49 -04:00
root ba1206e314 fix swagger docs 2026-03-10 18:29:09 -04:00
root 25e7b3c67c add inventory and supply logic 2026-03-10 18:21:08 -04:00
root 17d73e2f92 add family logic 2026-03-10 17:52:47 -04:00
root a82f7aedbc add report card logic 2026-03-10 17:33:03 -04:00
root c235fd2170 add role permission logic 2026-03-10 17:11:48 -04:00
root abebe0d9c0 add school calendar logic 2026-03-10 16:55:50 -04:00
root 311bb93977 add SessionTimeout logic 2026-03-10 16:45:08 -04:00
root 2e9a391280 add stickers logic 2026-03-10 16:40:11 -04:00
root 3bf4c687da add whatsup logic 2026-03-10 16:31:35 -04:00
root 20ee70d153 add more controller 2026-03-10 00:48:32 -04:00
root 5eeaec0257 migration 3 2026-03-09 18:36:58 -04:00
root a953f00675 migrations fix 2026-03-09 18:24:26 -04:00
root 99d399d36c update migration table 2026-03-09 18:09:35 -04:00
root b7bdee3af5 update 15 docker CI 2026-03-09 17:52:17 -04:00
root 51ab9cbdbb update 14 docker CI 2026-03-09 17:44:13 -04:00
root d0c8ac533b update 14 docker CI 2026-03-09 17:36:32 -04:00
root f236e5bfe4 update 13 docker CI 2026-03-09 17:31:28 -04:00
root 6a03e547cc update 12 docker CI 2026-03-09 17:25:12 -04:00
root 51d2504e99 update 11 docker CI 2026-03-09 17:17:59 -04:00
root d19d27bd88 update 10 docker CI 2026-03-09 17:13:41 -04:00
root cd73300bc6 update 9 docker CI 2026-03-09 17:10:14 -04:00
root 6e8c3f5466 update 8 docker CI 2026-03-09 17:06:56 -04:00
root e2c3e3cf85 update 7 docker CI 2026-03-09 17:05:24 -04:00
root d9567f925f update 6 docker CI 2026-03-09 17:01:24 -04:00
root d7d718c469 update 5 docker CI 2026-03-09 16:54:38 -04:00
root 380740e4c8 update 4 docker CI 2026-03-09 16:50:25 -04:00
root af250d9fd4 update 3 docker CI 2026-03-09 16:43:12 -04:00
root 52bd34e213 update 3 docker CI 2026-03-09 16:38:19 -04:00
root ad5e42c6c7 update 3 ci docker 2026-03-09 16:32:05 -04:00
root f8aa350bfd update ci docker 2026-03-09 16:26:39 -04:00
root adc4219766 docker ci file 2026-03-09 16:05:44 -04:00
root 79e44fe037 fix logic and tests, update docker CI file 2026-03-09 16:03:16 -04:00
root 1cb3573d4b add more controllers and fix tests 2026-03-09 16:02:30 -04:00
Administrator 0c3e9b16f7 Edit docker-compose.ci.yml 2026-03-09 19:29:52 +00:00
Administrator b3efb8c815 Edit docker-compose.ci.yml 2026-03-09 19:26:22 +00:00
Administrator 6fbca86b9b Edit docker-compose.ci.yml 2026-03-09 19:24:26 +00:00
root d76c871cb7 add controllers, servoices 2026-03-09 02:52:13 -04:00
2424 changed files with 197455 additions and 57196 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
+37 -4
View File
@@ -1,4 +1,9 @@
FROM php:8.2-cli ARG PHP_IMAGE=php:8.2-cli
ARG COMPOSER_IMAGE=composer:2
FROM ${COMPOSER_IMAGE} AS composer
FROM ${PHP_IMAGE}
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
@@ -6,11 +11,39 @@ RUN apt-get update \
unzip \ unzip \
sqlite3 \ sqlite3 \
libsqlite3-dev \ libsqlite3-dev \
default-mysql-client \
libonig-dev \ libonig-dev \
libxml2-dev \ libxml2-dev \
&& docker-php-ext-install pdo_sqlite mbstring xml \ && docker-php-ext-install pdo_sqlite pdo_mysql mbstring xml \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer COPY --from=composer /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html ENV APP_DIR=/app
WORKDIR ${APP_DIR}
COPY . ${APP_DIR}
RUN printf '%s\n' \
'#!/bin/sh' \
'set -e' \
'if [ -z "${APP_KEY:-}" ]; then' \
' if command -v php >/dev/null 2>&1; then' \
' APP_KEY=$(php -r '"'"'echo "base64:" . base64_encode(random_bytes(32));'"'"')' \
' elif command -v openssl >/dev/null 2>&1; then' \
' APP_KEY=$(openssl rand -base64 32 | sed "s/^/base64:/")' \
' else' \
' echo "APP_KEY is required but php/openssl is unavailable." >&2' \
' exit 1' \
' fi' \
' export APP_KEY' \
'fi' \
'exec "$@"' \
> /usr/local/bin/ci-entrypoint \
&& chmod +x /usr/local/bin/ci-entrypoint
RUN if [ -f composer.json ]; then \
composer install --no-interaction --prefer-dist --no-progress; \
fi
ENTRYPOINT ["ci-entrypoint"]
+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.
Vendored
+4 -3
View File
@@ -22,7 +22,7 @@ pipeline {
stage('Prepare Test DB') { stage('Prepare Test DB') {
steps { steps {
sh 'docker compose -f docker-compose.ci.yml run --rm app sh -lc \"mkdir -p database && touch database/database.sqlite\"' sh 'docker compose -f docker-compose.ci.yml run --rm app sh -lc "echo using mysql: $DB_HOST:$DB_PORT/$DB_DATABASE"'
} }
} }
@@ -31,16 +31,17 @@ pipeline {
script { script {
env.APP_KEY = sh( env.APP_KEY = sh(
returnStdout: true, returnStdout: true,
script: "php -r 'echo \"base64:\" . base64_encode(random_bytes(32));'" script: "docker compose -f docker-compose.ci.yml run --rm keygen"
).trim() ).trim()
} }
sh 'APP_KEY=$APP_KEY docker compose -f docker-compose.ci.yml run --rm app php artisan test' sh 'APP_KEY=$APP_KEY docker compose -f docker-compose.ci.yml run --rm app sh -lc "for i in \\$(seq 1 60); do mysqladmin ping -h \\${DB_HOST:-db} -u\\${DB_USERNAME:-school} -p\\${DB_PASSWORD:-school} >/dev/null 2>&1 && break; sleep 2; done; php artisan migrate --force && php artisan test"'
} }
} }
} }
post { post {
always { always {
sh 'docker compose -f docker-compose.ci.yml rm -f -s -v || true'
sh 'docker compose -f docker-compose.ci.yml down -v --remove-orphans' sh 'docker compose -f docker-compose.ci.yml down -v --remove-orphans'
} }
} }
+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\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'],
],
];
@@ -0,0 +1,27 @@
<?php
namespace App\Console\Commands;
use App\Services\Attendance\AttendanceAutoPublishJobService;
use Illuminate\Console\Command;
class AttendanceAutoPublishCommand extends Command
{
protected $signature = 'attendance:auto-publish';
protected $description = 'Auto-publish attendance days per "second Sunday backward" rule.';
public function handle(AttendanceAutoPublishJobService $service): int
{
$result = $service->run();
$this->info(sprintf(
'Auto-publish checked at %s (TZ: %s). Published rows: %d.',
$result['checked_at'],
$result['timezone'],
$result['published']
));
return self::SUCCESS;
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Console\Commands;
use App\Services\Payments\PaymentMissedCheckService;
use Illuminate\Console\Command;
class CheckMissedPaymentsCommand extends Command
{
protected $signature = 'payments:check-missed';
protected $description = 'Checks for users who missed payments and sends reminders.';
public function handle(PaymentMissedCheckService $service): int
{
$users = $service->findUsersWithMissedPayments();
if (empty($users)) {
$this->info('No missed payments found.');
return self::SUCCESS;
}
$result = $service->sendReminders($users);
$this->info(sprintf('Finished checking missed payments. Sent: %d, Failed: %d.', $result['sent'], $result['failed']));
return self::SUCCESS;
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Console\Commands;
use App\Services\Notifications\NotificationCleanupService;
use Illuminate\Console\Command;
class CleanupExpiredNotificationsCommand extends Command
{
protected $signature = 'notifications:cleanup';
protected $description = 'Deletes expired notifications from the database.';
public function handle(NotificationCleanupService $service): int
{
$count = $service->cleanupExpired();
if ($count <= 0) {
$this->info('No expired notifications found to soft delete.');
return self::SUCCESS;
}
$this->info("Soft-deleted {$count} expired notifications.");
return self::SUCCESS;
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Console\Commands;
use App\Services\Auth\PasswordResetCleanupService;
use Illuminate\Console\Command;
class CleanupPasswordResetsCommand extends Command
{
protected $signature = 'cleanup:password-resets {--days=30}';
protected $description = 'Delete password reset requests older than N days.';
public function handle(PasswordResetCleanupService $service): int
{
$days = (int) $this->option('days');
$count = $service->cleanup($days > 0 ? $days : 30);
$this->info("Deleted {$count} old password reset request(s).");
return self::SUCCESS;
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use App\Services\System\ConfigUpdateService;
use DateTimeZone;
use Illuminate\Console\Command;
class ConfigUpdateCommand extends Command
{
protected $signature = 'config:update {task?} {--task=} {--dry} {--force} {--tz=}';
protected $description = 'Run a configuration update task (weekly cron).';
public function handle(ConfigUpdateService $service): int
{
$task = (string) ($this->option('task') ?: $this->argument('task') ?: '');
$dry = (bool) $this->option('dry');
$force = (bool) $this->option('force');
$tzName = (string) ($this->option('tz') ?: (config('School')->attendance['timezone'] ?? 'UTC'));
$tz = new DateTimeZone($tzName);
if ($task === '' || ! in_array($task, $service->availableTasks(), true)) {
$this->error('Invalid or missing --task. Available: '.implode(', ', $service->availableTasks()));
return self::FAILURE;
}
$result = $service->runTask($task, $dry, $force, $tz);
if (! $result['ok']) {
$this->error($result['message'] ?? "Task '{$task}' failed.");
return self::FAILURE;
}
$this->info("Task '{$task}' finished successfully.".($dry ? ' [DRY RUN]' : ''));
return self::SUCCESS;
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Console\Commands;
use App\Services\Users\InactiveUserCleanupService;
use Illuminate\Console\Command;
class DeleteInactiveUsersCommand extends Command
{
protected $signature = 'users:delete-inactive-users {--minutes=15}';
protected $description = 'Delete users that are inactive and created more than N minutes ago.';
public function handle(InactiveUserCleanupService $service): int
{
$minutes = (int) $this->option('minutes');
$result = $service->cleanup($minutes > 0 ? $minutes : 15);
$this->info(sprintf(
'Deleted %d inactive users, %d parents rows, %d orphaned user_roles.',
$result['deleted_users'],
$result['deleted_parents'],
$result['deleted_roles']
));
return self::SUCCESS;
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Console\Commands;
use App\Events\DeleteUnverifiedUser;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Event;
class DeleteUnverifiedUsersCommand extends Command
{
protected $signature = 'users:delete-unverified {--timeout=86400}';
protected $description = 'Delete unverified users older than the configured timeout (seconds).';
public function handle(): int
{
$timeout = (int) $this->option('timeout');
if ($timeout <= 0) {
$timeout = 86400;
}
$cutoff = now()->subSeconds($timeout);
$users = User::query()
->where('is_verified', 0)
->where(function ($q) use ($cutoff) {
$q->where('updated_at', '<=', $cutoff)
->orWhere(function ($sub) use ($cutoff) {
$sub->whereNull('updated_at')->where('created_at', '<=', $cutoff);
});
})
->get(['id']);
$count = 0;
foreach ($users as $user) {
Event::dispatch(new DeleteUnverifiedUser((int) $user->id));
$count++;
}
$this->info("Dispatched delete events for {$count} unverified user(s).");
return self::SUCCESS;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class ImportUsers extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:import-users';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command description';
/**
* Execute the console command.
*/
public function handle()
{
//
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Console\Commands;
use App\Services\Attendance\AttendanceSummaryRebuildService;
use Illuminate\Console\Command;
class RecalculateAttendanceCommand extends Command
{
protected $signature = 'attendance:recalculate-summary';
protected $description = 'Recalculates the attendance summary records from raw attendance data.';
public function handle(AttendanceSummaryRebuildService $service): int
{
$result = $service->rebuild();
$this->info('Attendance summary recalculation finished. Inserted: '.$result['inserted']);
return self::SUCCESS;
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Console\Commands;
use App\Services\Attendance\AttendanceDailySummaryService;
use Illuminate\Console\Command;
class SendAbsenteesSummaryCommand extends Command
{
protected $signature = 'attendance:absentees-summary';
protected $description = 'Sends daily attendance summaries for absences to parents.';
public function handle(AttendanceDailySummaryService $service): int
{
$sent = $service->sendAbsenteesSummary();
$this->info("Attendance absentees summary completed. Sent: {$sent}.");
return self::SUCCESS;
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Console\Commands;
use App\Services\Attendance\AttendanceDailySummaryService;
use Illuminate\Console\Command;
class SendLatesSummaryCommand extends Command
{
protected $signature = 'attendance:lates-summary';
protected $description = 'Sends daily attendance summaries for lates to parents.';
public function handle(AttendanceDailySummaryService $service): int
{
$sent = $service->sendLatesSummary();
$this->info("Attendance lates summary completed. Sent: {$sent}.");
return self::SUCCESS;
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Services\Payments\PaymentNotificationService;
use Illuminate\Console\Command;
class SendMonthlyPaymentNotificationsCommand extends Command
{
protected $signature = 'payments:monthly-reminder {--force} {--email=} {--type=}';
protected $description = 'Send monthly payment reminders on the first Saturday of every month.';
public function handle(PaymentNotificationService $service): int
{
$force = (bool) $this->option('force');
$email = (string) ($this->option('email') ?? '');
$type = (string) ($this->option('type') ?? '');
if ($email !== '') {
$user = User::query()->where('email', $email)->first();
if (! $user) {
$this->error('Invalid --email provided.');
return self::FAILURE;
}
$result = $service->send([
'parent_id' => (int) $user->id,
'type' => $type,
'force' => true,
]);
$this->info('Test reminder sent for '.$email.'.');
return $result['failed'] > 0 ? self::FAILURE : self::SUCCESS;
}
if (! $force && ! $this->isFirstSaturday(now())) {
$this->info('Not the first Saturday of the month. Use --force to override.');
return self::SUCCESS;
}
$result = $service->send([
'type' => $type,
'force' => $force,
]);
$this->info(sprintf('Done. Sent: %d, Skipped: %d, Failed: %d.', $result['sent'], $result['skipped'], $result['failed']));
return $result['failed'] > 0 ? self::FAILURE : self::SUCCESS;
}
private function isFirstSaturday(\DateTimeInterface $dt): bool
{
return (int) $dt->format('w') === 6 && (int) $dt->format('j') <= 7;
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use App\Services\Payments\PaymentTestNotificationService;
use Illuminate\Console\Command;
class SendTestPaymentNotificationCommand extends Command
{
protected $signature = 'payments:send-test {--email=} {--type=no_payment}';
protected $description = 'Send a single test non-payment or installment reminder to a specific email.';
public function handle(PaymentTestNotificationService $service): int
{
$email = (string) $this->option('email');
$type = (string) $this->option('type');
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
$this->error('Usage: php artisan payments:send-test --email=addr@example.com [--type=no_payment|installment]');
return self::FAILURE;
}
$result = $service->send($email, $type);
if (! $result['ok']) {
$this->error('Failed to send test reminder to '.$email.'.');
return self::FAILURE;
}
$this->info('Sent test reminder to '.$email.'.');
return self::SUCCESS;
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Console\Commands;
use App\Services\Payments\PaypalPaymentSyncService;
use Illuminate\Console\Command;
class SyncPaypalPaymentsCommand extends Command
{
protected $signature = 'payments:sync-paypal {--dry-run} {--report-only}';
protected $description = 'Sync PayPal payments to internal payments table from paypal_payments.';
public function handle(PaypalPaymentSyncService $service): int
{
$dryRun = (bool) $this->option('dry-run');
$reportOnly = (bool) $this->option('report-only');
$result = $service->sync($dryRun, $reportOnly);
$this->info(sprintf('[%s] %d PayPal payments processed.', $result['mode'], $result['processed']));
if (! empty($result['failed'])) {
$this->error(sprintf('[%s] Failed transactions: %s', $result['mode'], implode(', ', $result['failed'])));
}
return self::SUCCESS;
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Events;
class DeleteUnverifiedUser
{
public function __construct(public int $userId) {}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class WhatsappInvitesSend
{
use Dispatchable;
use SerializesModels;
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.
@@ -3,17 +3,17 @@
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\Administrator\SubmitAdministratorAbsenceRequest;
use App\Services\Administrator\AdministratorAbsenceService; use App\Services\Administrator\AdministratorAbsenceService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
class AdministratorAbsenceController extends Controller class AdministratorAbsenceController extends Controller
{ {
public function __construct( public function __construct(
protected AdministratorAbsenceService $service protected AdministratorAbsenceService $service
) { ) {}
}
public function index(): JsonResponse public function index(): JsonResponse
{ {
@@ -25,13 +25,42 @@ class AdministratorAbsenceController extends Controller
return response()->json($this->service->getAbsenceFormData($userId)); return response()->json($this->service->getAbsenceFormData($userId));
} }
public function store(SubmitAdministratorAbsenceRequest $request): JsonResponse public function store(Request $request): JsonResponse
{ {
$userId = (int) Auth::id(); $userId = (int) Auth::id();
if ($userId <= 0) { if ($userId <= 0) {
return response()->json(['message' => 'Please log in first.'], 401); return response()->json(['message' => 'Please log in first.'], 401);
} }
$payload = $request->all();
if (! array_key_exists('dates', $payload) || ! array_key_exists('reason', $payload)) {
$json = json_decode($request->getContent() ?: '', true);
if (is_array($json)) {
$payload = array_merge($payload, $json);
}
}
$validator = Validator::make($payload, [
'dates' => ['required', 'array', 'min:1'],
'dates.*' => ['required', 'date_format:Y-m-d'],
'reason_type' => ['nullable', 'string', 'max:100'],
'reason' => ['required', 'string', 'max:2000'],
], [
'dates.required' => 'At least one date is required.',
'dates.array' => 'Dates must be submitted as an array.',
'dates.min' => 'At least one date is required.',
'dates.*.date_format' => 'Each date must be in Y-m-d format.',
'reason.required' => 'Reason is required.',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$request->merge($validator->validated());
$result = $this->service->submit($request, $userId); $result = $this->service->submit($request, $userId);
return response()->json([ return response()->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
{ {
@@ -3,18 +3,18 @@
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\Administrator\UpdateEnrollmentStatusesRequest;
use App\Services\Administrator\AdministratorEnrollmentService; use App\Services\Administrator\AdministratorEnrollmentService;
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 Illuminate\Validation\Rule;
class AdministratorEnrollmentController extends Controller 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
{ {
@@ -30,14 +30,53 @@ class AdministratorEnrollmentController extends Controller
return response()->json($this->service->showNewStudentsData()); return response()->json($this->service->showNewStudentsData());
} }
public function updateStatuses(UpdateEnrollmentStatusesRequest $request): JsonResponse public function updateStatuses(Request $request): JsonResponse
{ {
$editorUserId = (int) Auth::id(); $editorUserId = (int) Auth::id();
if ($editorUserId <= 0) { if ($editorUserId <= 0) {
return response()->json(['message' => 'Please log in first.'], 401); return response()->json(['message' => 'Please log in first.'], 401);
} }
$data = $request->validated(); $payload = $request->all();
if (! array_key_exists('enrollment_status', $payload)) {
$json = json_decode($request->getContent() ?: '', true);
if (is_array($json) && array_key_exists('enrollment_status', $json)) {
$payload['enrollment_status'] = $json['enrollment_status'];
}
}
if (! array_key_exists('enrollment_status', $payload)) {
$payload['enrollment_status'] = $request->query('enrollment_status');
}
$allowedStatuses = [
'admission under review',
'payment pending',
'enrolled',
'withdraw under review',
'refund pending',
'withdrawn',
'denied',
'waitlist',
];
$validator = Validator::make($payload, [
'enrollment_status' => ['required', 'array', 'min:1'],
'enrollment_status.*' => ['required', 'string', Rule::in($allowedStatuses)],
], [
'enrollment_status.required' => 'Enrollment statuses are required.',
'enrollment_status.array' => 'Enrollment statuses must be an array.',
'enrollment_status.min' => 'At least one enrollment status is required.',
'enrollment_status.*.in' => 'One or more enrollment statuses are invalid.',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$data = $validator->validated();
$result = $this->service->updateStatuses( $result = $this->service->updateStatuses(
(array) ($data['enrollment_status'] ?? []), (array) ($data['enrollment_status'] ?? []),
$editorUserId $editorUserId
@@ -3,26 +3,47 @@
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\Administrator\SaveAdminNotificationSubjectsRequest;
use App\Http\Requests\Administrator\SavePrintRecipientsRequest;
use App\Services\Administrator\AdministratorNotificationService; use App\Services\Administrator\AdministratorNotificationService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class AdministratorNotificationController extends Controller class AdministratorNotificationController extends Controller
{ {
public function __construct( public function __construct(
protected AdministratorNotificationService $service protected AdministratorNotificationService $service
) { ) {}
}
public function alerts(): JsonResponse public function alerts(): JsonResponse
{ {
return response()->json($this->service->notificationsAlertsData()); return response()->json($this->service->notificationsAlertsData());
} }
public function saveAlerts(SaveAdminNotificationSubjectsRequest $request): JsonResponse public function saveAlerts(Request $request): JsonResponse
{ {
$data = $request->validated(); $payload = $request->all();
if (! array_key_exists('subjects', $payload)) {
$json = json_decode($request->getContent() ?: '', true);
if (is_array($json)) {
$payload = array_merge($payload, $json);
}
}
$validator = Validator::make($payload, [
'subjects' => ['required', 'array'],
], [
'subjects.required' => 'Subjects payload is required.',
'subjects.array' => 'Subjects payload must be an array.',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$data = $validator->validated();
$result = $this->service->saveNotificationSubjects( $result = $this->service->saveNotificationSubjects(
(array) ($data['subjects'] ?? []) (array) ($data['subjects'] ?? [])
); );
@@ -35,9 +56,31 @@ class AdministratorNotificationController extends Controller
return response()->json($this->service->printNotificationRecipientsData()); return response()->json($this->service->printNotificationRecipientsData());
} }
public function savePrintRecipients(SavePrintRecipientsRequest $request): JsonResponse public function savePrintRecipients(Request $request): JsonResponse
{ {
$data = $request->validated(); $payload = $request->all();
if (! array_key_exists('notify', $payload)) {
$json = json_decode($request->getContent() ?: '', true);
if (is_array($json)) {
$payload = array_merge($payload, $json);
}
}
$validator = Validator::make($payload, [
'notify' => ['required', 'array'],
], [
'notify.required' => 'Notify payload is required.',
'notify.array' => 'Notify payload must be an array.',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$data = $validator->validated();
$result = $this->service->savePrintNotificationRecipients( $result = $this->service->savePrintNotificationRecipients(
(array) ($data['notify'] ?? []) (array) ($data['notify'] ?? [])
); );
@@ -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;
}
}
@@ -3,30 +3,47 @@
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\Administrator\SendTeacherSubmissionNotificationsRequest;
use App\Services\Administrator\AdministratorTeacherSubmissionService; use App\Services\Administrator\AdministratorTeacherSubmissionService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
class AdministratorTeacherSubmissionController extends Controller 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(SendTeacherSubmissionNotificationsRequest $request): JsonResponse public function notify(Request $request): JsonResponse
{ {
$adminId = (int) Auth::id(); $adminId = (int) Auth::id();
if ($adminId <= 0) { if ($adminId <= 0) {
return response()->json(['message' => 'Please log in first.'], 401); return response()->json(['message' => 'Please log in first.'], 401);
} }
$validator = Validator::make($request->all(), [
'notify' => ['required', 'array', 'min:1'],
'missing_items' => ['nullable', 'array'],
], [
'notify.required' => 'Select at least one teacher to notify.',
'notify.array' => 'Notify payload must be an array.',
'notify.min' => 'Select at least one teacher to notify.',
'missing_items.array' => 'Missing items payload must be an array.',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$result = $this->service->sendNotifications($request, $adminId); $result = $this->service->sendNotifications($request, $adminId);
return response()->json([ return response()->json([
@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Api\Administrator;
use App\Http\Controllers\Controller;
use App\Http\Requests\EmergencyContacts\EmergencyContactParentRequest;
use App\Http\Requests\EmergencyContacts\EmergencyContactUpdateRequest;
use App\Http\Resources\EmergencyContacts\EmergencyContactGroupResource;
use App\Http\Resources\EmergencyContacts\EmergencyContactResource;
use App\Services\EmergencyContacts\EmergencyContactCrudService;
use App\Services\EmergencyContacts\EmergencyContactDirectoryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class EmergencyContactController extends Controller
{
public function __construct(
private EmergencyContactDirectoryService $directoryService,
private EmergencyContactCrudService $crudService
) {}
public function index(Request $request): JsonResponse
{
$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([
'ok' => true,
'groups' => EmergencyContactGroupResource::collection($groups),
]);
}
public function show(EmergencyContactParentRequest $request, int $contactId): JsonResponse
{
$parentId = (int) $request->validated()['parent_id'];
$contact = $this->crudService->findForParent($contactId, $parentId);
return response()->json([
'ok' => true,
'contact' => new EmergencyContactResource($contact),
]);
}
public function update(EmergencyContactUpdateRequest $request, int $contactId): JsonResponse
{
$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]);
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers\Api\Administrator;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Teachers\TeacherAssignmentDeleteRequest;
use App\Http\Requests\Teachers\TeacherAssignmentListRequest;
use App\Http\Requests\Teachers\TeacherAssignmentStoreRequest;
use App\Http\Resources\Teachers\TeacherAssignmentResource;
use App\Http\Resources\Teachers\TeacherClassResource;
use App\Services\Teachers\TeacherAssignmentService;
use Illuminate\Http\JsonResponse;
class TeacherClassAssignmentController extends BaseApiController
{
public function __construct(private TeacherAssignmentService $assignmentService)
{
parent::__construct();
}
public function index(TeacherAssignmentListRequest $request): JsonResponse
{
$data = $this->assignmentService->listAssignments($request->validated()['school_year'] ?? null);
return response()->json([
'ok' => true,
'school_year' => $data['school_year'],
'teachers' => TeacherAssignmentResource::collection($data['teachers']),
'classes' => TeacherClassResource::collection($data['classes']),
]);
}
public function store(TeacherAssignmentStoreRequest $request): JsonResponse
{
$userId = $this->authenticatedUserIdOrUnauthorized();
if ($userId instanceof JsonResponse) {
return $userId;
}
$payload = $request->validated();
$payload['updated_by'] = $userId;
$result = $this->assignmentService->assign($payload);
$status = $result['ok'] ? 200 : 422;
return response()->json([
'ok' => (bool) $result['ok'],
'message' => $result['message'] ?? '',
'position' => $result['position'] ?? null,
], $status);
}
public function destroy(TeacherAssignmentDeleteRequest $request): JsonResponse
{
$result = $this->assignmentService->delete($request->validated());
$status = $result['ok'] ? 200 : 422;
return response()->json([
'ok' => (bool) $result['ok'],
'message' => $result['message'] ?? '',
], $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
),
]);
}
}
Binary file not shown.
@@ -3,12 +3,12 @@
namespace App\Http\Controllers\Api\Assignment; namespace App\Http\Controllers\Api\Assignment;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Assignment\StoreAssignmentRequest;
use App\Http\Resources\Assignment\AssignmentOverviewResource; use App\Http\Resources\Assignment\AssignmentOverviewResource;
use App\Http\Resources\Assignment\AssignmentSectionResource; use App\Http\Resources\Assignment\AssignmentSectionResource;
use App\Services\Assignment\AssignmentService; use App\Services\Assignment\AssignmentService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class AssignmentApiController extends Controller class AssignmentApiController extends Controller
{ {
@@ -29,11 +29,31 @@ class AssignmentApiController extends Controller
]); ]);
} }
public function store(StoreAssignmentRequest $request): JsonResponse public function store(Request $request): JsonResponse
{ {
$validator = Validator::make($request->all(), [
'student_id' => ['required', 'integer', 'exists:students,id'],
'class_section_id' => ['required', 'integer', 'exists:classSection,class_section_id'],
'semester' => ['required', 'string', 'max:50'],
'school_year' => ['required', 'string', 'max:50'],
'description' => ['nullable', 'string'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 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: $request->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,9 +47,14 @@ 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,17 +3,25 @@
namespace App\Http\Controllers\Api\Attendance; namespace App\Http\Controllers\Api\Attendance;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\AttendanceCommentTemplate\StoreAttendanceCommentTemplateRequest; use App\Services\ApplicationUrlService;
use App\Http\Requests\AttendanceCommentTemplate\UpdateAttendanceCommentTemplateRequest;
use App\Services\AttendanceCommentTemplateService; use App\Services\AttendanceCommentTemplateService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
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
@@ -49,9 +57,28 @@ class AttendanceCommentTemplateController extends Controller
]); ]);
} }
public function store(StoreAttendanceCommentTemplateRequest $request): JsonResponse public function store(Request $request): JsonResponse
{ {
$created = $this->service->create($request->validated()); $validator = Validator::make($request->all(), [
'min_score' => ['required', 'integer', 'min:0', 'max:100'],
'max_score' => ['required', 'integer', 'min:0', 'max:100', 'gte:min_score'],
'template_text' => ['required', 'string', 'max:5000'],
'is_active' => ['sometimes', 'boolean'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$data = $validator->validated();
if (! array_key_exists('is_active', $data)) {
$data['is_active'] = true;
}
$created = $this->service->create($data);
return response()->json([ return response()->json([
'status' => 'success', 'status' => 'success',
@@ -60,9 +87,31 @@ class AttendanceCommentTemplateController extends Controller
], 201); ], 201);
} }
public function update(UpdateAttendanceCommentTemplateRequest $request, int $id): JsonResponse public function update(Request $request, int $id): JsonResponse
{ {
$updated = $this->service->update($id, $request->validated()); $validator = Validator::make($request->all(), [
'min_score' => ['sometimes', 'integer', 'min:0', 'max:100'],
'max_score' => ['sometimes', 'integer', 'min:0', 'max:100'],
'template_text' => ['sometimes', 'string', 'max:5000'],
'is_active' => ['sometimes', 'boolean'],
]);
$validator->after(function ($validator) use ($request) {
$min = $request->has('min_score') ? (int) $request->input('min_score') : null;
$max = $request->has('max_score') ? (int) $request->input('max_score') : null;
if ($min !== null && $max !== null && $max < $min) {
$validator->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);
}
$updated = $this->service->update($id, $validator->validated());
return response()->json([ return response()->json([
'status' => 'success', 'status' => 'success',
@@ -79,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.');
}
}
@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\Api\Attendance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Attendance\LateSlipLogIndexRequest;
use App\Http\Resources\Attendance\LateSlipLogCollection;
use App\Http\Resources\Attendance\LateSlipLogResource;
use App\Models\LateSlipLog;
use App\Services\Attendance\LateSlipLogCommandService;
use App\Services\Attendance\LateSlipLogQueryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class LateSlipLogsController extends BaseApiController
{
public function __construct(
private LateSlipLogQueryService $queryService,
private LateSlipLogCommandService $commandService
) {
parent::__construct();
}
public function index(LateSlipLogIndexRequest $request): JsonResponse
{
$this->authorize('viewAny', LateSlipLog::class);
$filters = $request->validated();
$page = (int) ($filters['page'] ?? 1);
$perPage = (int) ($filters['per_page'] ?? 50);
$logs = $this->queryService->paginate($filters, $page, $perPage);
$collection = new LateSlipLogCollection($logs);
return $this->success([
'logs' => $collection->toArray($request),
'meta' => $collection->with($request)['meta'],
]);
}
public function show(int $id): JsonResponse
{
$log = $this->queryService->find($id);
if (! $log) {
return $this->error('Late slip log not found.', Response::HTTP_NOT_FOUND);
}
$this->authorize('view', $log);
return $this->success([
'log' => new LateSlipLogResource($log),
]);
}
public function destroy(int $id): JsonResponse
{
$log = $this->queryService->find($id);
if (! $log) {
return $this->error('Late slip log not found.', Response::HTTP_NOT_FOUND);
}
$this->authorize('delete', $log);
try {
$deleted = $this->commandService->delete($log);
} catch (\Throwable $e) {
Log::error('Late slip log delete failed: '.$e->getMessage());
return $this->error('Unable to delete late slip log.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
if (! $deleted) {
return $this->error('Unable to delete late slip log.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success(null, 'Late slip log deleted.');
}
}
@@ -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);
}
}
}
@@ -3,21 +3,16 @@
namespace App\Http\Controllers\Api\AttendanceTracking; namespace App\Http\Controllers\Api\AttendanceTracking;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\AttendanceTracking\ComposeAttendanceEmailRequest; use App\Services\AttendanceTracking\AttendanceTrackingService;
use App\Http\Requests\AttendanceTracking\ParentsInfoRequest;
use App\Http\Requests\AttendanceTracking\RecordAttendanceTrackingRequest;
use App\Http\Requests\AttendanceTracking\SaveAttendanceNotificationNoteRequest;
use App\Http\Requests\AttendanceTracking\SendAttendanceManualEmailRequest;
use App\Services\AttendanceTrackingService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class AttendanceTrackingController extends Controller 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
{ {
@@ -63,9 +58,26 @@ class AttendanceTrackingController extends Controller
); );
} }
public function record(RecordAttendanceTrackingRequest $request): JsonResponse public function record(Request $request): JsonResponse
{ {
$result = $this->service->record($request->validated()); $validator = Validator::make($request->all(), [
'student_id' => ['required', 'integer', 'min:1'],
'date' => ['required', 'date_format:Y-m-d'],
'parent_email' => ['nullable', 'email'],
'parent_name' => ['nullable', 'string'],
'subject_type' => ['nullable', 'string'],
'semester' => ['nullable', 'string'],
'school_year' => ['nullable', 'string'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$result = $this->service->record($validator->validated());
return response()->json( return response()->json(
$result, $result,
@@ -80,13 +92,28 @@ class AttendanceTrackingController extends Controller
return response()->json($result); return response()->json($result);
} }
public function compose(ComposeAttendanceEmailRequest $request): JsonResponse public function compose(Request $request): JsonResponse
{ {
$validator = Validator::make($request->query(), [
'student_id' => ['required', 'integer', 'min:1'],
'code' => ['nullable', 'string'],
'variant' => ['nullable', 'string'],
'incident_date' => ['nullable', 'date_format:Y-m-d'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$data = $validator->validated();
$result = $this->service->compose( $result = $this->service->compose(
(int) $request->validated('student_id'), (int) ($data['student_id'] ?? 0),
(string) $request->validated('code', 'ABS_1'), (string) ($data['code'] ?? 'ABS_1'),
(string) $request->validated('variant', 'default'), (string) ($data['variant'] ?? 'default'),
$request->validated('incident_date'), $data['incident_date'] ?? null,
); );
return response()->json( return response()->json(
@@ -95,9 +122,26 @@ class AttendanceTrackingController extends Controller
); );
} }
public function sendManualEmail(SendAttendanceManualEmailRequest $request): JsonResponse public function sendManualEmail(Request $request): JsonResponse
{ {
$result = $this->service->sendEmailManual($request->validated()); $validator = Validator::make($request->all(), [
'student_id' => ['required', 'integer', 'min:1'],
'to' => ['required', 'email'],
'subject' => ['required', 'string'],
'body_html' => ['required', 'string'],
'code' => ['required', 'string'],
'variant' => ['nullable', 'string'],
'incident_date' => ['nullable', 'date_format:Y-m-d'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$result = $this->service->sendEmailManual($validator->validated());
return response()->json( return response()->json(
$result, $result,
@@ -105,9 +149,21 @@ class AttendanceTrackingController extends Controller
); );
} }
public function parentsInfo(ParentsInfoRequest $request): JsonResponse public function parentsInfo(Request $request): JsonResponse
{ {
$result = $this->service->parentsInfo((int) $request->validated('student_id')); $validator = Validator::make($request->query(), [
'student_id' => ['required', 'integer', 'min:1'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$data = $validator->validated();
$result = $this->service->parentsInfo((int) ($data['student_id'] ?? 0));
return response()->json( return response()->json(
$result, $result,
@@ -115,9 +171,26 @@ class AttendanceTrackingController extends Controller
); );
} }
public function saveNotificationNote(SaveAttendanceNotificationNoteRequest $request): JsonResponse public function saveNotificationNote(Request $request): JsonResponse
{ {
$result = $this->service->saveNotificationNote($request->validated()); $validator = Validator::make($request->all(), [
'student_id' => ['required', 'integer', 'min:1'],
'code' => ['required', 'string'],
'note' => ['required', 'string'],
'incident_date' => ['nullable', 'date_format:Y-m-d'],
'to' => ['nullable', 'email'],
'subject' => ['nullable', 'string'],
'variant' => ['nullable', 'string'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$result = $this->service->saveNotificationNote($validator->validated());
return response()->json( return response()->json(
$result, $result,
@@ -0,0 +1,140 @@
<?php
namespace App\Http\Controllers\Api\Auth;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Models\User;
use App\Services\Auth\ApiLoginSecurityService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
class AuthController extends BaseApiController
{
public function login(Request $request, ApiLoginSecurityService $security): JsonResponse
{
$payload = $request->all();
if ($request->isJson() && ($payload === [] || $payload === null)) {
$decoded = json_decode((string) $request->getContent(), true);
$payload = is_array($decoded) ? $decoded : [];
}
$email = strtolower(trim((string) ($payload['email'] ?? '')));
$password = (string) ($payload['password'] ?? '');
if ($email === '' || $password === '') {
return response()->json([
'status' => false,
'message' => 'Email and password are required.',
], 400);
}
$ip = $request->ip();
if ($security->isIpBlocked((string) $ip)) {
return response()->json([
'status' => false,
'message' => 'Too many failed attempts from your IP. Please try again later.',
], 429);
}
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
if (! $user) {
$security->logIpAttempt((string) $ip);
return response()->json([
'status' => false,
'message' => 'Invalid email or password.',
], 401);
}
if (! verify_stored_password($password, (string) $user->password)) {
$security->handleFailedLogin($user, $email, (string) $ip);
return response()->json([
'status' => false,
'message' => 'Invalid email or password.',
], 401);
}
// Suspension is checked AFTER credentials are verified so that the
// response shape cannot be used to enumerate which emails exist.
if ($user->is_suspended) {
return response()->json([
'status' => false,
'message' => 'Account suspended. Please reset your password.',
], 403);
}
$security->resetFailedAttempts($user);
$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
{
try {
$newToken = JWTAuth::parseToken()->refresh();
} catch (\Throwable $e) {
return $this->respondError('Token refresh failed.', 401);
}
return $this->respondSuccess([
'access_token' => $newToken,
'token_type' => 'bearer',
'expires_in' => (int) config('jwt.ttl') * 60,
], 'Token refreshed.');
}
public function me(): JsonResponse
{
$user = Auth::user();
if (! $user) {
return $this->respondError('Unauthorized.', 401);
}
$teacherContext = $user->teacherSessionContext();
return $this->respondSuccess([
'id' => (int) $user->id,
'firstname' => $user->firstname,
'lastname' => $user->lastname,
'email' => $user->email,
'class_section_id' => $teacherContext['class_section_id'],
'class_section_name' => $teacherContext['class_section_name'],
], 'OK');
}
public function logout(Request $request): JsonResponse
{
$token = $request->bearerToken();
if ($token && substr_count($token, '.') === 2) {
try {
JWTAuth::setToken($token)->invalidate();
} catch (\Throwable $e) {
// ignore invalidation errors
}
}
$user = $request->user();
if ($user && method_exists($user, 'currentAccessToken')) {
$current = $user->currentAccessToken();
if ($current) {
$current->delete();
}
}
return $this->respondSuccess(null, 'Logged out.');
}
}
@@ -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,
]);
}
}
@@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Api\Auth;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Security\IpBanIndexRequest;
use App\Http\Requests\Security\IpBanRequest;
use App\Http\Requests\Security\IpUnbanRequest;
use App\Http\Resources\Security\IpBanCollection;
use App\Http\Resources\Security\IpBanResource;
use App\Models\IpAttempt;
use App\Services\Security\IpBanCommandService;
use App\Services\Security\IpBanQueryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class IpBanController extends BaseApiController
{
public function __construct(
private IpBanQueryService $queryService,
private IpBanCommandService $commandService
) {
parent::__construct();
}
public function index(IpBanIndexRequest $request): JsonResponse
{
$this->authorize('viewAny', IpAttempt::class);
$filters = $request->validated();
$page = (int) ($filters['page'] ?? 1);
$perPage = (int) ($filters['per_page'] ?? 20);
$bans = $this->queryService->paginate($filters, $page, $perPage);
$collection = new IpBanCollection($bans);
return $this->success([
'bans' => $collection->toArray($request),
'meta' => $collection->with($request)['meta'],
]);
}
public function show(int $id): JsonResponse
{
$ban = $this->queryService->find($id);
if (! $ban) {
return $this->error('IP record not found.', Response::HTTP_NOT_FOUND);
}
$this->authorize('view', $ban);
return $this->success([
'ban' => new IpBanResource($ban),
]);
}
public function ban(IpBanRequest $request): JsonResponse
{
$this->authorize('create', IpAttempt::class);
$validated = $request->validated();
$id = $validated['id'] ?? null;
$ip = $validated['ip'] ?? null;
$hours = (int) ($validated['hours'] ?? 24);
try {
$ban = $this->commandService->banNow($id ? (int) $id : null, $ip ? (string) $ip : null, $hours);
} catch (\Throwable $e) {
Log::error('IP ban failed: '.$e->getMessage());
return $this->error('Unable to ban IP.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
if (! $ban) {
return $this->error('IP record not found.', Response::HTTP_NOT_FOUND);
}
return $this->success([
'ban' => new IpBanResource($ban),
], 'IP banned.');
}
public function unban(IpUnbanRequest $request): JsonResponse
{
$this->authorize('update', IpAttempt::class);
$validated = $request->validated();
$all = filter_var($validated['all'] ?? false, FILTER_VALIDATE_BOOLEAN);
try {
if ($all) {
$count = $this->commandService->unbanAll();
return $this->success([
'count' => $count,
], $count.' IP(s) unbanned.');
}
$ban = $this->commandService->unbanOne(
isset($validated['id']) ? (int) $validated['id'] : null,
$validated['ip'] ?? null
);
} catch (\Throwable $e) {
Log::error('IP unban failed: '.$e->getMessage());
return $this->error('Unable to unban IP.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
if (! $ban) {
return $this->error('IP record not found.', Response::HTTP_NOT_FOUND);
}
return $this->success([
'ban' => new IpBanResource($ban),
], 'IP unbanned.');
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Api\Auth;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Resources\Auth\RegisterResource;
use App\Services\Auth\ApiRegistrationService;
use App\Services\Auth\RegistrationCaptchaService;
use App\Services\Auth\RegistrationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class RegisterController extends BaseApiController
{
public function __construct(
private RegistrationService $service,
private RegistrationCaptchaService $captchaService
) {
parent::__construct();
}
public function captcha(): JsonResponse
{
$captcha = $this->captchaService->generate();
return response()->json([
'ok' => true,
'captcha' => $captcha,
]);
}
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') {
return response()->json([
'ok' => true,
'debug' => [
'content_type' => $request->header('Content-Type'),
'content_length' => strlen((string) $request->getContent()),
'request_all' => $request->all(),
'json_all' => $request->json()->all(),
'payload_data' => $this->payloadData(),
],
]);
}
$validator = Validator::make($request->all(), RegisterRequest::ruleset());
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$result = $this->service->register($validator->validated());
if (empty($result['ok'])) {
$code = $result['code'] ?? 'registration_failed';
$status = $code === 'pending_activation' || $code === 'email_exists' ? 409 : 422;
return response()->json([
'ok' => false,
'message' => $result['message'] ?? 'Registration failed.',
'code' => $code,
], $status);
}
return response()->json([
'ok' => true,
'registration' => new RegisterResource($result),
], 201);
}
}
@@ -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,
]);
}
}
@@ -3,13 +3,12 @@
namespace App\Http\Controllers\Api\Badges; namespace App\Http\Controllers\Api\Badges;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Badges\BadgePrintStatusRequest;
use App\Http\Requests\Badges\GenerateBadgePdfRequest;
use App\Http\Requests\Badges\LogBadgePrintRequest;
use App\Services\Badges\BadgeFormDataService; use App\Services\Badges\BadgeFormDataService;
use App\Services\Badges\BadgePdfService; use App\Services\Badges\BadgePdfService;
use App\Services\Badges\BadgePrintLogService; use App\Services\Badges\BadgePrintLogService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Throwable; use Throwable;
class BadgeController extends Controller class BadgeController extends Controller
@@ -18,27 +17,60 @@ 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)
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
} }
public function generatePdf(GenerateBadgePdfRequest $request) $validator = Validator::make($request->all(), [
{ 'user_ids' => ['nullable', 'array'],
'user_ids.*' => ['integer', 'exists:users,id'],
'student_ids' => ['nullable', 'array'],
'student_ids.*' => ['integer', 'exists:students,id'],
'school_year' => ['nullable', 'string', 'max:50'],
'roles' => ['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()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$data = $validator->validated();
return $this->badgePdfService->generate( return $this->badgePdfService->generate(
userIds: $request->input('user_ids', []), studentIds: $data['student_ids'] ?? [],
schoolYear: $request->input('school_year'), userIds: $data['user_ids'] ?? [],
rolesMap: $request->input('roles', []), schoolYear: $data['school_year'] ?? null,
classesMap: $request->input('classes', []), rolesMap: $data['roles'] ?? [],
actorId: optional($request->user())->id classesMap: $data['classes'] ?? [],
actorId: $guard
); );
} }
public function printStatus(BadgePrintStatusRequest $request): JsonResponse public function printStatus(Request $request): JsonResponse
{ {
try { try {
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'data' => $this->badgePrintLogService->getStatus( 'data' => $this->badgePrintLogService->getStatus(
$request->normalizedUserIds(), $this->parseCombinedBadgeIds($request),
$request->input('school_year') $request->input('school_year')
), ),
]); ]);
@@ -50,15 +82,51 @@ class BadgeController extends Controller
} }
} }
public function logPrint(LogBadgePrintRequest $request): JsonResponse public function logPrint(Request $request): JsonResponse
{ {
try { try {
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$validator = Validator::make($request->all(), [
'user_ids' => ['nullable', 'array'],
'user_ids.*' => ['integer', 'exists:users,id'],
'student_ids' => ['nullable', 'array'],
'student_ids.*' => ['integer', 'exists:students,id'],
'school_year' => ['nullable', 'string', 'max:50'],
'roles' => ['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()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$data = $validator->validated();
$inserted = $this->badgePrintLogService->log( $inserted = $this->badgePrintLogService->log(
userIds: $request->input('user_ids', []), userIds: array_values(array_unique(array_merge(
actorId: optional($request->user())->id, $data['user_ids'] ?? [],
schoolYear: $request->input('school_year'), $data['student_ids'] ?? []
rolesMap: $request->input('roles', []), ))),
classesMap: $request->input('classes', []) actorId: $guard,
schoolYear: $data['school_year'] ?? null,
rolesMap: $data['roles'] ?? [],
classesMap: $data['classes'] ?? []
); );
return response()->json([ return response()->json([
@@ -73,15 +141,79 @@ class BadgeController extends Controller
} }
} }
public function formData(BadgePrintStatusRequest $request): JsonResponse public function formData(Request $request): JsonResponse
{ {
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'data' => $this->badgeFormDataService->build( 'data' => $this->badgeFormDataService->build(
schoolYear: $request->input('school_year'), schoolYear: $request->input('school_year'),
selectedUserIds: $request->normalizedUserIds(), selectedUserIds: $this->parseStaffUserIds($request),
selectedStudentIds: $this->parseStudentIds($request),
activeRole: $request->input('active_role') activeRole: $request->input('active_role')
), ),
]); ]);
} }
/**
* Staff user IDs for the badge picker (`users.id`).
*
* @return int[]
*/
private function parseStaffUserIds(Request $request): array
{
$ids = $request->input('user_ids', $request->query('user_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));
}
/**
* 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 -275
View File
@@ -2,279 +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) (session()->get('user_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;
}
$roles = (array) (session()->get('roles') ?? []);
if (empty($roles)) {
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()
);
}
}
@@ -0,0 +1,140 @@
<?php
namespace App\Http\Controllers\Api\ClassPreparation;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\ClassPreparation\ClassPreparationAdjustmentRequest;
use App\Http\Requests\ClassPreparation\ClassPreparationIndexRequest;
use App\Http\Requests\ClassPreparation\ClassPreparationMarkPrintedRequest;
use App\Http\Requests\ClassPreparation\ClassPreparationPrintRequest;
use App\Http\Resources\ClassPreparation\ClassPreparationPrintResource;
use App\Http\Resources\ClassPreparation\ClassPreparationResultResource;
use App\Services\ClassPrep\ClassRosterService;
use App\Services\ClassPrep\StickerCountService;
use App\Services\ClassPreparation\ClassPreparationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class ClassPreparationController extends BaseApiController
{
private ClassPreparationService $service;
private StickerCountService $stickerCounts;
private ClassRosterService $roster;
public function __construct(
ClassPreparationService $service,
StickerCountService $stickerCounts,
ClassRosterService $roster
) {
parent::__construct();
$this->service = $service;
$this->stickerCounts = $stickerCounts;
$this->roster = $roster;
}
public function index(ClassPreparationIndexRequest $request): JsonResponse
{
$schoolYear = (string) ($request->query('school_year') ?? '');
$semester = (string) ($request->query('semester') ?? '');
$payload = $this->service->listPrep(
$schoolYear !== '' ? $schoolYear : null,
$semester !== '' ? $semester : null
);
return $this->success([
'schoolYear' => $payload['schoolYear'],
'semester' => $payload['semester'],
'results' => ClassPreparationResultResource::collection($payload['results']),
'totals' => $payload['totals'],
'shortages' => $payload['shortages'],
]);
}
public function markPrinted(ClassPreparationMarkPrintedRequest $request): JsonResponse
{
$validated = $request->validated();
$schoolYear = (string) ($validated['school_year'] ?? '');
$semester = (string) ($validated['semester'] ?? '');
$ids = $validated['class_section_ids'] ?? [];
$count = $this->service->markPrinted($schoolYear, $semester, $ids);
return $this->success([
'updated' => $count,
], 'Class preparation logs updated.');
}
public function saveAdjustments(ClassPreparationAdjustmentRequest $request): JsonResponse
{
$validated = $request->validated();
$classSectionId = (string) $validated['class_section_id'];
$schoolYear = (string) ($validated['school_year'] ?? '');
try {
$count = $this->service->saveAdjustments($classSectionId, $schoolYear, $validated['adjustments']);
} catch (\Throwable $e) {
Log::error('Class prep adjustment save failed: '.$e->getMessage());
return $this->error('Unable to save adjustments.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success([
'updated' => $count,
], 'Adjustments saved.');
}
public function print(ClassPreparationPrintRequest $request): JsonResponse
{
$validated = $request->validated();
$payload = $this->service->printPrep(
(string) $validated['class_section_id'],
(string) $validated['school_year'],
(string) ($validated['semester'] ?? '')
);
if (empty($payload['ok'])) {
return $this->error('Unable to log class preparation print.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success([
'print' => new ClassPreparationPrintResource($payload),
], '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,
];
}
}
@@ -0,0 +1,137 @@
<?php
namespace App\Http\Controllers\Api\Classes;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Classes\ClassSectionIndexRequest;
use App\Http\Requests\Classes\ClassSectionStoreRequest;
use App\Http\Requests\Classes\ClassSectionUpdateRequest;
use App\Http\Resources\Classes\ClassAttendanceResource;
use App\Http\Resources\Classes\ClassSectionCollection;
use App\Http\Resources\Classes\ClassSectionResource;
use App\Models\ClassSection;
use App\Services\ClassSections\ClassAttendanceService;
use App\Services\ClassSections\ClassSectionCommandService;
use App\Services\ClassSections\ClassSectionQueryService;
use App\Services\ClassSections\ClassSectionSeedService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class ClassController extends BaseApiController
{
public function __construct(
private ClassSectionQueryService $queryService,
private ClassSectionCommandService $commandService,
private ClassAttendanceService $attendanceService,
private ClassSectionSeedService $seedService
) {
parent::__construct();
}
public function index(ClassSectionIndexRequest $request): JsonResponse
{
$this->authorize('viewAny', ClassSection::class);
$filters = $request->validated();
$page = (int) ($filters['page'] ?? 1);
$perPage = (int) ($filters['per_page'] ?? 20);
$sections = $this->queryService->list($filters, $page, $perPage);
$collection = new ClassSectionCollection($sections);
return $this->success([
'sections' => $collection->toArray($request),
'meta' => $collection->with($request)['meta'],
]);
}
public function show(ClassSection $classSection): JsonResponse
{
$this->authorize('view', $classSection);
$section = $this->queryService->find((int) $classSection->id);
if (! $section) {
return $this->error('Class section not found.', Response::HTTP_NOT_FOUND);
}
return $this->success([
'section' => new ClassSectionResource($section),
]);
}
public function store(ClassSectionStoreRequest $request): JsonResponse
{
$this->authorize('create', ClassSection::class);
$section = $this->commandService->create($request->validated());
return $this->success([
'section' => new ClassSectionResource($section),
], 'Class section created.', Response::HTTP_CREATED);
}
public function update(ClassSectionUpdateRequest $request, ClassSection $classSection): JsonResponse
{
$this->authorize('update', $classSection);
$section = $this->commandService->update($classSection, $request->validated());
return $this->success([
'section' => new ClassSectionResource($section),
], 'Class section updated.');
}
public function destroy(ClassSection $classSection): JsonResponse
{
$this->authorize('delete', $classSection);
if (! $this->commandService->delete($classSection)) {
return $this->error('Class section not found.', Response::HTTP_NOT_FOUND);
}
return $this->success(null, 'Class section deleted.');
}
public function attendance(Request $request, int $classSectionId): JsonResponse
{
$teacherId = (int) ($request->user()?->id ?? 0);
if ($teacherId <= 0) {
return $this->error('Missing teacher authentication.', Response::HTTP_UNAUTHORIZED);
}
$payload = $this->attendanceService->getAttendancePayload(
$classSectionId,
$teacherId,
$request->query('school_year'),
$request->query('semester')
);
if (empty($payload['students'])) {
return $this->error('No data found for this class.', Response::HTTP_NOT_FOUND);
}
return $this->success([
'attendance' => new ClassAttendanceResource($payload),
]);
}
public function seedDefaults(): JsonResponse
{
$this->authorize('seedDefaults', ClassSection::class);
try {
$created = $this->seedService->seedDefaults();
} catch (\Throwable $e) {
Log::error('Seeding default classes failed: '.$e->getMessage());
return $this->error('Unable to seed default classes.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success([
'created' => $created,
], 'Default classes seeded.');
}
}
@@ -0,0 +1,199 @@
<?php
namespace App\Http\Controllers\Api\Communication;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Services\Communication\CommunicationFamilyService;
use App\Services\Communication\CommunicationPreviewService;
use App\Services\Communication\CommunicationSendService;
use App\Services\Communication\CommunicationStudentService;
use App\Services\Communication\CommunicationTemplateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CommunicationController extends BaseApiController
{
private CommunicationStudentService $students;
private CommunicationTemplateService $templates;
private CommunicationFamilyService $families;
private CommunicationPreviewService $previewService;
private CommunicationSendService $sendService;
public function __construct(
CommunicationStudentService $students,
CommunicationTemplateService $templates,
CommunicationFamilyService $families,
CommunicationPreviewService $previewService,
CommunicationSendService $sendService
) {
parent::__construct();
$this->students = $students;
$this->templates = $templates;
$this->families = $families;
$this->previewService = $previewService;
$this->sendService = $sendService;
}
public function options(): JsonResponse
{
return response()->json([
'ok' => true,
'students' => $this->students->listStudents(),
'templates' => $this->templates->listActiveTemplates(),
]);
}
public function families(int $studentId): JsonResponse
{
return response()->json([
'ok' => true,
'data' => $this->families->familiesForStudent($studentId),
]);
}
public function guardians(int $familyId): JsonResponse
{
return response()->json([
'ok' => true,
'data' => $this->families->guardiansForFamily($familyId),
]);
}
public function preview(Request $request): JsonResponse
{
$templateKey = (string) $request->input('template_key', '');
$studentId = (int) $request->input('student_id', 0);
$familyId = (int) $request->input('family_id', 0);
$vars = $this->normalizeArray($request->input('vars', []));
if ($templateKey === '' || $studentId <= 0 || $familyId <= 0) {
return response()->json([
'ok' => false,
'message' => 'template_key, student_id, and family_id are required.',
], 422);
}
$teacherName = $this->resolveTeacherName();
$result = $this->previewService->buildPreview($templateKey, $studentId, $familyId, $vars, $teacherName);
if (! $result['ok']) {
return response()->json([
'ok' => false,
'message' => $result['message'] ?? 'Preview failed.',
], $result['status'] ?? 400);
}
return response()->json([
'ok' => true,
'subject' => $result['subject'],
'html' => $result['html'],
]);
}
public function send(Request $request): JsonResponse
{
$studentId = (int) $request->input('student_id', 0);
$familyId = (int) $request->input('family_id', 0);
$templateKey = (string) $request->input('template_key', '');
$subject = (string) $request->input('subject', '');
$body = (string) $request->input('body', '');
$recipients = $this->normalizeArray($request->input('recipients', []));
$cc = $this->normalizeArray($request->input('cc', []));
$bcc = $this->normalizeArray($request->input('bcc', []));
if ($studentId <= 0 || $familyId <= 0 || $templateKey === '' || $subject === '' || $body === '') {
return response()->json([
'ok' => false,
'message' => 'student_id, family_id, template_key, subject, and body are required.',
], 422);
}
if (empty($recipients)) {
return response()->json([
'ok' => false,
'message' => 'At least one recipient is required.',
], 422);
}
$student = $this->students->getStudentBasic($studentId);
if (! $student) {
return response()->json([
'ok' => false,
'message' => 'Student not found.',
], 404);
}
$senderId = $this->authenticatedUserIdOrUnauthorized();
if ($senderId instanceof JsonResponse) {
return $senderId;
}
$result = $this->sendService->send([
'student_id' => $studentId,
'family_id' => $familyId,
'template_key' => $templateKey,
'subject' => $subject,
'body' => $body,
'recipients' => $recipients,
'cc' => $cc,
'bcc' => $bcc,
'student_name' => trim(($student['firstname'] ?? '').' '.($student['lastname'] ?? '')),
'sent_by' => $senderId,
]);
if (! $result['ok']) {
return response()->json([
'ok' => false,
'message' => 'Failed to send email.',
'error' => $result['error'],
], 500);
}
return response()->json([
'ok' => true,
'message' => 'Email sent successfully.',
]);
}
private function normalizeArray($value): array
{
if (is_array($value)) {
return $value;
}
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
if (is_array($decoded)) {
return $decoded;
}
}
return [];
}
private function resolveTeacherName(): string
{
$user = auth()->user();
if ($user) {
$name = trim(($user->firstname ?? '').' '.($user->lastname ?? ''));
return $name !== '' ? $name : '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;
}
}
@@ -0,0 +1,237 @@
<?php
namespace App\Http\Controllers\Api\CompetitionScores;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Models\Competition;
use App\Services\CompetitionScores\CompetitionScoresContextService;
use App\Services\CompetitionScores\CompetitionScoresQueryService;
use App\Services\CompetitionScores\CompetitionScoresRosterService;
use App\Services\CompetitionScores\CompetitionScoresSaveService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CompetitionScoresController extends BaseApiController
{
private CompetitionScoresContextService $contextService;
private CompetitionScoresQueryService $queryService;
private CompetitionScoresRosterService $rosterService;
private CompetitionScoresSaveService $saveService;
public function __construct(
CompetitionScoresContextService $contextService,
CompetitionScoresQueryService $queryService,
CompetitionScoresRosterService $rosterService,
CompetitionScoresSaveService $saveService
) {
parent::__construct();
$this->contextService = $contextService;
$this->queryService = $queryService;
$this->rosterService = $rosterService;
$this->saveService = $saveService;
}
public function index(Request $request): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
[$assignments, $schoolYear, $semester] = $this->contextService->getTeacherContext($guard);
$activeClassId = $this->contextService->resolveActiveClassId(
$assignments,
(int) $request->query('class_section_id', 0)
);
$activeClassName = $this->contextService->getActiveClassName($assignments, $activeClassId);
if (empty($assignments) || $activeClassId <= 0) {
return response()->json([
'ok' => true,
'competitions' => [],
'activeClassId' => $activeClassId,
'activeClassName' => $activeClassName,
'questionCounts' => [],
'scoreCounts' => [],
'studentTotal' => 0,
'sectionMap' => $this->contextService->getClassSectionMap(),
'schoolYear' => $schoolYear,
'semester' => $semester,
'hasClasses' => false,
]);
}
$competitions = $this->queryService->getCompetitionsForClass($activeClassId, $schoolYear, $semester);
$competitionIds = array_values(array_filter(array_map(static function ($row) {
return (int) ($row['id'] ?? 0);
}, $competitions)));
return response()->json([
'ok' => true,
'competitions' => $competitions,
'activeClassId' => $activeClassId,
'activeClassName' => $activeClassName,
'questionCounts' => $this->queryService->getQuestionCounts($competitionIds, $activeClassId),
'scoreCounts' => $this->queryService->getScoreCounts($competitionIds, $activeClassId),
'studentTotal' => $this->rosterService->getClassStudentCount($activeClassId, $schoolYear),
'sectionMap' => $this->contextService->getClassSectionMap(),
'schoolYear' => $schoolYear,
'semester' => $semester,
'hasClasses' => true,
]);
}
public function edit(Request $request, int $id): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
[$assignments, $schoolYear, $semester] = $this->contextService->getTeacherContext($guard);
$allowedClassIds = $this->contextService->getAllowedClassIds($assignments);
if (empty($allowedClassIds)) {
return response()->json([
'ok' => false,
'message' => 'No class assignments found for your account.',
], 403);
}
$competition = Competition::query()->find($id);
if (! $competition) {
return response()->json([
'ok' => false,
'message' => 'Competition not found.',
], 404);
}
$isLocked = (bool) $competition->is_locked;
$lockedClassId = (int) ($competition->class_section_id ?? 0);
$requestedClassId = (int) $request->query('class_section_id', 0);
$activeClassId = $this->contextService->resolveActiveClassId($assignments, $requestedClassId);
$classSectionId = $lockedClassId > 0 ? $lockedClassId : $activeClassId;
if ($classSectionId <= 0) {
return response()->json([
'ok' => false,
'message' => 'Select a class section before entering scores.',
], 422);
}
if (! in_array($classSectionId, $allowedClassIds, true)) {
return response()->json([
'ok' => false,
'message' => 'You are not assigned to that class.',
], 403);
}
$competitionArray = $competition->toArray();
$students = $this->rosterService->getStudentsForCompetition($competitionArray, $classSectionId);
$scoreMap = $this->saveService->getScoreMap($id, $classSectionId);
$questionCount = $this->queryService->getQuestionCountForCompetition($id, $classSectionId);
$classStudentCount = $this->rosterService->getClassStudentCount(
$classSectionId,
$competitionArray['school_year'] ?? $schoolYear
);
return response()->json([
'ok' => true,
'competition' => $competitionArray,
'students' => $students,
'scoreMap' => $scoreMap,
'classSectionId' => $classSectionId,
'classSectionName' => $this->contextService->getActiveClassName($assignments, $classSectionId),
'classStudentCount' => $classStudentCount,
'questionCount' => $questionCount,
'classSelectionLocked' => $lockedClassId > 0,
'isLocked' => $isLocked,
'schoolYear' => $schoolYear,
'semester' => $semester,
]);
}
public function save(Request $request, int $id): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
[$assignments] = $this->contextService->getTeacherContext($guard);
$allowedClassIds = $this->contextService->getAllowedClassIds($assignments);
if (empty($allowedClassIds)) {
return response()->json([
'ok' => false,
'message' => 'No class assignments found for your account.',
], 403);
}
$competition = Competition::query()->find($id);
if (! $competition) {
return response()->json([
'ok' => false,
'message' => 'Competition not found.',
], 404);
}
if ((bool) $competition->is_locked) {
return response()->json([
'ok' => false,
'message' => 'Competition is locked. You cannot update scores.',
], 423);
}
$lockedClassId = (int) ($competition->class_section_id ?? 0);
$classSectionId = $lockedClassId > 0 ? $lockedClassId : (int) $request->input('class_section_id', 0);
if ($classSectionId <= 0) {
return response()->json([
'ok' => false,
'message' => 'Select a class section before saving scores.',
], 422);
}
if (! in_array($classSectionId, $allowedClassIds, true)) {
return response()->json([
'ok' => false,
'message' => 'You are not assigned to that class.',
], 403);
}
$scores = $request->input('scores', []);
$scores = is_array($scores) ? $scores : [];
[$cleanScores, $invalidScores] = $this->saveService->filterScores($scores);
if (! empty($invalidScores)) {
return response()->json([
'ok' => false,
'message' => 'Scores must be whole numbers (no decimals).',
'invalidScores' => $invalidScores,
], 422);
}
$this->saveService->saveScores($id, $classSectionId, $cleanScores);
return response()->json([
'ok' => true,
'message' => 'Scores saved.',
'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,
];
}
}
@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers\Api\Discounts;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Discounts\ApplyDiscountVoucherRequest;
use App\Http\Requests\Discounts\StoreDiscountVoucherRequest;
use App\Http\Requests\Discounts\UpdateDiscountVoucherRequest;
use App\Http\Resources\Discounts\DiscountParentResource;
use App\Http\Resources\Discounts\DiscountVoucherResource;
use App\Services\Discounts\DiscountApplyService;
use App\Services\Discounts\DiscountContextService;
use App\Services\Discounts\DiscountParentService;
use App\Services\Discounts\DiscountVoucherService;
use Illuminate\Http\JsonResponse;
class DiscountController extends BaseApiController
{
private DiscountContextService $context;
private DiscountVoucherService $voucherService;
private DiscountParentService $parentService;
private DiscountApplyService $applyService;
public function __construct(
DiscountContextService $context,
DiscountVoucherService $voucherService,
DiscountParentService $parentService,
DiscountApplyService $applyService
) {
parent::__construct();
$this->context = $context;
$this->voucherService = $voucherService;
$this->parentService = $parentService;
$this->applyService = $applyService;
}
public function options(): JsonResponse
{
$schoolYear = $this->context->getSchoolYear();
$vouchers = $this->voucherService->listActive();
$parents = $this->parentService->listParentsWithDiscounts($schoolYear);
return response()->json([
'ok' => true,
'vouchers' => DiscountVoucherResource::collection($vouchers),
'parents' => DiscountParentResource::collection($parents),
'schoolYear' => $schoolYear,
]);
}
public function apply(ApplyDiscountVoucherRequest $request): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated();
$result = $this->applyService->applyVoucher(
(int) $payload['voucher_id'],
$payload['parent_ids'],
(bool) ($payload['allow_additional'] ?? false),
$guard
);
return response()->json($result, $result['ok'] ? 200 : 422);
}
public function listVouchers(): JsonResponse
{
$vouchers = $this->voucherService->listAll();
return response()->json([
'ok' => true,
'vouchers' => DiscountVoucherResource::collection($vouchers),
]);
}
public function storeVoucher(StoreDiscountVoucherRequest $request): JsonResponse
{
$voucher = $this->voucherService->create($request->validated());
return response()->json([
'ok' => true,
'voucher' => new DiscountVoucherResource($voucher),
], 201);
}
public function updateVoucher(UpdateDiscountVoucherRequest $request, int $id): JsonResponse
{
$voucher = $this->voucherService->update($id, $request->validated());
return response()->json([
'ok' => true,
'voucher' => new DiscountVoucherResource($voucher),
]);
}
public function showVoucher(int $id): JsonResponse
{
$voucher = $this->voucherService->find($id);
return response()->json([
'ok' => true,
'voucher' => new DiscountVoucherResource($voucher),
]);
}
public function deleteVoucher(int $id): JsonResponse
{
$this->voucherService->delete($id);
return response()->json([
'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',
]);
}
}
@@ -0,0 +1,189 @@
<?php
namespace App\Http\Controllers\Api\Email;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Services\BroadcastEmail\BroadcastEmailComposerService;
use App\Services\BroadcastEmail\BroadcastEmailDispatchService;
use App\Services\BroadcastEmail\BroadcastEmailImageService;
use App\Services\BroadcastEmail\BroadcastEmailRecipientService;
use App\Services\BroadcastEmail\BroadcastEmailSenderOptionsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BroadcastEmailController extends BaseApiController
{
private BroadcastEmailSenderOptionsService $senderOptions;
private BroadcastEmailRecipientService $recipients;
private BroadcastEmailComposerService $composer;
private BroadcastEmailDispatchService $dispatch;
private BroadcastEmailImageService $imageService;
public function __construct(
BroadcastEmailSenderOptionsService $senderOptions,
BroadcastEmailRecipientService $recipients,
BroadcastEmailComposerService $composer,
BroadcastEmailDispatchService $dispatch,
BroadcastEmailImageService $imageService
) {
parent::__construct();
$this->senderOptions = $senderOptions;
$this->recipients = $recipients;
$this->composer = $composer;
$this->dispatch = $dispatch;
$this->imageService = $imageService;
}
public function options(): JsonResponse
{
return response()->json([
'ok' => true,
'parents' => $this->recipients->parentsWithEmails(),
'fromOptions' => $this->senderOptions->listOptions(),
]);
}
public function send(Request $request): JsonResponse
{
$mode = (string) ($request->input('mode') ?? 'standard');
$subject = trim((string) ($request->input('subject') ?? ''));
$fromKey = trim((string) ($request->input('from_key') ?? 'general'));
$body = (string) ($request->input('body_html') ?? '');
$wrapLayout = (bool) $request->boolean('wrap_layout');
$preheader = (string) ($request->input('preheader') ?? '');
$ctaText = (string) ($request->input('cta_text') ?? '');
$ctaUrl = (string) ($request->input('cta_url') ?? '');
$testEmail = trim((string) ($request->input('test_email') ?? ''));
$isTestOnly = (bool) $request->boolean('send_test_only');
$body = $this->composer->sanitizeHtml($body);
if ($subject === '' || $body === '') {
return response()->json([
'ok' => false,
'message' => 'Subject and Body are required.',
], 422);
}
$personalize = $mode === 'personalized';
if ($isTestOnly) {
if ($testEmail === '') {
return response()->json([
'ok' => false,
'message' => 'Provide a test email address.',
], 422);
}
$result = $this->dispatch->sendTest([
'wrap_layout' => $wrapLayout,
'subject' => $subject,
'body_html' => $body,
'recipient_name' => 'Parent',
'preheader' => $preheader,
'cta_text' => $ctaText,
'cta_url' => $ctaUrl,
'personalize' => $personalize,
'test_email' => $testEmail,
'from_key' => $fromKey,
]);
return response()->json($result, $result['ok'] ? 200 : 500);
}
$rawIds = $request->input('parent_ids', []);
$ids = $this->normalizeIds($rawIds);
if (empty($ids)) {
return response()->json([
'ok' => false,
'message' => 'Please select at least one parent.',
], 422);
}
$recipients = $this->recipients->recipientsByIds($ids);
if (empty($recipients)) {
return response()->json([
'ok' => false,
'message' => 'No valid parent emails found.',
], 422);
}
$stats = $this->dispatch->sendBroadcast([
'wrap_layout' => $wrapLayout,
'subject' => $subject,
'body_html' => $body,
'preheader' => $preheader,
'cta_text' => $ctaText,
'cta_url' => $ctaUrl,
'personalize' => $personalize,
'from_key' => $fromKey,
'mode' => $mode,
], $recipients);
$message = sprintf(
'Broadcast finished. Mode: %s. Sent: %d/%d. Failures: %d.',
$stats['mode'],
$stats['sent'],
$stats['attempted'],
$stats['failed']
);
return response()->json([
'ok' => $stats['failed'] === 0,
'stats' => $stats,
'message' => $message,
]);
}
public function uploadImage(Request $request): JsonResponse
{
$file = $request->file('image');
if (! $file || ! $file->isValid()) {
return response()->json([
'success' => false,
'error' => 'No image uploaded',
], 400);
}
$result = $this->imageService->store($file);
if (! $result['ok']) {
return response()->json([
'success' => false,
'error' => $result['error'] ?? 'Upload failed',
], $result['status'] ?? 400);
}
$newHash = function_exists('csrf_hash') ? csrf_hash() : csrf_token();
return response()->json([
'success' => true,
'url' => $result['url'],
'csrf_hash' => $newHash,
])->header('X-CSRF-HASH', (string) $newHash);
}
private function normalizeIds($rawIds): array
{
$ids = [];
$raw = is_array($rawIds) ? $rawIds : [$rawIds];
foreach ($raw as $value) {
if (is_string($value) && strpos($value, ',') !== false) {
foreach (explode(',', $value) as $piece) {
$ids[] = (int) $piece;
}
continue;
}
$ids[] = (int) $value;
}
return array_values(array_unique(array_filter($ids)));
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Api\Email;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Email\EmailSendRequest;
use App\Http\Resources\Email\EmailSenderResource;
use App\Services\Email\EmailAttachmentService;
use App\Services\Email\EmailDispatchService;
use App\Services\Email\EmailSenderOptionsService;
use Illuminate\Http\JsonResponse;
class EmailController extends BaseApiController
{
public function __construct(
private EmailDispatchService $dispatch,
private EmailSenderOptionsService $senderOptions,
private EmailAttachmentService $attachments
) {
parent::__construct();
}
public function senders(): JsonResponse
{
$senders = $this->senderOptions->listSenders();
return response()->json([
'ok' => true,
'senders' => EmailSenderResource::collection($senders),
]);
}
public function send(EmailSendRequest $request): JsonResponse
{
$payload = $request->validated();
$attachments = $this->attachments->normalize($request->file('attachments', []));
$ok = $this->dispatch->send(
$payload['recipient'],
$payload['subject'],
$payload['html_message'],
$payload['profile'] ?? null,
$payload['reply_to_email'] ?? null,
$payload['reply_to_name'] ?? null,
$attachments
);
if (! $ok) {
return response()->json([
'ok' => false,
'message' => 'Email failed to send.',
], 500);
}
return response()->json([
'ok' => true,
'message' => 'Email sent successfully.',
]);
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Api\Email;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Email\EmailExtractorCompareRequest;
use App\Http\Resources\Email\EmailExtractorCompareResource;
use App\Http\Resources\Email\EmailExtractorEmailsResource;
use App\Services\Email\EmailExtractorService;
use Illuminate\Http\JsonResponse;
class EmailExtractorController extends BaseApiController
{
public function __construct(private EmailExtractorService $service)
{
parent::__construct();
}
public function emails(): JsonResponse
{
$emails = $this->service->listEmails();
return response()->json([
'ok' => true,
'users' => $emails['users'] ?? [],
'parents' => $emails['parents'] ?? [],
'emails' => new EmailExtractorEmailsResource($emails),
]);
}
public function compare(EmailExtractorCompareRequest $request): JsonResponse
{
$csvEmails = [];
$file = $request->file('file');
if ($file && $file->isValid()) {
$contents = file_get_contents($file->getRealPath() ?: $file->getPathname());
$csvEmails = $this->service->extractEmailsFromText($contents);
}
if (empty($csvEmails)) {
$csvEmails = $request->input('csv_emails', []);
}
if (empty($csvEmails)) {
return response()->json([
'ok' => false,
'message' => 'Provide a CSV file or csv_emails array.',
], 422);
}
$result = $this->service->compare($csvEmails);
return response()->json([
'ok' => true,
'comparison' => new EmailExtractorCompareResource($result),
]);
}
}
@@ -0,0 +1,125 @@
<?php
namespace App\Http\Controllers\Api\Exams;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Exams\ExamDraftAdminLegacyRequest;
use App\Http\Requests\Exams\ExamDraftAdminReviewRequest;
use App\Http\Requests\Exams\ExamDraftTeacherStoreRequest;
use App\Http\Resources\Exams\ExamDraftResource;
use App\Services\Exams\ExamDraftAdminService;
use App\Services\Exams\ExamDraftTeacherService;
use Illuminate\Http\JsonResponse;
class ExamDraftController extends BaseApiController
{
public function __construct(
private ExamDraftTeacherService $teacherService,
private ExamDraftAdminService $adminService
) {
parent::__construct();
}
public function teacherIndex(): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$data = $this->teacherService->listForTeacher($guard);
return response()->json([
'ok' => true,
'assignments' => $data['assignments'],
'drafts' => ExamDraftResource::collection($data['drafts']),
'legacy_exams' => ExamDraftResource::collection($data['legacyExams']),
'exam_types' => $data['examTypes'],
'status_options' => $data['statusOptions'],
'school_year' => $data['schoolYear'],
'semester' => $data['semester'],
]);
}
public function teacherStore(ExamDraftTeacherStoreRequest $request): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$result = $this->teacherService->store($guard, $request->validated(), $request->file('draft_file'));
if (! $result['ok']) {
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422);
}
return response()->json([
'ok' => true,
'draft' => new ExamDraftResource($result['draft']),
], 201);
}
public function adminIndex(): JsonResponse
{
$data = $this->adminService->list();
return response()->json([
'ok' => true,
'drafts' => ExamDraftResource::collection($data['drafts']),
'class_sections' => $data['classSections'],
'legacy_by_class' => $data['legacyByClass'],
'exam_types' => $data['examTypes'],
'status_options' => $data['statusOptions'],
'school_year' => $data['schoolYear'],
'semester' => $data['semester'],
'allowed_extensions' => $data['allowedExtensions'],
'max_upload_bytes' => $data['maxUploadBytes'],
]);
}
public function adminUploadLegacy(ExamDraftAdminLegacyRequest $request): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$result = $this->adminService->uploadLegacy($guard, $request->validated(), $request->file('old_exam_file'));
if (! $result['ok']) {
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422);
}
return response()->json([
'ok' => true,
'draft' => new ExamDraftResource($result['draft']),
], 201);
}
public function adminReview(ExamDraftAdminReviewRequest $request): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$result = $this->adminService->review($guard, $request->validated(), $request->file('final_file'));
if (! $result['ok']) {
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422);
}
return response()->json([
'ok' => true,
'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;
}
}
@@ -0,0 +1,203 @@
<?php
namespace App\Http\Controllers\Api\Expenses;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Expenses\StoreExpenseRequest;
use App\Http\Requests\Expenses\UpdateExpenseRequest;
use App\Http\Requests\Expenses\UpdateExpenseStatusRequest;
use App\Http\Resources\Expenses\ExpenseResource;
use App\Http\Resources\Expenses\ExpenseStaffResource;
use App\Models\Expense;
use App\Services\Expenses\ExpenseContextService;
use App\Services\Expenses\ExpenseQueryService;
use App\Services\Expenses\ExpenseReceiptService;
use App\Services\Expenses\ExpenseRetailorService;
use App\Services\Expenses\ExpenseStaffService;
use App\Services\Expenses\ExpenseStatusService;
use App\Services\Expenses\ExpenseWriteService;
use Illuminate\Http\JsonResponse;
class ExpenseController extends BaseApiController
{
public function __construct(
private ExpenseContextService $context,
private ExpenseRetailorService $retailors,
private ExpenseStaffService $staff,
private ExpenseQueryService $queryService,
private ExpenseReceiptService $receiptService,
private ExpenseWriteService $writeService,
private ExpenseStatusService $statusService
) {
parent::__construct();
}
public function options(): JsonResponse
{
$schoolYear = $this->context->getSchoolYear();
$semester = $this->context->getSemester();
return response()->json([
'ok' => true,
'schoolYear' => $schoolYear,
'semester' => $semester,
'retailors' => $this->retailors->listRetailors(),
'staff' => ExpenseStaffResource::collection($this->staff->listStaffUsers()),
]);
}
public function index(): JsonResponse
{
$rows = $this->queryService->listAll();
$rows = array_map(function ($row) {
$row['receipt_url'] = $this->receiptService->receiptUrl($row['receipt_path'] ?? null);
return $row;
}, $rows);
return response()->json([
'ok' => true,
'expenses' => ExpenseResource::collection($rows),
]);
}
public function show(int $id): JsonResponse
{
$row = $this->queryService->findById($id);
if (! $row) {
return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404);
}
$row['receipt_url'] = $this->receiptService->receiptUrl($row['receipt_path'] ?? null);
return response()->json([
'ok' => true,
'expense' => new ExpenseResource($row),
]);
}
public function store(StoreExpenseRequest $request): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated();
$receiptName = null;
if ($request->hasFile('receipt')) {
$receiptName = $this->receiptService->storeReceipt($request->file('receipt'));
}
$isDonation = $payload['category'] === 'Donation';
$expense = $this->writeService->store([
'category' => $payload['category'],
'amount' => $payload['amount'],
'receipt_path' => $receiptName,
'description' => $payload['description'] ?? null,
'retailor' => $payload['retailor'] ?? null,
'date_of_purchase' => $payload['date_of_purchase'],
'purchased_by' => (int) $payload['purchased_by'],
'added_by' => $guard,
'status' => $isDonation ? 'approved' : 'pending',
'status_reason' => $isDonation ? 'Marked as Donation (non-reimbursable).' : null,
'approved_by' => $isDonation ? $guard : null,
'school_year' => $payload['school_year'] ?? null,
'semester' => $payload['semester'] ?? null,
]);
return response()->json([
'ok' => true,
'expense' => new ExpenseResource($expense->toArray()),
], 201);
}
public function update(UpdateExpenseRequest $request, int $id): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$expense = Expense::query()->find($id);
if (! $expense) {
return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404);
}
$payload = $request->validated();
$receiptName = $expense->receipt_path;
if ($request->hasFile('receipt')) {
$receiptName = $this->receiptService->storeReceipt($request->file('receipt'));
}
if (! empty($payload['remove_receipt'])) {
$receiptName = null;
}
$category = $payload['category'];
$isDonation = $category === 'Donation';
$updateData = [
'category' => $category,
'amount' => $payload['amount'],
'description' => $payload['description'] ?? null,
'retailor' => $payload['retailor'] ?? null,
'date_of_purchase' => $payload['date_of_purchase'],
'purchased_by' => (int) $payload['purchased_by'],
'receipt_path' => $receiptName,
'updated_by' => $guard,
];
if ($isDonation) {
$updateData['status'] = 'approved';
$updateData['status_reason'] = 'Marked as Donation (non-reimbursable).';
$updateData['approved_by'] = $guard ?: null;
$updateData['reimbursement_id'] = null;
} elseif (($expense->category ?? '') === 'Donation') {
$updateData['status_reason'] = null;
$updateData['approved_by'] = $expense->approved_by;
$updateData['status'] = $expense->status ?? 'pending';
}
$updated = $this->writeService->update($expense, $updateData);
return response()->json([
'ok' => true,
'expense' => new ExpenseResource($updated->toArray()),
]);
}
public function updateStatus(UpdateExpenseStatusRequest $request, int $id): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$expense = Expense::query()->find($id);
if (! $expense) {
return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404);
}
$payload = $request->validated();
$updated = $this->statusService->updateStatus($expense, $payload['status'], $payload['reason'] ?? '', $guard);
return response()->json([
'ok' => true,
'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;
}
}
@@ -0,0 +1,163 @@
<?php
namespace App\Http\Controllers\Api\ExtraCharges;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\ExtraCharges\StoreExtraChargeRequest;
use App\Http\Requests\ExtraCharges\UpdateExtraChargeRequest;
use App\Http\Resources\ExtraCharges\ExtraChargeInvoiceOptionResource;
use App\Http\Resources\ExtraCharges\ExtraChargeParentOptionResource;
use App\Http\Resources\ExtraCharges\ExtraChargeResource;
use App\Models\AdditionalCharge;
use App\Services\ExtraCharges\ExtraChargesChargeService;
use App\Services\ExtraCharges\ExtraChargesContextService;
use App\Services\ExtraCharges\ExtraChargesInvoiceService;
use App\Services\ExtraCharges\ExtraChargesListService;
use App\Services\ExtraCharges\ExtraChargesMetaService;
use App\Services\ExtraCharges\ExtraChargesParentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ExtraChargesController extends BaseApiController
{
public function __construct(
private ExtraChargesContextService $context,
private ExtraChargesMetaService $meta,
private ExtraChargesParentService $parents,
private ExtraChargesInvoiceService $invoices,
private ExtraChargesListService $listService,
private ExtraChargesChargeService $chargeService
) {
parent::__construct();
}
public function list(Request $request): JsonResponse
{
$year = (string) ($request->query('school_year') ?? $this->context->getSchoolYear());
$sem = (string) ($request->query('semester') ?? $this->context->getSemester());
$status = $request->query('status') ?: null;
$q = trim((string) ($request->query('q') ?? '')) ?: null;
$per = (int) ($request->query('per_page') ?? 50);
[$rows, $meta] = $this->listService->listForTerm($year, $sem, $status, $q, $per);
return response()->json([
'ok' => true,
'school_year' => $year,
'semester' => $sem,
'rows' => ExtraChargeResource::collection($rows),
'pager' => $meta,
]);
}
public function options(Request $request): JsonResponse
{
$q = trim((string) ($request->query('q') ?? ''));
$parentOptions = $this->parents->searchParents($q);
return response()->json([
'ok' => true,
'schoolYear' => $this->context->getSchoolYear(),
'semester' => $this->context->getSemester(),
'schoolYears' => $this->meta->getSchoolYears($this->context->getSchoolYear()),
'parents' => ExtraChargeParentOptionResource::collection($parentOptions),
]);
}
public function parentOptions(Request $request): JsonResponse
{
$q = trim((string) ($request->query('q') ?? ''));
$parentOptions = $this->parents->searchParents($q);
return response()->json([
'results' => ExtraChargeParentOptionResource::collection($parentOptions),
]);
}
public function invoicesForParent(Request $request): JsonResponse
{
$parentId = (int) ($request->query('parent_id') ?? 0);
$schoolYear = (string) ($request->query('school_year') ?? $this->context->getSchoolYear());
$rows = $this->invoices->listInvoicesForParent($parentId, $schoolYear);
return response()->json([
'results' => ExtraChargeInvoiceOptionResource::collection($rows),
]);
}
public function store(StoreExtraChargeRequest $request): JsonResponse
{
$userId = $this->authenticatedUserIdOrUnauthorized();
if ($userId instanceof JsonResponse) {
return $userId;
}
$payload = $request->validated();
$payload['created_by'] = $userId;
$result = $this->chargeService->createCharge($payload);
return response()->json($result, $result['ok'] ? 201 : 422);
}
public function update(UpdateExtraChargeRequest $request, int $id): JsonResponse
{
$charge = AdditionalCharge::query()->find($id);
if (! $charge) {
return response()->json(['ok' => false, 'error' => 'Charge not found'], 404);
}
$userId = $this->authenticatedUserIdOrUnauthorized();
if ($userId instanceof JsonResponse) {
return $userId;
}
$payload = $request->validated();
$payload['updated_by'] = $userId;
$ok = $this->chargeService->updateCharge($charge, $payload);
return response()->json([
'ok' => $ok,
'id' => $id,
], $ok ? 200 : 422);
}
public function void(int $id): JsonResponse
{
$charge = AdditionalCharge::query()->find($id);
if (! $charge) {
return response()->json(['ok' => false, 'error' => 'Charge not found'], 404);
}
$ok = $this->chargeService->voidCharge($charge);
return response()->json([
'ok' => $ok,
], $ok ? 200 : 422);
}
public function reverse(int $id): JsonResponse
{
$charge = AdditionalCharge::query()->find($id);
if (! $charge) {
return response()->json(['ok' => false, 'error' => 'Charge not found'], 404);
}
$ok = $this->chargeService->reverseCharge($charge);
return response()->json([
'ok' => $ok,
], $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;
}
}
@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Api\Family;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Families\FamilyAdminCardRequest;
use App\Http\Requests\Families\FamilyAdminIndexRequest;
use App\Http\Requests\Families\FamilyAdminSearchRequest;
use App\Http\Requests\Families\FamilyComposeEmailRequest;
use App\Http\Resources\Families\FamilyResource;
use App\Http\Resources\Families\FamilySearchItemResource;
use App\Models\Configuration;
use App\Services\Families\FamilyNotificationService;
use App\Services\Families\FamilyQueryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class FamilyAdminController extends BaseApiController
{
public function __construct(
private FamilyQueryService $queryService,
private FamilyNotificationService $notificationService
) {
parent::__construct();
}
public function index(FamilyAdminIndexRequest $request): JsonResponse
{
$payload = $request->validated();
$schoolYear = trim((string) (Configuration::getConfig('school_year') ?? ''));
$data = $this->queryService->adminIndexData(
$payload['student_id'] ?? null,
$payload['guardian_id'] ?? null,
$schoolYear
);
return $this->success([
'student' => $data['student'],
'families' => FamilyResource::collection($data['families']),
'guardians' => $data['guardians'],
'students' => $data['students'],
'searchStudents' => $data['searchStudents'],
'searchGuardians' => $data['searchGuardians'],
'resolved_student_id' => $data['resolved_student_id'],
]);
}
public function search(FamilyAdminSearchRequest $request): JsonResponse
{
$payload = $request->validated();
$items = $this->queryService->searchSuggestions(
(string) $payload['q'],
(int) ($payload['limit'] ?? 8)
);
return $this->success([
'items' => FamilySearchItemResource::collection($items),
]);
}
public function card(FamilyAdminCardRequest $request): JsonResponse
{
$payload = $request->validated();
$schoolYear = trim((string) (Configuration::getConfig('school_year') ?? ''));
$family = $this->queryService->familyCard(
$payload['student_id'] ?? null,
$payload['guardian_id'] ?? null,
$payload['family_id'] ?? null,
$schoolYear
);
if (! $family) {
return $this->error('Family not found.', Response::HTTP_NOT_FOUND);
}
return $this->success(new FamilyResource($family));
}
public function composeEmail(FamilyComposeEmailRequest $request): JsonResponse
{
$payload = $request->validated();
$ok = $this->notificationService->sendComposeEmail(
(string) $payload['recipient'],
(string) $payload['subject'],
(string) $payload['html_message'],
$payload['profile'] ?? null,
$payload['reply_to_email'] ?? null,
$payload['reply_to_name'] ?? null
);
if (! $ok) {
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->success(['recipient' => $payload['recipient']], 'Email sent.');
}
}
@@ -0,0 +1,172 @@
<?php
namespace App\Http\Controllers\Api\Family;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Families\FamiliesByStudentRequest;
use App\Http\Requests\Families\FamilyAttachSecondByEmailRequest;
use App\Http\Requests\Families\FamilyAttachSecondByUserRequest;
use App\Http\Requests\Families\FamilyBootstrapRequest;
use App\Http\Requests\Families\FamilyImportLegacyRequest;
use App\Http\Requests\Families\FamilySetGuardianFlagsRequest;
use App\Http\Requests\Families\FamilySetPrimaryHomeRequest;
use App\Http\Requests\Families\FamilyUnlinkGuardianRequest;
use App\Http\Requests\Families\FamilyUnlinkStudentRequest;
use App\Http\Requests\Families\GuardiansByFamilyRequest;
use App\Http\Resources\Families\FamilyGuardianResource;
use App\Http\Resources\Families\FamilyResource;
use App\Services\Families\FamilyMutationService;
use App\Services\Families\FamilyQueryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class FamilyController extends BaseApiController
{
public function __construct(
private FamilyQueryService $queryService,
private FamilyMutationService $mutationService
) {
parent::__construct();
}
public function familiesByStudent(FamiliesByStudentRequest $request, int $studentId): JsonResponse
{
$rows = $this->queryService->listFamiliesByStudent($studentId);
return $this->success([
'families' => FamilyResource::collection($rows),
]);
}
public function guardiansByFamily(GuardiansByFamilyRequest $request, int $familyId): JsonResponse
{
$rows = $this->queryService->listGuardiansByFamily($familyId);
return $this->success([
'guardians' => FamilyGuardianResource::collection($rows),
]);
}
public function bootstrap(FamilyBootstrapRequest $request): JsonResponse
{
try {
$result = $this->mutationService->bootstrapFamilies();
} catch (\Throwable $e) {
Log::error('Family bootstrap failed: '.$e->getMessage());
return $this->error('Family bootstrap failed.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->success($result, 'Bootstrap completed.');
}
public function attachSecondByUser(FamilyAttachSecondByUserRequest $request): JsonResponse
{
$payload = $request->validated();
$result = $this->mutationService->attachSecondByUser(
(int) $payload['student_id'],
(int) $payload['user_id'],
(string) ($payload['relation'] ?? 'secondary')
);
if (! ($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Unable to attach guardian.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->success($result, 'Guardian attached.');
}
public function attachSecondByEmail(FamilyAttachSecondByEmailRequest $request): JsonResponse
{
$payload = $request->validated();
try {
$result = $this->mutationService->attachSecondByEmail(
(int) $payload['student_id'],
(string) $payload['email'],
$payload['firstname'] ?? null,
$payload['lastname'] ?? null,
(string) ($payload['relation'] ?? 'secondary')
);
} catch (\Throwable $e) {
Log::error('Attach guardian by email failed: '.$e->getMessage());
return $this->error('Unable to attach guardian.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
if (! ($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Unable to attach guardian.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->success($result, 'Guardian attached.');
}
public function setPrimaryHome(FamilySetPrimaryHomeRequest $request): JsonResponse
{
$payload = $request->validated();
$this->mutationService->setPrimaryHome(
(int) $payload['family_id'],
(int) $payload['student_id'],
(bool) $payload['is_primary_home']
);
return $this->success(null, 'Primary home updated.');
}
public function setGuardianFlags(FamilySetGuardianFlagsRequest $request): JsonResponse
{
$payload = $request->validated();
$flags = array_intersect_key($payload, array_flip(['receive_emails', 'is_primary', 'receive_sms', 'relation']));
if (empty($flags)) {
return $this->error('No flags provided.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
$this->mutationService->setGuardianFlags(
(int) $payload['family_id'],
(int) $payload['user_id'],
$flags
);
return $this->success(null, 'Guardian flags updated.');
}
public function unlinkGuardian(FamilyUnlinkGuardianRequest $request): JsonResponse
{
$payload = $request->validated();
$this->mutationService->unlinkGuardian(
(int) $payload['family_id'],
(int) $payload['user_id']
);
return $this->success(null, 'Guardian unlinked.');
}
public function unlinkStudent(FamilyUnlinkStudentRequest $request): JsonResponse
{
$payload = $request->validated();
$this->mutationService->unlinkStudent(
(int) $payload['family_id'],
(int) $payload['student_id']
);
return $this->success(null, 'Student unlinked.');
}
public function importSecondParentsFromLegacy(FamilyImportLegacyRequest $request): JsonResponse
{
try {
$result = $this->mutationService->importSecondParentsFromLegacy();
} catch (\Throwable $e) {
Log::error('Legacy second parent import failed: '.$e->getMessage());
return $this->error('Legacy import failed.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
if (! ($result['ok'] ?? true)) {
return $this->error($result['message'] ?? 'Legacy import failed.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->success($result, 'Legacy import completed.');
}
}
@@ -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);
}
}
@@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Finance\FeeFamilyBalanceRequest;
use App\Http\Requests\Finance\FeeRefundRequest;
use App\Http\Requests\Finance\FeeTuitionBreakdownRequest;
use App\Http\Requests\Finance\FeeTuitionTotalRequest;
use App\Http\Resources\Fees\FeeFamilyBalanceResource;
use App\Http\Resources\Fees\FeeRefundResource;
use App\Http\Resources\Fees\FeeTuitionBreakdownResource;
use App\Http\Resources\Fees\FeeTuitionTotalResource;
use App\Services\Billing\BalanceCalculationService;
use App\Services\Fees\FeeRefundCalculatorService;
use App\Services\Fees\FeeStudentFeeService;
use App\Services\Fees\TuitionCalculationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
class FeeCalculationController extends BaseApiController
{
public function __construct(
private FeeRefundCalculatorService $refunds,
private FeeStudentFeeService $tuition,
private TuitionCalculationService $tuitionCalculator,
private BalanceCalculationService $balance
) {
parent::__construct();
}
public function refund(FeeRefundRequest $request): JsonResponse
{
$payload = $request->validated();
try {
$result = $this->refunds->calculateRefund($payload['students'], (int) $payload['parent_id']);
} catch (\Throwable $e) {
Log::error('Fee refund calculation failed', [
'parent_id' => $payload['parent_id'] ?? null,
'exception' => $e,
]);
return response()->json([
'ok' => false,
'message' => 'Unable to calculate refund.',
], 500);
}
return response()->json([
'ok' => true,
'refund' => new FeeRefundResource($result),
]);
}
public function tuitionTotal(FeeTuitionTotalRequest $request): JsonResponse
{
$payload = $request->validated();
try {
$total = $this->tuition->totalTuitionFee($payload['students']);
} catch (\Throwable $e) {
Log::error('Fee tuition total calculation failed', [
'exception' => $e,
]);
return response()->json([
'ok' => false,
'message' => 'Unable to calculate tuition total.',
], 500);
}
return response()->json([
'ok' => true,
'tuition' => new FeeTuitionTotalResource([
'total_tuition' => $total,
]),
]);
}
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())]);
}
}
@@ -0,0 +1,314 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Finance\FinancialReportRequest;
use App\Http\Requests\Finance\FinancialSummaryRequest;
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\FinancialSummaryResource;
use App\Http\Resources\Finance\FinancialUnpaidParentResource;
use App\Services\Finance\FinancialPdfReportService;
use App\Services\Finance\FinancialReportService;
use App\Services\Finance\FinancialSchoolYearService;
use App\Services\Finance\FinancialSummaryService;
use App\Services\Finance\FinancialUnpaidParentsService;
use App\Services\Finance\ParentPaymentFollowUpService;
use App\Services\Finance\StakeholderFinancialAnalysisService;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
class FinancialController extends BaseApiController
{
public function __construct(
private FinancialReportService $reportService,
private FinancialSummaryService $summaryService,
private FinancialUnpaidParentsService $unpaidParentsService,
private FinancialSchoolYearService $schoolYears,
private FinancialPdfReportService $pdfService,
private StakeholderFinancialAnalysisService $stakeholderAnalysisService,
private ParentPaymentFollowUpService $parentPaymentFollowUpService
) {
parent::__construct();
}
public function report(FinancialReportRequest $request): JsonResponse
{
$payload = $request->validated();
$report = $this->reportService->getReport(
$payload['date_from'] ?? null,
$payload['date_to'] ?? null,
$payload['school_year'] ?? null
);
return response()->json([
'ok' => true,
'report' => new FinancialReportResource($report),
]);
}
public function summary(FinancialSummaryRequest $request): JsonResponse
{
$payload = $request->validated();
$summary = $this->summaryService->getSummary(
$payload['date_from'] ?? null,
$payload['date_to'] ?? null,
$payload['school_year'] ?? null
);
$schoolYears = $this->schoolYears->listYears($summary['schoolYear'] ?? null);
return response()->json([
'ok' => true,
'summary' => new FinancialSummaryResource($summary),
'schoolYears' => $schoolYears,
]);
}
public function unpaidParents(FinancialUnpaidParentsRequest $request): JsonResponse
{
$payload = $request->validated();
$data = $this->unpaidParentsService->getUnpaidParents($payload['school_year'] ?? null);
return response()->json([
'ok' => true,
'schoolYear' => $data['schoolYear'] ?? null,
'schoolYears' => $data['schoolYears'] ?? [],
'results' => FinancialUnpaidParentResource::collection($data['results'] ?? []),
]);
}
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
{
$payload = $request->validated();
$report = $this->reportService->getReport(
$payload['date_from'] ?? null,
$payload['date_to'] ?? null,
$payload['school_year'] ?? null
);
$paymentsMap = [];
foreach ($report['payments'] as $payment) {
$invoiceId = (int) ($payment['invoice_id'] ?? 0);
$paymentsMap[$invoiceId] = (float) ($payment['paid_amount'] ?? 0);
}
$refundsMap = [];
foreach ($report['refunds'] as $refund) {
$invoiceId = (int) ($refund['invoice_id'] ?? 0);
$refundsMap[$invoiceId] = (float) ($refund['total_refunded'] ?? 0);
}
$discountsMap = [];
foreach ($report['discounts'] as $discount) {
$invoiceId = (int) ($discount['invoice_id'] ?? 0);
$discountsMap[$invoiceId] = (float) ($discount['discount_amount'] ?? 0);
}
$breakdown = $report['paymentBreakdown'] ?? [];
$filename = 'financial_report_'.date('Ymd_His').'.csv';
return response()->streamDownload(function () use ($report, $paymentsMap, $refundsMap, $discountsMap, $breakdown) {
$out = fopen('php://output', 'w');
fputcsv($out, ['Invoice #', 'Parent', 'School Year', 'Total', 'Paid', 'Cash', 'Credit', 'Check', 'Balance', 'Refund', 'Discount', 'Status']);
$sumTotal = 0.0;
$sumPaid = 0.0;
$sumCash = 0.0;
$sumCredit = 0.0;
$sumCheck = 0.0;
$sumBalance = 0.0;
$sumRefund = 0.0;
$sumDiscount = 0.0;
foreach ($report['invoices'] as $inv) {
$invoiceId = (int) ($inv['id'] ?? 0);
$paid = (float) ($paymentsMap[$invoiceId] ?? 0);
$bd = $breakdown[$invoiceId] ?? [];
$cash = (float) ($bd['cash'] ?? 0);
$credit = (float) ($bd['credit'] ?? 0);
$check = (float) ($bd['check'] ?? 0);
$refunded = (float) ($refundsMap[$invoiceId] ?? 0);
$discount = (float) ($discountsMap[$invoiceId] ?? 0);
$total = (float) ($inv['total_amount'] ?? 0);
$balance = $total - $paid - $discount - $refunded;
if ($balance < 0) {
$balance = 0.0;
}
$status = $balance === 0.0 ? 'Paid' : 'Unpaid';
fputcsv($out, [
$inv['invoice_number'] ?? '',
$inv['parent_name'] ?? '',
$inv['school_year'] ?? '',
$total,
$paid,
$cash,
$credit,
$check,
$balance,
$refunded,
$discount,
$status,
]);
$sumTotal += $total;
$sumPaid += $paid;
$sumCash += $cash;
$sumCredit += $credit;
$sumCheck += $check;
$sumBalance += $balance;
$sumRefund += $refunded;
$sumDiscount += $discount;
}
fputcsv($out, [
'TOTALS',
'',
'',
$sumTotal,
$sumPaid,
$sumCash,
$sumCredit,
$sumCheck,
$sumBalance,
$sumRefund,
$sumDiscount,
'',
]);
fputcsv($out, []);
fputcsv($out, ['Expenses Summary']);
fputcsv($out, ['Category', 'Total Amount']);
foreach ($report['expenses'] as $exp) {
fputcsv($out, [
$exp['category'] ?? '',
$exp['total_amount'] ?? 0,
]);
}
fputcsv($out, []);
fputcsv($out, ['Reimbursements Summary']);
fputcsv($out, ['Status', 'Total Amount']);
foreach ($report['reimbursements'] as $row) {
fputcsv($out, [
$row['status'] ?? '',
$row['total_amount'] ?? 0,
]);
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv']);
}
public function downloadPdf(FinancialReportRequest $request): StreamedResponse
{
$payload = $request->validated();
$summary = $this->summaryService->getSummary(
$payload['date_from'] ?? null,
$payload['date_to'] ?? null,
$payload['school_year'] ?? null
);
$pdfBytes = $this->pdfService->buildPdf($summary);
$schoolYear = $summary['schoolYear'] ?? 'report';
return response()->streamDownload(function () use ($pdfBytes) {
echo $pdfBytes;
}, '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)]);
}
}
@@ -0,0 +1,166 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Invoices\InvoiceManagementRequest;
use App\Http\Requests\Invoices\InvoiceParentRequest;
use App\Http\Requests\Invoices\InvoiceStatusRequest;
use App\Http\Resources\Invoices\InvoiceManagementParentResource;
use App\Http\Resources\Invoices\InvoiceResource;
use App\Models\Invoice;
use App\Services\Invoices\InvoiceGenerationService;
use App\Services\Invoices\InvoiceManagementService;
use App\Services\Invoices\InvoicePaymentService;
use App\Services\Invoices\InvoicePdfService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpFoundation\StreamedResponse;
class InvoiceController extends BaseApiController
{
public function __construct(
private InvoiceManagementService $management,
private InvoiceGenerationService $generation,
private InvoicePaymentService $paymentService,
private InvoicePdfService $pdfService
) {
parent::__construct();
}
public function management(InvoiceManagementRequest $request): JsonResponse
{
$schoolYear = $request->validated()['school_year'] ?? null;
$schoolYear = $schoolYear ?: $request->input('schoolYear');
$schoolYear = $schoolYear ?: $request->input('year');
$data = $this->management->getManagementData($schoolYear);
return response()->json([
'ok' => true,
'schoolYear' => $data['schoolYear'],
'schoolYears' => $data['schoolYears'],
'invoices' => InvoiceManagementParentResource::collection($data['invoices']),
]);
}
public function generate(Request $request): JsonResponse
{
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'parent_id' => ['required', 'integer', 'exists:users,id'],
'school_year' => ['nullable', 'string', 'max:20'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$result = $this->generation->generateInvoice((int) $payload['parent_id'], $payload['school_year'] ?? null);
if (empty($result['ok'])) {
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to generate invoice.'], 422);
}
return response()->json([
'ok' => true,
'updated' => $result['updated'] ?? false,
'updated_ids' => $result['updated_ids'] ?? [],
'insert_id' => $result['insert_id'] ?? null,
]);
}
public function byParent(InvoiceParentRequest $request, int $parentId): JsonResponse
{
$schoolYear = $request->validated()['school_year'] ?? null;
$invoices = Invoice::getInvoicesByParentId($parentId, $schoolYear);
return response()->json([
'ok' => true,
'invoices' => InvoiceResource::collection($invoices),
]);
}
public function parentPayment(InvoiceParentRequest $request): JsonResponse
{
$parentId = $this->authenticatedUserIdOrUnauthorized();
if ($parentId instanceof JsonResponse) {
return $parentId;
}
$schoolYear = $request->validated()['school_year'] ?? null;
$data = $this->paymentService->getParentInvoiceSummary($parentId, $schoolYear);
return response()->json([
'ok' => true,
'invoices' => InvoiceResource::collection($data['invoices']),
'schoolYears' => $data['schoolYears'],
'selectedYear' => $data['selectedYear'],
'currentSchoolYear' => $data['currentSchoolYear'],
'dueDate' => $data['dueDate'],
]);
}
public function updateStatus(InvoiceStatusRequest $request, int $invoiceId): JsonResponse
{
$status = $request->validated()['status'];
$updated = Invoice::updateInvoiceStatus($invoiceId, $status);
if (! $updated) {
return response()->json(['ok' => false, 'message' => 'Invoice not found.'], 404);
}
return response()->json(['ok' => true, 'status' => $status]);
}
public function unpaid(): JsonResponse
{
$rows = Invoice::getUnpaidInvoices();
return response()->json([
'ok' => true,
'invoices' => InvoiceResource::collection($rows),
]);
}
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
{
$pdfBytes = $this->pdfService->buildPdf($invoiceId);
return response()->streamDownload(function () use ($pdfBytes) {
echo $pdfBytes;
}, '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;
}
}
@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Payments\PaymentByParentRequest;
use App\Http\Resources\Payments\PaymentResource;
use App\Services\Payments\PaymentBalanceService;
use App\Services\Payments\PaymentLookupService;
use App\Services\Payments\PaymentPlanService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class PaymentController extends BaseApiController
{
public function __construct(
private PaymentPlanService $planService,
private PaymentLookupService $lookupService,
private PaymentBalanceService $balanceService
) {
parent::__construct();
}
public function store(Request $request): JsonResponse
{
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'parent_id' => ['required', 'integer', 'exists:users,id'],
'invoice_id' => ['nullable', 'integer', 'exists:invoices,id'],
'total_amount' => ['required', 'numeric', 'min:0.01'],
'number_of_installments' => ['required', 'integer', 'min:1'],
'payment_date' => ['nullable', 'date'],
'payment_method' => ['nullable', 'string', 'max:50'],
'payment_type' => ['nullable', 'string', 'max:50'],
'status' => ['nullable', 'string', 'max:50'],
'semester' => ['nullable', 'string', 'max:50'],
'school_year' => ['nullable', 'string', 'max:50'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$payment = $this->planService->createPlan($payload);
return response()->json([
'ok' => true,
'payment' => new PaymentResource($payment),
], 201);
}
public function byParent(PaymentByParentRequest $request, int $parentId): JsonResponse
{
$payload = $request->validated();
$payments = $this->lookupService->getByParent($parentId, $payload['school_year'] ?? null);
return response()->json([
'ok' => true,
'payments' => PaymentResource::collection($payments),
]);
}
public function updateBalance(Request $request, int $paymentId): JsonResponse
{
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'paid_amount' => ['required', 'numeric', 'min:0.01'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$updated = $this->balanceService->updateBalance($paymentId, (float) $payload['paid_amount']);
if (! $updated) {
return response()->json(['ok' => false, 'message' => 'Payment not found.'], 404);
}
return response()->json(['ok' => true]);
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Payments\PaymentEventChargesListRequest;
use App\Services\Payments\PaymentEventChargesService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class PaymentEventChargesController extends BaseApiController
{
public function __construct(private PaymentEventChargesService $service)
{
parent::__construct();
}
public function index(PaymentEventChargesListRequest $request): JsonResponse
{
$payload = $request->validated();
$data = $this->service->listCharges($payload['school_year'] ?? null, $payload['semester'] ?? null);
return response()->json(['ok' => true] + $data);
}
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());
$validator = Validator::make($data, [
'parent_id' => ['required', 'integer', 'min:1'],
'event_name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:1000'],
'amount' => ['required', 'numeric', 'min:0'],
'semester' => ['required', 'string', 'max:50'],
'school_year' => ['required', 'string', 'max:50'],
'student_ids' => ['nullable', 'array'],
'student_ids.*' => ['integer', 'min:1'],
'student_id' => ['nullable', 'integer', 'min:1'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$count = $this->service->addCharges($payload, $guard);
if ($count === 0) {
return response()->json(['ok' => false, 'message' => 'No student selected.'], 422);
}
return response()->json(['ok' => true, 'inserted' => $count]);
}
public function enrolledStudents(PaymentEventChargesListRequest $request, int $parentId): JsonResponse
{
$payload = $request->validated();
$students = $this->service->getEnrolledStudents($parentId, $payload['school_year'] ?? null);
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;
}
}
@@ -0,0 +1,126 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Payments\PaymentManualEditRequest;
use App\Http\Requests\Payments\PaymentManualUpdateRequest;
use App\Services\Payments\PaymentManualService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class PaymentManualController extends BaseApiController
{
public function __construct(private PaymentManualService $service)
{
parent::__construct();
}
public function search(Request $request): JsonResponse
{
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'search_term' => ['nullable', 'string', 'max:255'],
'school_year' => ['nullable', 'string', 'max:50'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$data = $this->service->search(
(string) ($payload['search_term'] ?? ''),
$payload['school_year'] ?? null
);
return response()->json(['ok' => true] + $data);
}
public function suggest(Request $request): JsonResponse
{
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'q' => ['required', 'string', 'max:255'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$items = $this->service->suggest((string) ($payload['q'] ?? ''));
return response()->json(['ok' => true, 'items' => $items]);
}
public function record(PaymentManualUpdateRequest $request, int $invoiceId): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated();
$result = $this->service->recordPayment(
$invoiceId,
(float) $payload['amount'],
(string) $payload['payment_method'],
$payload['payment_date'] ?? null,
$payload['check_number'] ?? null,
$payload['payment_type'] ?? null,
$request->file('payment_file'),
$payload['school_year'] ?? null,
$payload['semester'] ?? null,
$guard
);
return response()->json(['ok' => true] + $result);
}
public function edit(PaymentManualEditRequest $request, int $paymentId): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated();
$this->service->editPayment(
$paymentId,
(float) $payload['paid_amount'],
(string) $payload['payment_method'],
$payload['check_number'] ?? null,
$request->file('payment_file'),
$guard
);
return response()->json(['ok' => true]);
}
public function file(Request $request, string $filename)
{
$mode = (string) ($request->query('mode') ?? 'download');
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;
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Payments\PaymentNotificationListRequest;
use App\Http\Requests\Payments\PaymentNotificationSendRequest;
use App\Http\Resources\Payments\PaymentNotificationLogResource;
use App\Services\Payments\PaymentNotificationService;
use Illuminate\Http\JsonResponse;
class PaymentNotificationController extends BaseApiController
{
public function __construct(private PaymentNotificationService $service)
{
parent::__construct();
}
public function index(PaymentNotificationListRequest $request): JsonResponse
{
$payload = $request->validated();
$logs = $this->service->listLogs(
$payload['from'] ?? null,
$payload['to'] ?? null,
$payload['type'] ?? null
);
return response()->json([
'ok' => true,
'logs' => PaymentNotificationLogResource::collection($logs),
]);
}
public function send(PaymentNotificationSendRequest $request): JsonResponse
{
$payload = $request->validated();
$result = $this->service->send($payload);
return response()->json(['ok' => true] + $result);
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Resources\Payments\PaymentTransactionResource;
use App\Services\Payments\PaymentTransactionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class PaymentTransactionController extends BaseApiController
{
public function __construct(private PaymentTransactionService $service)
{
parent::__construct();
}
public function store(Request $request): JsonResponse
{
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'transaction_id' => ['required', 'string', 'max:100'],
'payment_id' => ['required', 'integer', 'min:1'],
'transaction_date' => ['nullable', 'date'],
'amount' => ['required', 'numeric', 'min:0.01'],
'payment_method' => ['required', 'string', 'max:50'],
'payment_status' => ['nullable', 'string', 'max:50'],
'transaction_fee' => ['nullable', 'numeric', 'min:0'],
'payment_reference' => ['nullable', 'string', 'max:100'],
'is_full_payment' => ['nullable', 'boolean'],
'school_year' => ['nullable', 'string', 'max:50'],
'semester' => ['nullable', 'string', 'max:50'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$transaction = $this->service->create($payload);
return response()->json([
'ok' => true,
'transaction' => new PaymentTransactionResource($transaction->toArray()),
], 201);
}
public function byPayment(int $paymentId): JsonResponse
{
$transactions = $this->service->getByPayment($paymentId);
return response()->json([
'ok' => true,
'transactions' => PaymentTransactionResource::collection($transactions),
]);
}
public function updateStatus(Request $request, string $transactionId): JsonResponse
{
$status = $request->input('status');
if ($status === null) {
$raw = $request->getContent() ?: '';
$json = json_decode($raw, true);
if (is_array($json) && array_key_exists('status', $json)) {
$status = $json['status'];
} else {
$parsed = [];
parse_str($raw, $parsed);
$status = $parsed['status'] ?? null;
}
}
if ($status === null || trim((string) $status) === '') {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['status' => ['The status field is required.']],
], 422);
}
$updated = $this->service->updateStatus($transactionId, (string) $status);
if (! $updated) {
return response()->json(['ok' => false, 'message' => 'Transaction not found.'], 404);
}
return response()->json(['ok' => true]);
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Payments\PaypalExecuteRequest;
use App\Services\Payments\PaypalPaymentService;
use Illuminate\Http\JsonResponse;
class PaypalPaymentController extends BaseApiController
{
public function __construct(private PaypalPaymentService $service)
{
parent::__construct();
}
public function redirect(): JsonResponse
{
return response()->json([
'ok' => true,
'redirect_url' => $this->service->getRedirectUrl(),
]);
}
public function create(int $paymentId): JsonResponse
{
$result = $this->service->createPayment($paymentId);
return response()->json(['ok' => true] + $result);
}
public function execute(PaypalExecuteRequest $request): JsonResponse
{
$payload = $request->validated();
$this->service->executePayment(
(string) $payload['payer_id'],
$payload['paypal_payment_id'] ?? null,
isset($payload['payment_id']) ? (int) $payload['payment_id'] : null
);
return response()->json(['ok' => true]);
}
public function cancel(): JsonResponse
{
$this->service->cancelPayment();
return response()->json([
'ok' => true,
'message' => 'Payment was canceled.',
]);
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Payments\PaypalTransactionsListRequest;
use App\Http\Resources\Payments\PaypalTransactionResource;
use App\Services\Payments\PaypalTransactionsService;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PaypalTransactionsController extends BaseApiController
{
public function __construct(private PaypalTransactionsService $service)
{
parent::__construct();
}
public function index(PaypalTransactionsListRequest $request): JsonResponse
{
$payload = $request->validated();
$perPage = (int) ($payload['per_page'] ?? 10);
$rows = $this->service->list($payload['q'] ?? null, $perPage);
return response()->json([
'ok' => true,
'transactions' => PaypalTransactionResource::collection($rows->items()),
'pagination' => [
'current_page' => $rows->currentPage(),
'per_page' => $rows->perPage(),
'total' => $rows->total(),
'last_page' => $rows->lastPage(),
],
]);
}
public function downloadCsv(PaypalTransactionsListRequest $request): StreamedResponse
{
$payload = $request->validated();
$rows = $this->service->listAll($payload['q'] ?? null);
$filename = 'paypal_transactions_'.date('Ymd_His').'.csv';
return response()->streamDownload(function () use ($rows) {
$out = fopen('php://output', 'w');
fputcsv($out, [
'ID', 'Transaction ID', 'Order ID', 'Parent School ID',
'Email', 'Amount', 'Net Amount', 'Currency',
'Status', 'Event Type', 'Created At',
]);
foreach ($rows as $row) {
fputcsv($out, [
$row['id'] ?? null,
$row['transaction_id'] ?? null,
$row['order_id'] ?? null,
$row['parent_school_id'] ?? null,
$row['payer_email'] ?? null,
$row['amount'] ?? null,
$row['net_amount'] ?? null,
$row['currency'] ?? null,
$row['status'] ?? null,
$row['event_type'] ?? null,
$row['created_at'] ?? null,
]);
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv']);
}
}
@@ -0,0 +1,186 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\PurchaseOrders\PurchaseOrderIndexRequest;
use App\Http\Resources\PurchaseOrders\PurchaseOrderItemResource;
use App\Http\Resources\PurchaseOrders\PurchaseOrderResource;
use App\Models\PurchaseOrder;
use App\Services\PurchaseOrders\PurchaseOrderCreateService;
use App\Services\PurchaseOrders\PurchaseOrderQueryService;
use App\Services\PurchaseOrders\PurchaseOrderReceiveService;
use App\Services\PurchaseOrders\PurchaseOrderStatusService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use RuntimeException;
class PurchaseOrderController extends BaseApiController
{
public function __construct(
private PurchaseOrderQueryService $queryService,
private PurchaseOrderCreateService $createService,
private PurchaseOrderReceiveService $receiveService,
private PurchaseOrderStatusService $statusService
) {
parent::__construct();
}
public function index(PurchaseOrderIndexRequest $request): JsonResponse
{
$payload = $request->validated();
$orders = $this->queryService->list($payload['q'] ?? null);
return response()->json([
'ok' => true,
'orders' => PurchaseOrderResource::collection($orders),
]);
}
public function options(): JsonResponse
{
$data = $this->queryService->options();
return response()->json([
'ok' => true,
'suppliers' => $data['suppliers'] ?? [],
'supplies' => $data['supplies'] ?? [],
]);
}
public function show(int $id): JsonResponse
{
$data = $this->queryService->find($id);
if (! $data) {
return response()->json(['ok' => false, 'message' => 'Purchase order not found.'], 404);
}
return response()->json([
'ok' => true,
'purchase_order' => new PurchaseOrderResource($data['purchase_order']),
'items' => PurchaseOrderItemResource::collection($data['items'] ?? []),
]);
}
public function store(Request $request): JsonResponse
{
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'po_number' => ['required', 'string', 'max:60'],
'supplier_id' => ['required', 'integer', 'min:1', 'exists:suppliers,id'],
'status' => ['nullable', 'string', 'in:'.implode(',', PurchaseOrder::allowedStatuses())],
'order_date' => ['nullable', 'date'],
'expected_date' => ['nullable', 'date'],
'notes' => ['nullable', 'string', 'max:5000'],
'items' => ['required', 'array', 'min:1'],
'items.*.supply_id' => ['required', 'integer', 'min:1', 'exists:supplies,id'],
'items.*.description' => ['nullable', 'string', 'max:1000'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'items.*.unit_cost' => ['required', 'numeric', 'min:0'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
if (! empty($payload['order_date']) && ! empty($payload['expected_date'])) {
$orderTs = strtotime((string) $payload['order_date']);
$expectedTs = strtotime((string) $payload['expected_date']);
if ($orderTs !== false && $expectedTs !== false && $expectedTs < $orderTs) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['expected_date' => ['The expected date must be on or after the order date.']],
], 422);
}
}
if (auth()->user() === null) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
try {
$po = $this->createService->create($payload);
} catch (RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
} catch (\Throwable $e) {
return response()->json(['ok' => false, 'message' => 'Unable to create purchase order.'], 500);
}
return response()->json([
'ok' => true,
'purchase_order' => new PurchaseOrderResource($po),
], 201);
}
public function receive(Request $request, int $id): JsonResponse
{
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'items' => ['required', 'array', 'min:1'],
'items.*.id' => ['required', 'integer', 'min:1'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$po = PurchaseOrder::query()->find($id);
if (! $po) {
return response()->json(['ok' => false, 'message' => 'Purchase order not found.'], 404);
}
$user = auth()->user();
if ($user === null) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
$issuedBy = (string) ($user->email ?? $user->username ?? $user->id);
try {
$result = $this->receiveService->receive($id, $payload['items'], $issuedBy);
} catch (RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 409);
} catch (\Throwable $e) {
return response()->json(['ok' => false, 'message' => 'Unable to receive items.'], 500);
}
return response()->json([
'ok' => true,
'status' => $result['status'] ?? null,
'completed' => $result['completed'] ?? false,
]);
}
public function cancel(int $id): JsonResponse
{
if (auth()->user() === null) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
$po = PurchaseOrder::query()->find($id);
if (! $po) {
return response()->json(['ok' => false, 'message' => 'Purchase order not found.'], 404);
}
try {
$this->statusService->cancel($id);
} catch (RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 409);
} catch (\Throwable $e) {
return response()->json(['ok' => false, 'message' => 'Unable to cancel purchase order.'], 500);
}
return response()->json(['ok' => true, 'status' => PurchaseOrder::STATUS_CANCELED]);
}
}
@@ -0,0 +1,225 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Refunds\RefundDecisionRequest;
use App\Http\Requests\Refunds\RefundIndexRequest;
use App\Http\Requests\Refunds\RefundPaymentRequest;
use App\Http\Requests\Refunds\RefundRecalculateRequest;
use App\Http\Requests\Refunds\RefundRejectRequest;
use App\Http\Requests\Refunds\RefundStoreRequest;
use App\Http\Resources\Refunds\RefundResource;
use App\Models\Configuration;
use App\Services\Refunds\RefundDecisionService;
use App\Services\Refunds\RefundOverpaymentService;
use App\Services\Refunds\RefundPayoutService;
use App\Services\Refunds\RefundQueryService;
use App\Services\Refunds\RefundRequestService;
use App\Services\Refunds\RefundSummaryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
class RefundController extends BaseApiController
{
public function __construct(
private RefundQueryService $queryService,
private RefundRequestService $requestService,
private RefundDecisionService $decisionService,
private RefundPayoutService $payoutService,
private RefundOverpaymentService $overpaymentService,
private RefundSummaryService $summaryService
) {
parent::__construct();
}
public function index(RefundIndexRequest $request): JsonResponse
{
$payload = $request->validated();
$result = $this->queryService->list($payload);
return response()->json([
'ok' => true,
'refunds' => RefundResource::collection($result['refunds']),
'pagination' => $result['pagination'],
]);
}
public function show(int $refundId): JsonResponse
{
$refund = $this->queryService->find($refundId);
if (! $refund) {
return response()->json(['ok' => false, 'message' => 'Refund not found.'], 404);
}
return response()->json([
'ok' => true,
'refund' => new RefundResource($refund),
]);
}
public function store(RefundStoreRequest $request): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated();
try {
$result = $this->requestService->requestRefund($payload, $guard);
} catch (\Throwable $e) {
Log::error('Refund request failed.', ['error' => $e->getMessage()]);
return response()->json(['ok' => false, 'message' => 'Failed to submit refund request.'], 500);
}
if (empty($result['ok'])) {
return response()->json([
'ok' => false,
'message' => $result['message'] ?? 'Failed to submit refund request.',
'available' => $result['available'] ?? null,
], 422);
}
return response()->json([
'ok' => true,
'refund_id' => $result['refund_id'] ?? null,
'refund_amount' => $result['refund_amount'] ?? null,
'status' => $result['status'] ?? null,
], 201);
}
public function update(RefundDecisionRequest $request, int $refundId): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated();
$result = $this->decisionService->updateDecision(
$refundId,
(string) $payload['status'],
(string) $payload['reason'],
$guard
);
if (empty($result['ok'])) {
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to update refund.'], 422);
}
return response()->json(['ok' => true]);
}
public function destroy(int $refundId): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$result = $this->decisionService->cancel($refundId, $guard);
if (empty($result['ok'])) {
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to cancel refund.'], 404);
}
return response()->json(['ok' => true]);
}
public function approve(int $refundId): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$result = $this->decisionService->approve($refundId, $guard);
if (empty($result['ok'])) {
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Approve failed.'], 422);
}
return response()->json(['ok' => true]);
}
public function reject(RefundRejectRequest $request, int $refundId): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated();
$result = $this->decisionService->reject($refundId, (string) $payload['reason'], $guard);
if (empty($result['ok'])) {
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Reject failed.'], 422);
}
return response()->json(['ok' => true]);
}
public function recordPayment(RefundPaymentRequest $request, int $refundId): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated();
$payload['check_file'] = $request->file('check_file');
$result = $this->payoutService->recordPayment($refundId, $payload, $guard);
if (empty($result['ok'])) {
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to update payment.'], 422);
}
return response()->json(['ok' => true]);
}
public function recalculateOverpayments(RefundRecalculateRequest $request): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated();
$result = $this->overpaymentService->recalculate(true, $payload['invoice_number'] ?? null, $guard);
return response()->json([
'ok' => true,
'created_ids' => $result['created_ids'] ?? null,
'updated_ids' => $result['updated_ids'] ?? null,
'message' => $result['message'] ?? null,
]);
}
public function parentBalances(int $parentId): JsonResponse
{
$termSchoolYear = (string) (Configuration::getConfig('school_year') ?? '');
$termSemester = (string) (Configuration::getConfig('semester') ?? '');
$summary = $this->summaryService->getParentFinancialSummary($parentId, $termSchoolYear, $termSemester);
return response()->json([
'ok' => true,
'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;
}
}
@@ -0,0 +1,492 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Files\FileNameRequest;
use App\Http\Requests\Reimbursements\ReimbursementBatchAdminFileRequest;
use App\Http\Requests\Reimbursements\ReimbursementBatchAssignmentRequest;
use App\Http\Requests\Reimbursements\ReimbursementBatchEmailRequest;
use App\Http\Requests\Reimbursements\ReimbursementBatchLockRequest;
use App\Http\Requests\Reimbursements\ReimbursementExportBatchRequest;
use App\Http\Requests\Reimbursements\ReimbursementExportRequest;
use App\Http\Requests\Reimbursements\ReimbursementIndexRequest;
use App\Http\Requests\Reimbursements\ReimbursementProcessRequest;
use App\Http\Requests\Reimbursements\ReimbursementUnderProcessingRequest;
use App\Http\Resources\Reimbursements\ReimbursementBatchResource;
use App\Http\Resources\Reimbursements\ReimbursementExpenseResource;
use App\Http\Resources\Reimbursements\ReimbursementRecipientResource;
use App\Http\Resources\Reimbursements\ReimbursementResource;
use App\Http\Resources\Reimbursements\ReimbursementUnderProcessingItemResource;
use App\Models\Reimbursement;
use App\Models\ReimbursementBatch;
use App\Services\Files\FileServeService;
use App\Services\Reimbursements\ReimbursementBatchAssignmentService;
use App\Services\Reimbursements\ReimbursementBatchService;
use App\Services\Reimbursements\ReimbursementContextService;
use App\Services\Reimbursements\ReimbursementCrudService;
use App\Services\Reimbursements\ReimbursementDonationService;
use App\Services\Reimbursements\ReimbursementEmailService;
use App\Services\Reimbursements\ReimbursementExportService;
use App\Services\Reimbursements\ReimbursementFileService;
use App\Services\Reimbursements\ReimbursementQueryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Validator;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ReimbursementController extends BaseApiController
{
public function __construct(
private ReimbursementContextService $context,
private ReimbursementQueryService $queryService,
private ReimbursementDonationService $donationService,
private ReimbursementBatchService $batchService,
private ReimbursementBatchAssignmentService $assignmentService,
private ReimbursementFileService $fileService,
private ReimbursementExportService $exportService,
private ReimbursementEmailService $emailService,
private ReimbursementCrudService $crudService,
private FileServeService $fileServeService
) {
parent::__construct();
}
public function underProcessing(ReimbursementUnderProcessingRequest $request): JsonResponse
{
$data = $this->queryService->underProcessing();
return response()->json([
'ok' => true,
'pendingItems' => ReimbursementUnderProcessingItemResource::collection($data['pendingItems'] ?? []),
'existingBatches' => ReimbursementBatchResource::collection($data['existingBatches'] ?? []),
'adminUsers' => ReimbursementRecipientResource::collection($data['adminUsers'] ?? []),
'itemsPayload' => ReimbursementUnderProcessingItemResource::collection($data['itemsPayload'] ?? []),
]);
}
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());
$validator = Validator::make($data, [
'expense_id' => ['required', 'integer', 'min:1'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
try {
$this->donationService->markDonation((int) $payload['expense_id'], $guard);
} catch (RuntimeException $e) {
$message = $e->getMessage();
$status = str_contains($message, 'not found') ? 404 : (str_contains($message, 'already') ? 409 : 422);
return response()->json(['ok' => false, 'message' => $message], $status);
} catch (\Throwable $e) {
return response()->json(['ok' => false, 'message' => 'Unable to mark donation right now.'], 500);
}
return response()->json(['ok' => true]);
}
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());
$validator = Validator::make($data, [
'title' => ['nullable', 'string', 'max:255'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$schoolYear = $this->context->getSchoolYear();
$semester = $this->context->getSemester();
$batch = $this->batchService->createBatch($payload['title'] ?? null, $guard, $schoolYear, $semester);
return response()->json([
'ok' => true,
'batch' => $batch,
], 201);
}
public function updateBatchAssignment(ReimbursementBatchAssignmentRequest $request): JsonResponse
{
$payload = $request->validated();
$batchId = (int) ($payload['batch_id'] ?? 0);
$batchNumber = $payload['batch_number'] ?? null;
if ($batchId <= 0 && $batchNumber !== null && trim((string) $batchNumber) !== '') {
$batchId = (int) $batchNumber;
}
$adminIdRaw = $payload['admin_id'] ?? null;
$adminId = ($adminIdRaw === '' || $adminIdRaw === null) ? null : (int) $adminIdRaw;
$reimbursementId = ! empty($payload['reimbursement_id']) ? (int) $payload['reimbursement_id'] : null;
try {
$result = $this->assignmentService->updateAssignment(
(int) $payload['expense_id'],
$batchId,
$adminId,
$reimbursementId,
$this->context->getSchoolYear(),
$this->context->getSemester()
);
} catch (RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
return response()->json([
'ok' => true,
'assignment' => $result,
]);
}
public function lockBatch(ReimbursementBatchLockRequest $request): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated();
try {
$this->batchService->lockBatch(
(int) $payload['batch_id'],
$guard,
$this->context->getSchoolYear(),
$this->context->getSemester()
);
} catch (RuntimeException $e) {
$message = $e->getMessage();
$status = str_contains($message, 'check file') ? 409 : 404;
return response()->json(['ok' => false, 'message' => $message], $status);
} catch (\Throwable $e) {
return response()->json(['ok' => false, 'message' => 'Unable to lock batch right now.'], 500);
}
return response()->json(['ok' => true, 'status' => 'closed']);
}
public function uploadBatchAdminFile(ReimbursementBatchAdminFileRequest $request): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated();
$batchId = (int) $payload['batch_id'];
$adminId = (int) $payload['admin_id'];
$batch = ReimbursementBatch::query()->find($batchId);
if (! $batch) {
return response()->json(['ok' => false, 'message' => 'Batch not found.'], 404);
}
if (strtolower((string) ($batch->status ?? '')) !== ReimbursementBatch::STATUS_OPEN) {
return response()->json(['ok' => false, 'message' => 'Cannot upload files for a locked batch.'], 409);
}
try {
$file = $this->fileService->storeAdminFile($batchId, $adminId, $request->file('check_file'), $guard);
} catch (\Throwable $e) {
return response()->json(['ok' => false, 'message' => 'Unable to store the uploaded file.'], 500);
}
return response()->json([
'ok' => true,
'file' => $file,
]);
}
public function serveAdminCheckFile(FileNameRequest $request, string $name, string $mode = 'inline'): Response|JsonResponse
{
$allowedExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
$meta = $this->fileServeService->meta(storage_path('uploads/reimbursements'), $name, $allowedExtensions, null);
$disposition = strtolower(trim($mode)) === 'download' ? 'attachment' : 'inline';
return response(file_get_contents($meta['path']), 200, [
'Content-Type' => $meta['mime'],
'Content-Disposition' => $disposition.'; filename="'.$meta['download_name'].'"',
'Content-Length' => (string) $meta['size'],
'ETag' => $meta['etag'],
'Last-Modified' => $meta['last_modified'],
]);
}
public function index(ReimbursementIndexRequest $request): JsonResponse
{
$payload = $request->validated();
$filters = [
'school_year' => $payload['school_year'] ?? null,
'semester' => $payload['semester'] ?? null,
'status' => $payload['status'] ?? null,
'user_id' => $payload['user_id'] ?? null,
];
$data = $this->queryService->index($filters);
return response()->json([
'ok' => true,
'expenses' => ReimbursementExpenseResource::collection($data['expenses'] ?? []),
'users' => ReimbursementRecipientResource::collection($data['users'] ?? []),
'schoolYears' => $data['schoolYears'] ?? [],
'batchSummaries' => $data['batchSummaries'] ?? [],
'batchDetails' => $data['batchDetails'] ?? [],
'batchAttachments' => $data['batchAttachments'] ?? [],
'donationBatch' => $data['donationBatch'] ?? null,
'donationDetails' => $data['donationDetails'] ?? null,
]);
}
public function sendBatchEmail(ReimbursementBatchEmailRequest $request): JsonResponse
{
$payload = $request->validated();
$receiptIds = $payload['receipts'] ?? [];
$checkIds = $payload['checks'] ?? [];
try {
$ok = $this->emailService->sendBatchEmail(
(int) $payload['batch_id'],
$payload['recipient_email'],
(string) ($payload['message'] ?? ''),
$receiptIds,
$checkIds
);
} catch (RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 404);
}
if (! $ok) {
return response()->json(['ok' => false, 'message' => 'Failed to send email. Please try again.'], 500);
}
return response()->json(['ok' => true, 'message' => 'Email sent successfully.']);
}
public function export(ReimbursementExportRequest $request): StreamedResponse
{
$payload = $request->validated();
$type = $payload['type'] ?? 'processed';
if ($type === 'under_processing') {
$csv = $this->exportService->buildUnderProcessingCsv();
} else {
$csv = $this->exportService->buildProcessedCsv($payload);
}
return response()->streamDownload(function () use ($csv) {
$out = fopen('php://output', 'w');
fprintf($out, chr(0xEF).chr(0xBB).chr(0xBF));
foreach ($csv['rows'] as $row) {
fputcsv($out, $row);
}
fclose($out);
}, $csv['filename'], [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
public function exportBatch(ReimbursementExportBatchRequest $request): StreamedResponse
{
$payload = $request->validated();
$csv = $this->exportService->buildBatchCsv((int) $payload['batch_id']);
return response()->streamDownload(function () use ($csv) {
$out = fopen('php://output', 'w');
fprintf($out, chr(0xEF).chr(0xBB).chr(0xBF));
foreach ($csv['rows'] as $row) {
fputcsv($out, $row);
}
fclose($out);
}, $csv['filename'], [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
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());
$validator = Validator::make($data, [
'amount' => ['required', 'numeric', 'gt:0'],
'reimbursed_to' => ['required', 'integer', 'min:1'],
'reimbursement_method' => ['required', 'in:Cash,Check'],
'check_number' => ['required_if:reimbursement_method,Check', 'nullable', 'string', 'max:50'],
'receipt' => ['required_if:reimbursement_method,Check', 'nullable', 'file', 'max:2048', 'mimes:jpg,jpeg,png,webp,gif,pdf'],
'expense_id' => ['nullable', 'integer', 'min:1'],
'description' => ['nullable', 'string'],
'school_year' => ['nullable', 'string', 'max:20'],
'semester' => ['nullable', 'string', 'max:20'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$receiptName = null;
if ($request->hasFile('receipt')) {
$receiptName = $this->fileService->storeReceipt($request->file('receipt'));
}
try {
$reimb = $this->crudService->store(
$payload,
$guard,
$this->context->getSchoolYear(),
$this->context->getSemester(),
$receiptName
);
} catch (RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
$data = $reimb->toArray();
$data['receipt_url'] = $this->fileService->receiptUrl($reimb->receipt_path ?? null);
return response()->json([
'ok' => true,
'reimbursement' => new ReimbursementResource($data),
], 201);
}
public function process(ReimbursementProcessRequest $request): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated();
$receiptName = null;
if ($request->hasFile('receipt')) {
$receiptName = $this->fileService->storeReceipt($request->file('receipt'));
}
try {
$reimb = $this->crudService->process(
$payload,
$guard,
$this->context->getSchoolYear(),
$this->context->getSemester(),
$receiptName
);
} catch (RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
}
$data = $reimb->toArray();
$data['receipt_url'] = $this->fileService->receiptUrl($reimb->receipt_path ?? null);
return response()->json([
'ok' => true,
'reimbursement' => new ReimbursementResource($data),
], 201);
}
public function reimbursedExpenses(): JsonResponse
{
$expenses = $this->queryService->reimbursedExpenses();
return response()->json([
'ok' => true,
'expenses' => $expenses,
]);
}
public function update(Request $request, int $id): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$reimb = Reimbursement::query()->find($id);
if (! $reimb) {
return response()->json(['ok' => false, 'message' => 'Reimbursement not found.'], 404);
}
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
$validator = Validator::make($data, [
'amount' => ['required', 'numeric', 'gt:0'],
'reimbursed_to' => ['required', 'integer', 'min:1'],
'reimbursement_method' => ['required', 'in:Cash,Check'],
'check_number' => ['required_if:reimbursement_method,Check', 'nullable', 'string', 'max:50'],
'receipt' => ['nullable', 'file', 'max:2048', 'mimes:jpg,jpeg,png,webp,gif,pdf'],
'remove_receipt' => ['nullable', 'boolean'],
'description' => ['nullable', 'string'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$payload = $validator->validated();
$receiptName = $reimb->receipt_path;
if ($request->hasFile('receipt')) {
$receiptName = $this->fileService->storeReceipt($request->file('receipt'));
}
if (! empty($payload['remove_receipt'])) {
$receiptName = null;
}
$updated = $this->crudService->update($reimb, $payload, $guard, $receiptName);
$data = $updated->toArray();
$data['receipt_url'] = $this->fileService->receiptUrl($updated->receipt_path ?? null);
return response()->json([
'ok' => true,
'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;
}
}

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