Compare commits
79 Commits
main
..
b5fd4a4ca1
| Author | SHA1 | Date | |
|---|---|---|---|
| b5fd4a4ca1 | |||
| 8d4d610b82 | |||
| c792b8be05 | |||
| 567dc24649 | |||
| bd743d57cf | |||
| 600cf8d876 | |||
| 60ecacb7f8 | |||
| 79024235ef | |||
| 8ca24ef03f | |||
| 68a5c9edca | |||
| 6866aedf42 | |||
| a192ed433d | |||
| ad968eaff7 | |||
| d28d11e2e5 | |||
| 6fa656bb6c | |||
| 6408906f07 | |||
| ae52b9c52e | |||
| 6e3f2eae5d | |||
| b8212d3304 | |||
| 5e5fe3794a | |||
| feb6be0610 | |||
| b4e6ac03c5 | |||
| 4e33882ac7 | |||
| fa6c9519a0 | |||
| df5266c5b5 | |||
| 8beeed883f | |||
| 5e8fa682b9 | |||
| 5c544f93b9 | |||
| eafe4eb134 | |||
| 4cd98f1d30 | |||
| 7216cb2885 | |||
| 5128c74892 | |||
| ca4ba272fc | |||
| 1977a513df | |||
| c582bfc242 | |||
| 33be0c9a0d | |||
| 0f39dbee62 | |||
| 863e330dd8 | |||
| 2ef71cc92b | |||
| 3e6c577085 | |||
| 182036cc41 | |||
| f6be51576c | |||
| ba1206e314 | |||
| 25e7b3c67c | |||
| 17d73e2f92 | |||
| a82f7aedbc | |||
| c235fd2170 | |||
| abebe0d9c0 | |||
| 311bb93977 | |||
| 2e9a391280 | |||
| 3bf4c687da | |||
| 20ee70d153 | |||
| 5eeaec0257 | |||
| a953f00675 | |||
| 99d399d36c | |||
| b7bdee3af5 | |||
| 51ab9cbdbb | |||
| d0c8ac533b | |||
| f236e5bfe4 | |||
| 6a03e547cc | |||
| 51d2504e99 | |||
| d19d27bd88 | |||
| cd73300bc6 | |||
| 6e8c3f5466 | |||
| e2c3e3cf85 | |||
| d9567f925f | |||
| d7d718c469 | |||
| 380740e4c8 | |||
| af250d9fd4 | |||
| 52bd34e213 | |||
| ad5e42c6c7 | |||
| f8aa350bfd | |||
| adc4219766 | |||
| 79e44fe037 | |||
| 1cb3573d4b | |||
| 0c3e9b16f7 | |||
| b3efb8c815 | |||
| 6fbca86b9b | |||
| d76c871cb7 |
@@ -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
@@ -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
@@ -2,8 +2,8 @@ FROM composer:2 AS vendor
|
||||
|
||||
WORKDIR /app
|
||||
COPY composer.json composer.lock ./
|
||||
COPY app/Helpers/ci_helpers.php app/Helpers/
|
||||
RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts
|
||||
COPY app/Helpers/app_helpers.php app/Helpers/
|
||||
RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts --ignore-platform-req=ext-gd
|
||||
COPY . .
|
||||
RUN composer dump-autoload --optimize \
|
||||
&& php artisan package:discover --ansi
|
||||
@@ -30,7 +30,10 @@ RUN apt-get update \
|
||||
libonig-dev \
|
||||
libxml2-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/*
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
+37
-4
@@ -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 \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
@@ -6,11 +11,39 @@ RUN apt-get update \
|
||||
unzip \
|
||||
sqlite3 \
|
||||
libsqlite3-dev \
|
||||
default-mysql-client \
|
||||
libonig-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/*
|
||||
|
||||
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"]
|
||||
|
||||
@@ -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
@@ -22,7 +22,7 @@ pipeline {
|
||||
|
||||
stage('Prepare Test DB') {
|
||||
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 {
|
||||
env.APP_KEY = sh(
|
||||
returnStdout: true,
|
||||
script: "php -r 'echo \"base64:\" . base64_encode(random_bytes(32));'"
|
||||
script: "docker compose -f docker-compose.ci.yml run --rm keygen"
|
||||
).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 {
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
Vendored
BIN
Binary file not shown.
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use App\Models\CiDatabase;
|
||||
|
||||
class Database
|
||||
{
|
||||
public static function connect(): CiDatabase
|
||||
{
|
||||
return CiDatabase::instance();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
class DeleteUnverifiedUser
|
||||
{
|
||||
public function __construct(public int $userId) {}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
Executable → Regular
+12
-42
@@ -2,30 +2,6 @@
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
if (!function_exists('log_message')) {
|
||||
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
|
||||
@@ -39,8 +15,9 @@ if (!function_exists('local_date')) {
|
||||
{
|
||||
try {
|
||||
$carbon = CarbonImmutable::parse($utcDateTime)->setTimezone(config('app.timezone'));
|
||||
|
||||
return $carbon->format($format);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
return CarbonImmutable::parse($utcDateTime)->format($format);
|
||||
}
|
||||
}
|
||||
@@ -53,13 +30,6 @@ if (!function_exists('user_timezone')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('model')) {
|
||||
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
|
||||
{
|
||||
@@ -93,25 +63,25 @@ if (!function_exists('pbkdf2_verify')) {
|
||||
}
|
||||
|
||||
$calc = hash_pbkdf2($algo, $password, $salt, (int) $iterations, strlen($derived), true);
|
||||
|
||||
return hash_equals($derived, $calc);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('legacy_password_verify')) {
|
||||
function legacy_password_verify(string $password, ?string $storedHash): bool
|
||||
{
|
||||
return ci_password_verify($password, $storedHash);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('ci_password_verify')) {
|
||||
function ci_password_verify(string $password, ?string $storedHash): bool
|
||||
if (! function_exists('verify_stored_password')) {
|
||||
function verify_stored_password(string $password, ?string $storedHash): bool
|
||||
{
|
||||
if (! $storedHash) {
|
||||
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);
|
||||
}
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -3,17 +3,17 @@
|
||||
namespace App\Http\Controllers\Api\Administrator;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Administrator\SubmitAdministratorAbsenceRequest;
|
||||
use App\Services\Administrator\AdministratorAbsenceService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class AdministratorAbsenceController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AdministratorAbsenceService $service
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
@@ -25,13 +25,42 @@ class AdministratorAbsenceController extends Controller
|
||||
return response()->json($this->service->getAbsenceFormData($userId));
|
||||
}
|
||||
|
||||
public function store(SubmitAdministratorAbsenceRequest $request): JsonResponse
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$userId = (int) Auth::id();
|
||||
if ($userId <= 0) {
|
||||
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);
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -11,8 +11,7 @@ class AdministratorDashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AdministratorDashboardService $service
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function metrics(): JsonResponse
|
||||
{
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
namespace App\Http\Controllers\Api\Administrator;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Administrator\UpdateEnrollmentStatusesRequest;
|
||||
use App\Services\Administrator\AdministratorEnrollmentService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class AdministratorEnrollmentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AdministratorEnrollmentService $service
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
@@ -30,14 +30,53 @@ class AdministratorEnrollmentController extends Controller
|
||||
return response()->json($this->service->showNewStudentsData());
|
||||
}
|
||||
|
||||
public function updateStatuses(UpdateEnrollmentStatusesRequest $request): JsonResponse
|
||||
public function updateStatuses(Request $request): JsonResponse
|
||||
{
|
||||
$editorUserId = (int) Auth::id();
|
||||
if ($editorUserId <= 0) {
|
||||
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(
|
||||
(array) ($data['enrollment_status'] ?? []),
|
||||
$editorUserId
|
||||
|
||||
@@ -3,26 +3,47 @@
|
||||
namespace App\Http\Controllers\Api\Administrator;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Administrator\SaveAdminNotificationSubjectsRequest;
|
||||
use App\Http\Requests\Administrator\SavePrintRecipientsRequest;
|
||||
use App\Services\Administrator\AdministratorNotificationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class AdministratorNotificationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AdministratorNotificationService $service
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function alerts(): JsonResponse
|
||||
{
|
||||
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(
|
||||
(array) ($data['subjects'] ?? [])
|
||||
);
|
||||
@@ -35,9 +56,31 @@ class AdministratorNotificationController extends Controller
|
||||
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(
|
||||
(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;
|
||||
}
|
||||
}
|
||||
+23
-6
@@ -3,30 +3,47 @@
|
||||
namespace App\Http\Controllers\Api\Administrator;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Administrator\SendTeacherSubmissionNotificationsRequest;
|
||||
use App\Services\Administrator\AdministratorTeacherSubmissionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class AdministratorTeacherSubmissionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
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();
|
||||
if ($adminId <= 0) {
|
||||
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);
|
||||
|
||||
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;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Assignment\StoreAssignmentRequest;
|
||||
use App\Http\Resources\Assignment\AssignmentOverviewResource;
|
||||
use App\Http\Resources\Assignment\AssignmentSectionResource;
|
||||
use App\Services\Assignment\AssignmentService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
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(
|
||||
data: $request->validated(),
|
||||
updatedBy: $request->user()?->id
|
||||
data: $validator->validated(),
|
||||
updatedBy: $userId
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -31,9 +31,14 @@ class AdminAttendanceApiController extends Controller
|
||||
|
||||
public function updateManagement(UpdateAttendanceManagementRequest $request): JsonResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
if ($user === null) {
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
return response()->json(
|
||||
$this->attendanceService->updateAttendanceManagement(auth()->user(), $request->validated())
|
||||
$this->attendanceService->updateAttendanceManagement($user, $request->validated())
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
@@ -42,9 +47,14 @@ class AdminAttendanceApiController extends Controller
|
||||
|
||||
public function addEntry(AdminAddAttendanceEntryRequest $request): JsonResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
if ($user === null) {
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
return response()->json(
|
||||
$this->attendanceService->adminAddEntry(auth()->user(), $request->validated())
|
||||
$this->attendanceService->adminAddEntry($user, $request->validated())
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
|
||||
@@ -3,17 +3,25 @@
|
||||
namespace App\Http\Controllers\Api\Attendance;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AttendanceCommentTemplate\StoreAttendanceCommentTemplateRequest;
|
||||
use App\Http\Requests\AttendanceCommentTemplate\UpdateAttendanceCommentTemplateRequest;
|
||||
use App\Services\ApplicationUrlService;
|
||||
use App\Services\AttendanceCommentTemplateService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class AttendanceCommentTemplateController extends Controller
|
||||
{
|
||||
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
|
||||
@@ -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([
|
||||
'status' => 'success',
|
||||
@@ -60,9 +87,31 @@ class AttendanceCommentTemplateController extends Controller
|
||||
], 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([
|
||||
'status' => 'success',
|
||||
@@ -79,4 +128,76 @@ class AttendanceCommentTemplateController extends Controller
|
||||
'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.');
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,14 @@ class StaffAttendanceApiController extends Controller
|
||||
|
||||
public function saveAdmins(SaveAdminAttendanceRequest $request): JsonResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
if ($user === null) {
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
return response()->json(
|
||||
$this->staffAttendanceService->saveAdmins(auth()->user(), $request->validated())
|
||||
$this->staffAttendanceService->saveAdmins($user, $request->validated())
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
@@ -53,9 +58,14 @@ class StaffAttendanceApiController extends Controller
|
||||
|
||||
public function saveCell(SaveStaffAttendanceCellRequest $request): JsonResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
if ($user === null) {
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
return response()->json(
|
||||
$this->staffAttendanceService->saveCell(auth()->user(), $request->validated())
|
||||
$this->staffAttendanceService->saveCell($user, $request->validated())
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
|
||||
@@ -33,10 +33,15 @@ class TeacherAttendanceApiController extends Controller
|
||||
|
||||
public function form(Request $request): JsonResponse|TeacherAttendanceFormResource
|
||||
{
|
||||
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
try {
|
||||
return new TeacherAttendanceFormResource(
|
||||
$this->queryService->teacherAttendanceFormData(
|
||||
auth()->id(),
|
||||
$guard,
|
||||
(int) $request->query('class_section_id', 0)
|
||||
)
|
||||
);
|
||||
@@ -47,12 +52,27 @@ class TeacherAttendanceApiController extends Controller
|
||||
|
||||
public function submit(TeacherSubmitAttendanceRequest $request): JsonResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
if ($user === null) {
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
return response()->json(
|
||||
$this->attendanceService->submitTeacherAttendance(auth()->user(), $request->validated())
|
||||
$this->attendanceService->submitTeacherAttendance($user, $request->validated())
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
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;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AttendanceTracking\ComposeAttendanceEmailRequest;
|
||||
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 App\Services\AttendanceTracking\AttendanceTrackingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class AttendanceTrackingController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AttendanceTrackingService $service
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
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(
|
||||
$result,
|
||||
@@ -80,13 +92,28 @@ class AttendanceTrackingController extends Controller
|
||||
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(
|
||||
(int) $request->validated('student_id'),
|
||||
(string) $request->validated('code', 'ABS_1'),
|
||||
(string) $request->validated('variant', 'default'),
|
||||
$request->validated('incident_date'),
|
||||
(int) ($data['student_id'] ?? 0),
|
||||
(string) ($data['code'] ?? 'ABS_1'),
|
||||
(string) ($data['variant'] ?? 'default'),
|
||||
$data['incident_date'] ?? null,
|
||||
);
|
||||
|
||||
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(
|
||||
$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(
|
||||
$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(
|
||||
$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;
|
||||
|
||||
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\BadgePdfService;
|
||||
use App\Services\Badges\BadgePrintLogService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Throwable;
|
||||
|
||||
class BadgeController extends Controller
|
||||
@@ -18,27 +17,60 @@ class BadgeController extends Controller
|
||||
protected BadgePdfService $badgePdfService,
|
||||
protected BadgePrintLogService $badgePrintLogService,
|
||||
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(
|
||||
userIds: $request->input('user_ids', []),
|
||||
schoolYear: $request->input('school_year'),
|
||||
rolesMap: $request->input('roles', []),
|
||||
classesMap: $request->input('classes', []),
|
||||
actorId: optional($request->user())->id
|
||||
studentIds: $data['student_ids'] ?? [],
|
||||
userIds: $data['user_ids'] ?? [],
|
||||
schoolYear: $data['school_year'] ?? null,
|
||||
rolesMap: $data['roles'] ?? [],
|
||||
classesMap: $data['classes'] ?? [],
|
||||
actorId: $guard
|
||||
);
|
||||
}
|
||||
|
||||
public function printStatus(BadgePrintStatusRequest $request): JsonResponse
|
||||
public function printStatus(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->badgePrintLogService->getStatus(
|
||||
$request->normalizedUserIds(),
|
||||
$this->parseCombinedBadgeIds($request),
|
||||
$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 {
|
||||
$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(
|
||||
userIds: $request->input('user_ids', []),
|
||||
actorId: optional($request->user())->id,
|
||||
schoolYear: $request->input('school_year'),
|
||||
rolesMap: $request->input('roles', []),
|
||||
classesMap: $request->input('classes', [])
|
||||
userIds: array_values(array_unique(array_merge(
|
||||
$data['user_ids'] ?? [],
|
||||
$data['student_ids'] ?? []
|
||||
))),
|
||||
actorId: $guard,
|
||||
schoolYear: $data['school_year'] ?? null,
|
||||
rolesMap: $data['roles'] ?? [],
|
||||
classesMap: $data['classes'] ?? []
|
||||
);
|
||||
|
||||
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([
|
||||
'ok' => true,
|
||||
'data' => $this->badgeFormDataService->build(
|
||||
schoolYear: $request->input('school_year'),
|
||||
selectedUserIds: $request->normalizedUserIds(),
|
||||
selectedUserIds: $this->parseStaffUserIds($request),
|
||||
selectedStudentIds: $this->parseStudentIds($request),
|
||||
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;
|
||||
}
|
||||
}
|
||||
Executable → Regular
+2
-275
@@ -2,279 +2,6 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
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;
|
||||
use App\Http\Controllers\Api\Core\BaseApiController as CoreBaseApiController;
|
||||
|
||||
class BaseApiController extends Controller
|
||||
{
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
class BaseApiController extends CoreBaseApiController {}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user