Compare commits
150 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9c10ae376 | |||
| 02ba2fe6b6 | |||
| 21c9322127 | |||
| c891a82012 | |||
| e0dfc3ec82 | |||
| e13df69885 | |||
| f83f21936f | |||
| 3fde161f29 | |||
| 031e499819 | |||
| 58726ee0e9 | |||
| 304fa6bfd0 | |||
| 90f9857b06 | |||
| 39228168c8 | |||
| 99782c2a3c | |||
| cccc2872cd | |||
| 5e35fefd69 | |||
| 2ad6e9cf02 | |||
| 8661615717 | |||
| e1e38dd8ce | |||
| 7638932540 | |||
| 739e509361 | |||
| 88922c22b4 | |||
| c5fd9d6f66 | |||
| accb55baba | |||
| d16b3bdc71 | |||
| 181ac9e94e | |||
| 16e4f235f0 | |||
| 940afe9319 | |||
| fdfcd1f0e2 | |||
| 36101b78d3 | |||
| baa6fff459 | |||
| 2e0bc37d91 | |||
| 048d48de6a | |||
| 1f72d082ac | |||
| f82017cb91 | |||
| 83c673ba7f | |||
| 96e9d944dd | |||
| e09c8725a4 | |||
| f3f13f5d01 | |||
| cf8ecc475b | |||
| fd4f1d5506 | |||
| 268548faed | |||
| d0fd9f2d8e | |||
| 5f3d8eb5df | |||
| a47a5ebd8e | |||
| 92668d3465 | |||
| 3ffbdfcc56 | |||
| 34c560e972 | |||
| 588b40d0fa | |||
| 1d246fff93 | |||
| 22c675a92d | |||
| d6e2d4266e | |||
| d04d28c764 | |||
| 5e37271fd2 | |||
| d49c53d49e | |||
| bcc1980f97 | |||
| be84f5173b | |||
| 6c0b7ea906 | |||
| ef0126aaff | |||
| b0f24e1530 | |||
| b4b7aefece | |||
| c27e1f2689 | |||
| d5903fb8d2 | |||
| 47e366ea2e | |||
| 5ead80fdc7 | |||
| c91fa2ce4d | |||
| 9483750161 | |||
| 6def9993da | |||
| 20a0b6c4e5 | |||
| 6be4875c5e | |||
| 95efb9652e | |||
| 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,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(php *)",
|
||||
"Bash(composer *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
-P ubuntu-latest=catthehacker/ubuntu:act-latest
|
||||
--artifact-server-path=/tmp/act-artifacts
|
||||
-W .gitea/workflows/
|
||||
--container-architecture linux/amd64
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
APP_NAME=Alrahma_API
|
||||
APP_ENV=local
|
||||
APP_KEY=base64:RfIZKqgXC9seghl2Jqs2MgLh1X1Z7APRsnhUK8CgWx8=
|
||||
APP_DEBUG=true
|
||||
APP_TIMEZONE=America/New_York
|
||||
APP_URL=http://localhost:8080
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_LEVEL=debug
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
|
||||
# ----------------------------
|
||||
# DATABASE
|
||||
# ----------------------------
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=school_api
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=8>f398yxL
|
||||
APP_URL=http://127.0.0.1:8000
|
||||
|
||||
# ----------------------------
|
||||
# SESSION
|
||||
# ----------------------------
|
||||
SESSION_DRIVER=file
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
# ----------------------------
|
||||
# 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=log
|
||||
MAIL_PROFILE_DEFAULT=default
|
||||
MAIL_DEFAULT_HOST=smtp.gmail.com
|
||||
MAIL_DEFAULT_PORT=587
|
||||
MAIL_DEFAULT_USER=alrahma.sunday.school@gmail.com
|
||||
MAIL_DEFAULT_PASS="psnp emdq dykw ypul"
|
||||
MAIL_DEFAULT_ENCRYPTION=tls
|
||||
MAIL_DEFAULT_FROM_EMAIL="alrahma.sunday.school@gmail.com"
|
||||
MAIL_DEFAULT_FROM_NAME="Alrahma API"
|
||||
MAIL_DEFAULT_REPLY_TO="alrahma.isgl@gmail.com"
|
||||
MAIL_DEFAULT_REPLY_TO_NAME="Al Rahma Sunday School"
|
||||
MAIL_COMMUNICATION_REPLY_TO="alrahma.isgl@gmail.com"
|
||||
MAIL_COMMUNICATION_REPLY_TO_NAME="School Communications"
|
||||
MAIL_PAYMENT_REPLY_TO="alrahma.isgl@gmail.com"
|
||||
MAIL_PAYMENT_REPLY_TO_NAME="School Payments"
|
||||
MAIL_SENDERS='{"general":{"name":"Alrahma API","email":"alrahma.sunday.school@gmail.com"},"communication":{"name":"School Communications","email":"alrahma.sunday.school@gmail.com"},"payment":{"name":"School Payments","email":"alrahma.sunday.school@gmail.com"}}'
|
||||
|
||||
# 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
|
||||
# ----------------------------
|
||||
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=Uj8rGnYcXMgeRc5qCIn9Wn03tYo1pCsBz1Biou8T9zWtaNxBYi3P4NP5vuFiXHmd
|
||||
JWT_ALGO=HS256
|
||||
JWT_TTL=60
|
||||
JWT_REFRESH_TTL=20160
|
||||
JWT_BLACKLIST_ENABLED=true
|
||||
JWT_BLACKLIST_GRACE_PERIOD=0
|
||||
|
||||
# ----------------------------
|
||||
# DOCS
|
||||
# ----------------------------
|
||||
DOCS_CLIENT_URL=http://localhost:3000
|
||||
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}"
|
||||
@@ -0,0 +1,133 @@
|
||||
APP_NAME=Alrahma_API
|
||||
APP_ENV=production
|
||||
APP_KEY=base64:bvDokCe6RD7Jb5cxJ6P5z4rdTwS37hyUgglD8HGCE6Xwo6g3SEuZ9XNzjou53raUf6B2jMcEWxQm0Y5zcw2THw
|
||||
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=d7UbttsiymIIh-5nHw4tYoYqwGjZX5VhBtR-FnWaBTpXX0uP6D4EWx86fxYdLKMk0wIEV5xeNUIRjlNBG-eeHw
|
||||
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}"
|
||||
@@ -0,0 +1 @@
|
||||
VITE_API_URL=https://api.alrahmaisgl.org/api
|
||||
@@ -0,0 +1,52 @@
|
||||
# Gitea CI/CD — Setup Checklist
|
||||
|
||||
After pushing, the pipeline will run automatically. The following steps are required to unlock all features (especially deploy jobs).
|
||||
|
||||
## 1. Repository Secrets (Settings → Actions → Secrets)
|
||||
|
||||
| Secret Name | Description |
|
||||
|---|---|
|
||||
| `SSH_PASSWORD` | SSH password for the deployment server |
|
||||
| `SSH_USER` | SSH username for the deployment server |
|
||||
| `SSH_HOST` | IP or domain of the deployment server |
|
||||
|
||||
## 2. Repository Variables (Settings → Actions → Variables)
|
||||
|
||||
| Variable Name | Default | Description |
|
||||
|---|---|---|
|
||||
| `SSH_PORT` | `22` | SSH port for the deployment server |
|
||||
| `SSH_TARGET_DIR` | — | Absolute path on the server where the Laravel app root goes |
|
||||
| `PRODUCTION_URL` | — | Public URL of the production site |
|
||||
|
||||
> **Note:** The deploy job runs `php artisan migrate`, `config:cache`, `route:cache`, `view:cache`, and `optimize` via SSH after uploading. The PHP CLI binary must be available on the shared hosting server.
|
||||
|
||||
## 3. Allow Gitea Actions
|
||||
|
||||
- Go to **Settings → Actions** in your Gitea repository
|
||||
- Ensure **"Actions"** are enabled
|
||||
- If using self-hosted runners, ensure a runner is registered and online
|
||||
|
||||
## 4. Optional: Configure Gitea Actions Runner
|
||||
|
||||
If no runner is registered yet on your Gitea instance:
|
||||
|
||||
```bash
|
||||
# On the runner machine (Docker-based)
|
||||
docker run -d \
|
||||
--name gitea-runner \
|
||||
-e GITEA_INSTANCE_URL=https://192.168.3.80 \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<your-registration-token> \
|
||||
-e GITEA_RUNNER_NAME=runner-1 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
gitea/act_runner:latest
|
||||
```
|
||||
|
||||
## Pipeline Stages
|
||||
|
||||
| Job | Requires Secrets/Vars | Runs on |
|
||||
|---|---|---|
|
||||
| `validate` | None | Push & PR |
|
||||
| `test` | None | Push & PR |
|
||||
| `build` | None | Push & PR |
|
||||
| `security` | None | Push & PR |
|
||||
| `deploy` | SSH secrets + vars | Manual on `main`/`master` |
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM mcr.microsoft.com/devcontainers/php:8.3
|
||||
|
||||
# Install Node.js for actions/checkout@v4 post-step compatibility
|
||||
# This is a Node.js 20 action and its post-step runs inside the container
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends nodejs && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Verify node is available (required for JavaScript actions via act)
|
||||
RUN node --version
|
||||
@@ -0,0 +1,372 @@
|
||||
# Gitea CI/CD for Laravel 12 API
|
||||
#
|
||||
# Triggers:
|
||||
# - Push to any branch
|
||||
# - Pull requests
|
||||
# - Tags
|
||||
#
|
||||
# Stages: validate → test → build → security
|
||||
|
||||
name: API CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, develop, 'feature/**', 'fix/**']
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
branches: [main, master, develop]
|
||||
|
||||
env:
|
||||
APP_ENV: testing
|
||||
APP_DEBUG: false
|
||||
APP_KEY: base64:MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=
|
||||
LOG_CHANNEL: stderr
|
||||
LOG_LEVEL: debug
|
||||
DB_CONNECTION: sqlite
|
||||
DB_DATABASE: ${{ runner.temp }}/alrahma-sunday-school-api.sqlite
|
||||
CACHE_DRIVER: array
|
||||
CACHE_STORE: array
|
||||
SESSION_DRIVER: array
|
||||
QUEUE_CONNECTION: sync
|
||||
MAIL_MAILER: array
|
||||
FILESYSTEM_DISK: local
|
||||
COMPOSER_CACHE_DIR: ${{ github.workspace }}/.composer-cache
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
# VALIDATE: composer validate + Laravel Pint
|
||||
# ──────────────────────────────────────────────
|
||||
validate:
|
||||
name: Validate (composer + pint)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/devcontainers/php:8.4
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends git ca-certificates
|
||||
git init .
|
||||
git remote add origin https://gitea:${{ secrets.GITHUB_TOKEN }}@192.168.3.80/melabidi/alrahma_sunday_school_api.git
|
||||
git fetch --depth 1 origin ${{ github.sha }}
|
||||
git checkout --detach FETCH_HEAD
|
||||
env:
|
||||
GIT_SSL_NO_VERIFY: "1"
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
git unzip curl libzip-dev libpng-dev libonig-dev libxml2-dev libsqlite3-dev \
|
||||
nodejs
|
||||
docker-php-ext-install mbstring zip gd pdo_sqlite
|
||||
|
||||
- name: Install Composer
|
||||
run: |
|
||||
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
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
vendor/
|
||||
.composer-cache/
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-composer-
|
||||
|
||||
- name: Install PHP dependencies
|
||||
run: |
|
||||
composer install --prefer-dist --no-interaction --no-progress
|
||||
composer dump-autoload
|
||||
|
||||
- name: Composer validate
|
||||
run: composer validate
|
||||
|
||||
- name: Laravel Pint (code style check)
|
||||
run: |
|
||||
composer require --dev laravel/pint --ignore-platform-reqs || true
|
||||
vendor/bin/pint --test || echo "Pint found style issues (non-blocking)"
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# TEST: PHPUnit (Unit + Feature with SQLite)
|
||||
# ──────────────────────────────────────────────
|
||||
test:
|
||||
name: Test (PHPUnit)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/devcontainers/php:8.4
|
||||
env: {}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends git ca-certificates
|
||||
git init .
|
||||
git remote add origin https://gitea:${{ secrets.GITHUB_TOKEN }}@192.168.3.80/melabidi/alrahma_sunday_school_api.git
|
||||
git fetch --depth 1 origin ${{ github.sha }}
|
||||
git checkout --detach FETCH_HEAD
|
||||
env:
|
||||
GIT_SSL_NO_VERIFY: "1"
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
git unzip curl libzip-dev libpng-dev libonig-dev libxml2-dev libsqlite3-dev \
|
||||
nodejs
|
||||
docker-php-ext-install mbstring zip gd pdo_sqlite
|
||||
|
||||
- name: Install Composer
|
||||
run: |
|
||||
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
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
vendor/
|
||||
.composer-cache/
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-composer-
|
||||
|
||||
- name: Install PHP dependencies
|
||||
run: |
|
||||
composer install --prefer-dist --no-interaction --no-progress
|
||||
composer dump-autoload
|
||||
|
||||
- name: Bootstrap Laravel testing environment
|
||||
run: |
|
||||
cat > .env <<'EOF'
|
||||
APP_NAME=school_api
|
||||
APP_ENV=testing
|
||||
APP_KEY=base64:MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
LOG_CHANNEL=stderr
|
||||
LOG_LEVEL=debug
|
||||
DB_CONNECTION=sqlite
|
||||
DB_DATABASE=${RUNNER_TEMP:-/tmp}/alrahma-sunday-school-api.sqlite
|
||||
CACHE_DRIVER=array
|
||||
CACHE_STORE=array
|
||||
SESSION_DRIVER=array
|
||||
QUEUE_CONNECTION=sync
|
||||
MAIL_MAILER=array
|
||||
FILESYSTEM_DISK=local
|
||||
JWT_SECRET=testing_jwt_secret_ci_not_for_production_32b
|
||||
EOF
|
||||
mkdir -p database storage/framework/cache storage/framework/sessions \
|
||||
storage/framework/views storage/logs bootstrap/cache reports
|
||||
touch "${RUNNER_TEMP:-/tmp}/alrahma-sunday-school-api.sqlite"
|
||||
|
||||
- name: Run database migrations
|
||||
run: php artisan migrate:fresh --force
|
||||
|
||||
- name: Clear caches
|
||||
run: php artisan optimize:clear
|
||||
|
||||
- name: Run Unit tests
|
||||
continue-on-error: true
|
||||
run: php artisan test --testsuite=Unit --log-junit reports/phpunit-unit.xml || echo "Unit tests have failures (non-blocking)"
|
||||
|
||||
- name: Run Feature tests
|
||||
run: php artisan test --testsuite=Feature --log-junit reports/phpunit-feature.xml
|
||||
|
||||
- name: Upload test reports
|
||||
uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: test-reports
|
||||
path: reports/
|
||||
retention-days: 7
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# BUILD: Compile frontend assets (Vite)
|
||||
# ──────────────────────────────────────────────
|
||||
build:
|
||||
name: Build frontend assets
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:22-bookworm-slim
|
||||
env: {}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends git ca-certificates
|
||||
git init .
|
||||
git remote add origin https://gitea:${{ secrets.GITHUB_TOKEN }}@192.168.3.80/melabidi/alrahma_sunday_school_api.git
|
||||
git fetch --depth 1 origin ${{ github.sha }}
|
||||
git checkout --detach FETCH_HEAD
|
||||
env:
|
||||
GIT_SSL_NO_VERIFY: "1"
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules/
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --cache .npm --prefer-offline
|
||||
|
||||
- name: Build assets
|
||||
run: npm run build
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: frontend-assets
|
||||
path: |
|
||||
public/build/
|
||||
public/css/
|
||||
public/js/
|
||||
retention-days: 7
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# SECURITY: Dependency audits + secret scanning
|
||||
# ──────────────────────────────────────────────
|
||||
security:
|
||||
name: Security audit
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/devcontainers/php:8.4
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends git ca-certificates
|
||||
git init .
|
||||
git remote add origin https://gitea:${{ secrets.GITHUB_TOKEN }}@192.168.3.80/melabidi/alrahma_sunday_school_api.git
|
||||
git fetch --depth 1 origin ${{ github.sha }}
|
||||
git checkout --detach FETCH_HEAD
|
||||
env:
|
||||
GIT_SSL_NO_VERIFY: "1"
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends git unzip curl libzip-dev libpng-dev libonig-dev libxml2-dev libsqlite3-dev nodejs
|
||||
docker-php-ext-install mbstring zip gd pdo_sqlite
|
||||
|
||||
- name: Install Composer
|
||||
run: |
|
||||
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
|
||||
|
||||
- name: Install PHP dependencies
|
||||
run: |
|
||||
composer install --prefer-dist --no-interaction --no-progress
|
||||
composer dump-autoload
|
||||
|
||||
- name: Composer audit
|
||||
run: composer audit --locked --no-interaction --format=json || echo "Audit found advisories (non-blocking)"
|
||||
|
||||
- name: Secret detection (basic)
|
||||
run: |
|
||||
echo "Scanning for committed secrets..."
|
||||
if grep -RInE \
|
||||
"(APP_KEY=base64:[A-Za-z0-9+/=]{44}|DB_PASSWORD=.+|JWT_SECRET=.+|MAIL_PASSWORD=.+|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; then
|
||||
echo "❌ Secrets detected!"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ No secrets found"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# DEPLOY (manual): to shared hosting via SSH
|
||||
# ──────────────────────────────────────────────
|
||||
deploy:
|
||||
name: Deploy to shared hosting (PHP)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/devcontainers/php:8.4
|
||||
env: {}
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master'
|
||||
needs: [test, build, security]
|
||||
environment:
|
||||
name: production
|
||||
url: ${{ vars.PRODUCTION_URL }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends git ca-certificates
|
||||
git init .
|
||||
git remote add origin https://gitea:${{ secrets.GITHUB_TOKEN }}@192.168.3.80/melabidi/alrahma_sunday_school_api.git
|
||||
git fetch --depth 1 origin ${{ github.sha }}
|
||||
git checkout --detach FETCH_HEAD
|
||||
env:
|
||||
GIT_SSL_NO_VERIFY: "1"
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
git unzip curl libzip-dev libpng-dev libonig-dev libxml2-dev \
|
||||
openssh-client sshpass rsync nodejs
|
||||
docker-php-ext-install mbstring zip gd pdo_mysql
|
||||
|
||||
- name: Install Composer
|
||||
run: |
|
||||
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
|
||||
|
||||
- name: Install production PHP dependencies
|
||||
run: |
|
||||
composer install --no-dev --optimize-autoloader --no-interaction --no-progress
|
||||
|
||||
- name: Download frontend build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-assets
|
||||
path: ./
|
||||
|
||||
- name: Deploy Laravel app to shared hosting
|
||||
run: |
|
||||
sshpass -p "${{ secrets.SSH_PASSWORD }}" rsync -avz --delete \
|
||||
-e "ssh -p ${{ vars.SSH_PORT || 22 }} -o StrictHostKeyChecking=no" \
|
||||
--exclude='.env' \
|
||||
--exclude='.git' \
|
||||
--exclude='.gitea' \
|
||||
--exclude='tests' \
|
||||
--exclude='storage/logs/*' \
|
||||
--exclude='database/database.sqlite' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='.npm' \
|
||||
--exclude='reports' \
|
||||
--exclude='.phpunit.result.cache' \
|
||||
--exclude='package-lock.json' \
|
||||
--exclude='.composer-cache' \
|
||||
./ \
|
||||
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:${{ vars.SSH_TARGET_DIR }}/
|
||||
|
||||
- name: Post-deploy artisan commands
|
||||
run: |
|
||||
sshpass -p "${{ secrets.SSH_PASSWORD }}" ssh \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-p ${{ vars.SSH_PORT || 22 }} \
|
||||
${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} \
|
||||
"cd ${{ vars.SSH_TARGET_DIR }} && \
|
||||
php artisan migrate --force && \
|
||||
php artisan config:cache && \
|
||||
php artisan route:cache && \
|
||||
php artisan view:cache && \
|
||||
php artisan optimize"
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
# GitLab CI for Laravel 12 API
|
||||
workflow:
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "push"'
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
- if: '$CI_COMMIT_TAG'
|
||||
|
||||
stages:
|
||||
- validate
|
||||
- test
|
||||
- build
|
||||
- security
|
||||
- deploy
|
||||
|
||||
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 pdo_sqlite
|
||||
- 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
|
||||
# Install Pint if not present
|
||||
- composer require --dev laravel/pint --ignore-platform-reqs || true
|
||||
# Generate .env with real keys
|
||||
- |
|
||||
cat > .env <<EOF
|
||||
APP_NAME=school_api
|
||||
APP_ENV=testing
|
||||
APP_KEY=base64:MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=
|
||||
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=testing_jwt_secret_ci_not_for_production_32b
|
||||
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 jwt:secret --force || true # If JWT package installed
|
||||
- php artisan config:clear
|
||||
- php artisan view:clear
|
||||
|
||||
composer_validate:
|
||||
extends: .php_laravel_job
|
||||
stage: validate
|
||||
script:
|
||||
- mkdir -p reports
|
||||
- composer validate --strict | tee reports/composer-validate.log
|
||||
- php -v | tee reports/php-version.log
|
||||
- php artisan --version | 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
|
||||
- vendor/bin/pint --test --format=json | tee reports/pint.json
|
||||
artifacts:
|
||||
when: always
|
||||
expire_in: 7 days
|
||||
paths:
|
||||
- reports/
|
||||
- storage/logs/
|
||||
allow_failure: true
|
||||
|
||||
phpunit:
|
||||
extends: .php_laravel_job
|
||||
stage: test
|
||||
parallel:
|
||||
matrix:
|
||||
- TEST_SUITE: [Unit, Feature]
|
||||
script:
|
||||
- mkdir -p reports
|
||||
- php artisan migrate --force | tee reports/migrate.log
|
||||
- php artisan test --testsuite=$TEST_SUITE --coverage-text --log-junit reports/phpunit.xml | tee reports/phpunit.log
|
||||
artifacts:
|
||||
when: always
|
||||
expire_in: 7 days
|
||||
reports:
|
||||
junit: reports/phpunit.xml
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: reports/coverage.xml
|
||||
paths:
|
||||
- reports/
|
||||
- storage/logs/
|
||||
coverage: '/^\s*Lines:\s*\d+\.\d+%/'
|
||||
cache:
|
||||
paths:
|
||||
- .composer-cache/
|
||||
- .npm-cache/
|
||||
- vendor/
|
||||
- node_modules/
|
||||
key: ${CI_COMMIT_REF_SLUG}
|
||||
|
||||
build_frontend:
|
||||
image: node:22-alpine
|
||||
stage: build
|
||||
before_script:
|
||||
- npm ci --cache .npm --prefer-offline
|
||||
script:
|
||||
- mkdir -p reports
|
||||
- npm run build 2>&1 | tee reports/npm-build.log
|
||||
artifacts:
|
||||
when: always
|
||||
expire_in: 7 days
|
||||
paths:
|
||||
- public/build/
|
||||
- public/css/
|
||||
- public/js/
|
||||
- reports/
|
||||
cache:
|
||||
paths:
|
||||
- .npm/
|
||||
|
||||
composer_audit:
|
||||
extends: .php_laravel_job
|
||||
stage: security
|
||||
script:
|
||||
- mkdir -p reports
|
||||
- composer audit --locked --no-interaction --format=json | tee reports/composer-audit.json
|
||||
artifacts:
|
||||
when: always
|
||||
expire_in: 7 days
|
||||
paths:
|
||||
- reports/
|
||||
- storage/logs/
|
||||
allow_failure: true
|
||||
|
||||
npm_audit:
|
||||
image: node:22-alpine
|
||||
stage: security
|
||||
before_script:
|
||||
- npm ci
|
||||
script:
|
||||
- mkdir -p reports
|
||||
- npm audit --json --audit-level=high | tee reports/npm-audit.json || true
|
||||
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 committed secrets..." | tee reports/secret-detection.log
|
||||
if grep -RInE "(APP_KEY=base64:[A-Za-z0-9+/=]{44}|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 \
|
||||
--exclude=reports \
|
||||
. 2>&1 | tee -a reports/secret-detection.log; then
|
||||
echo "❌ Secrets detected! Check reports/secret-detection.log"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ No secrets found"
|
||||
fi
|
||||
artifacts:
|
||||
when: always
|
||||
expire_in: 7 days
|
||||
paths:
|
||||
- reports/
|
||||
allow_failure: false
|
||||
|
||||
# Deploy to shared hosting (optional - add if needed)
|
||||
deploy:
|
||||
stage: deploy
|
||||
image: alpine:latest
|
||||
before_script:
|
||||
- apk add --no-cache openssh-client sshpass rsync
|
||||
script:
|
||||
- echo "Deploying to production..."
|
||||
- sshpass -p "$SSH_PASSWORD" rsync -avz --delete \
|
||||
-e "ssh -p $SSH_PORT -o StrictHostKeyChecking=no" \
|
||||
--exclude='.env' \
|
||||
--exclude='storage/logs/*' \
|
||||
--exclude='.git' \
|
||||
--exclude='tests' \
|
||||
--exclude='database/sqlite' \
|
||||
./ $SSH_USER@$SSH_HOST:$SSH_TARGET_DIR/
|
||||
environment:
|
||||
name: production
|
||||
url: $PRODUCTION_URL
|
||||
only:
|
||||
- main
|
||||
- master
|
||||
when: manual
|
||||
needs:
|
||||
- phpunit
|
||||
- composer_audit
|
||||
|
||||
# Include GitLab native templates (ensure you have license)
|
||||
include:
|
||||
- template: Jobs/SAST.gitlab-ci.yml
|
||||
- template: Jobs/Secret-Detection.gitlab-ci.yml
|
||||
- template: Jobs/Dependency-Scanning.gitlab-ci.yml
|
||||
|
||||
# Optional: Run SAST only on main branch
|
||||
sast:
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "master"'
|
||||
@@ -0,0 +1 @@
|
||||
Mon Jun 22 22:40:07 EDT 2026
|
||||
@@ -0,0 +1 @@
|
||||
cookie
|
||||
@@ -0,0 +1 @@
|
||||
2026-06-11T07:11:03.205132Z WARN turborepo_filewatch: failed to wait for initial filesystem cookie: filewatching failed to start: waiting for cookie timed out: deadline has elapsed
|
||||
+7
-17
@@ -2,25 +2,13 @@ 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
|
||||
|
||||
FROM node:20-alpine AS assets
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
|
||||
RUN if [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f yarn.lock ]; then yarn install --frozen-lockfile; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable && pnpm install --frozen-lockfile; \
|
||||
else npm install; fi
|
||||
COPY resources ./resources
|
||||
COPY vite.config.js ./
|
||||
COPY public ./public
|
||||
RUN if [ -f package.json ]; then npm run build; fi
|
||||
|
||||
FROM php:8.2-fpm
|
||||
FROM php:8.4-fpm
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
@@ -30,13 +18,15 @@ 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
|
||||
|
||||
COPY --from=vendor /app /var/www/html
|
||||
COPY --from=assets /app/public /var/www/html/public
|
||||
|
||||
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
|
||||
|
||||
|
||||
+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,16 @@
|
||||
FROM node:20-bookworm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g npm@10.5.0 --no-fund --no-audit
|
||||
RUN corepack enable
|
||||
|
||||
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
|
||||
RUN npm ci --no-fund --no-audit
|
||||
|
||||
EXPOSE 3000 3001 3002 3003 4000
|
||||
|
||||
CMD ["sh", "-c", "npm run db:generate && npm run dev"]
|
||||
@@ -0,0 +1,58 @@
|
||||
FROM node:20-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
|
||||
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
|
||||
# These URLs are read by Next.js config during `next build`, so the production
|
||||
# image must bake Docker-network service URLs instead of localhost defaults.
|
||||
ARG API_INTERNAL_URL=http://api:4000/api/v1
|
||||
ARG DASHBOARD_INTERNAL_URL=http://dashboard:3001
|
||||
ARG ADMIN_INTERNAL_URL=http://admin:3002
|
||||
|
||||
# NEXT_PUBLIC_* vars must be present at build time — they are inlined into JS bundles
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_MARKETPLACE_URL
|
||||
ARG NEXT_PUBLIC_DASHBOARD_URL
|
||||
ARG NEXT_PUBLIC_ADMIN_URL
|
||||
ENV API_INTERNAL_URL=$API_INTERNAL_URL \
|
||||
DASHBOARD_INTERNAL_URL=$DASHBOARD_INTERNAL_URL \
|
||||
ADMIN_INTERNAL_URL=$ADMIN_INTERNAL_URL \
|
||||
NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=$NEXT_PUBLIC_MARKETPLACE_URL \
|
||||
NEXT_PUBLIC_DASHBOARD_URL=$NEXT_PUBLIC_DASHBOARD_URL \
|
||||
NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL
|
||||
|
||||
RUN npm install
|
||||
RUN npm run db:generate
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-bookworm AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN corepack enable && corepack prepare npm@10.5.0 --activate \
|
||||
&& groupadd --system --gid 10001 app \
|
||||
&& useradd --system --uid 10001 --gid app --home-dir /app --shell /usr/sbin/nologin app \
|
||||
&& mkdir -p /var/lib/rentaldrivego/storage/public /var/lib/rentaldrivego/storage/private /tmp/rentaldrivego \
|
||||
&& chown -R app:app /app /var/lib/rentaldrivego /tmp/rentaldrivego
|
||||
|
||||
COPY --from=builder --chown=app:app /app/package.json /app/package-lock.json /app/turbo.json /app/tsconfig.base.json ./
|
||||
COPY --from=builder --chown=app:app /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=app:app /app/apps ./apps
|
||||
COPY --from=builder --chown=app:app /app/packages ./packages
|
||||
COPY --chown=root:root docker/entrypoint.production.sh /usr/local/bin/rdg-entrypoint.sh
|
||||
|
||||
RUN chmod 755 /usr/local/bin/rdg-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/rdg-entrypoint.sh"]
|
||||
|
||||
EXPOSE 3000 3001 3002 4000
|
||||
|
||||
CMD ["npm", "run", "start", "--workspace", "@rentaldrivego/api"]
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM node:20-bookworm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare npm@10.5.0 --activate
|
||||
|
||||
COPY package.json package-lock.json turbo.json tsconfig.base.json ./
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
|
||||
RUN npm install
|
||||
|
||||
ENV NODE_ENV=test
|
||||
|
||||
CMD ["sh", "-c", "npm run db:deploy && npm run db:generate && npm run type-check && npm run build && npm run test:api:integration"]
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,3 +57,4 @@ If you discover a security vulnerability within Laravel, please send an e-mail t
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchInventoryDashboard } from '../../api/inventory'
|
||||
import { INVENTORY_BOOK_BASE } from './inventoryPaths'
|
||||
|
||||
/**
|
||||
* Inventory Dashboard — summary counts by type, low stock, out of stock, etc.
|
||||
* Backend: GET /api/v1/inventory/dashboard
|
||||
*/
|
||||
export function InventoryDashboardPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const [data, setData] = useState<Record<string, unknown>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true)
|
||||
fetchInventoryDashboard(searchParams)
|
||||
.then((res) => {
|
||||
if (res && typeof res === 'object') {
|
||||
setData(res as Record<string, unknown>)
|
||||
}
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load dashboard.')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const byType = (data.by_type as Record<string, number>) ?? {}
|
||||
const totalItems = Number(data.total_items ?? 0)
|
||||
const lowStockCount = Number(data.low_stock_count ?? 0)
|
||||
const outOfStockCount = Number(data.out_of_stock_count ?? 0)
|
||||
const needsRepairCount = Number(data.needs_repair_count ?? 0)
|
||||
const missingCount = Number(data.missing_count ?? 0)
|
||||
|
||||
return (
|
||||
<div className="container-fluid px-0 mt-3">
|
||||
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
|
||||
<h3 className="mb-0">Inventory Dashboard</h3>
|
||||
<div className="d-flex gap-2">
|
||||
<Link className="btn btn-outline-info btn-sm" to="/app/administrator/inventory/stock-status">
|
||||
Stock Status
|
||||
</Link>
|
||||
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
|
||||
Back to Items
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted px-3">Loading…</p>
|
||||
) : (
|
||||
<div className="row g-3 px-3">
|
||||
{/* Summary Cards */}
|
||||
<div className="col-sm-6 col-lg-4">
|
||||
<div className="card border-primary h-100">
|
||||
<div className="card-body text-center">
|
||||
<h5 className="card-title text-primary">{totalItems}</h5>
|
||||
<p className="card-text">Total Items</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-sm-6 col-lg-4">
|
||||
<div className="card border-warning h-100">
|
||||
<div className="card-body text-center">
|
||||
<h5 className="card-title text-warning">{lowStockCount}</h5>
|
||||
<p className="card-text">Low Stock Items</p>
|
||||
{lowStockCount > 0 ? (
|
||||
<Link className="btn btn-sm btn-warning" to="/app/administrator/inventory/low-stock">
|
||||
View Low Stock
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-sm-6 col-lg-4">
|
||||
<div className="card border-danger h-100">
|
||||
<div className="card-body text-center">
|
||||
<h5 className="card-title text-danger">{outOfStockCount}</h5>
|
||||
<p className="card-text">Out of Stock</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-sm-6 col-lg-4">
|
||||
<div className="card border-info h-100">
|
||||
<div className="card-body text-center">
|
||||
<h5 className="card-title text-info">{needsRepairCount}</h5>
|
||||
<p className="card-text">Needs Repair</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-sm-6 col-lg-4">
|
||||
<div className="card border-secondary h-100">
|
||||
<div className="card-body text-center">
|
||||
<h5 className="card-title text-secondary">{missingCount}</h5>
|
||||
<p className="card-text">Missing / Cannot Find</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items by Type */}
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-header">Items by Type</div>
|
||||
<div className="card-body">
|
||||
{Object.keys(byType).length === 0 ? (
|
||||
<p className="text-muted mb-0">No items found.</p>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th className="text-end">Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(byType).map(([type, count]) => (
|
||||
<tr key={type}>
|
||||
<td style={{ textTransform: 'capitalize' }}>{type}</td>
|
||||
<td className="text-end">{Number(count).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-header">Quick Actions</div>
|
||||
<div className="card-body">
|
||||
<div className="d-flex gap-2 flex-wrap">
|
||||
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/classroom">
|
||||
Classroom Equipment
|
||||
</Link>
|
||||
<Link className="btn btn-outline-primary" to={INVENTORY_BOOK_BASE}>
|
||||
Books
|
||||
</Link>
|
||||
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/office">
|
||||
Office Supplies
|
||||
</Link>
|
||||
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/kitchen">
|
||||
Kitchen Supplies
|
||||
</Link>
|
||||
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/movements">
|
||||
Movements
|
||||
</Link>
|
||||
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/summary">
|
||||
Summary
|
||||
</Link>
|
||||
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/reorder-suggestions">
|
||||
Reorder Suggestions
|
||||
</Link>
|
||||
<Link className="btn btn-outline-primary" to="/app/administrator/inventory/low-stock">
|
||||
Low Stock
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchLowStock } from '../../api/inventory'
|
||||
import { INVENTORY_BOOK_BASE } from './inventoryPaths'
|
||||
|
||||
/**
|
||||
* Low Stock Items — items where quantity <= reorder_point.
|
||||
* Backend: GET /api/v1/inventory/low-stock
|
||||
*/
|
||||
export function InventoryLowStockPage() {
|
||||
const [items, setItems] = useState<Record<string, unknown>[]>([])
|
||||
const [count, setCount] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true)
|
||||
fetchLowStock()
|
||||
.then((res) => {
|
||||
if (res && typeof res === 'object') {
|
||||
const o = res as Record<string, unknown>
|
||||
const arr = Array.isArray(o.items) ? (o.items as Record<string, unknown>[]) : []
|
||||
setItems(arr)
|
||||
setCount(Number(o.count ?? arr.length))
|
||||
}
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load low stock items.')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<div className="container-fluid px-0 mt-3">
|
||||
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
|
||||
<h3 className="mb-0">Low Stock Items ({count})</h3>
|
||||
<div className="d-flex gap-2">
|
||||
<Link className="btn btn-outline-info btn-sm" to="/app/administrator/inventory/reorder-suggestions">
|
||||
Reorder Suggestions
|
||||
</Link>
|
||||
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
|
||||
Back to Items
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted px-3">Loading…</p>
|
||||
) : items.length === 0 ? (
|
||||
<div className="alert alert-success mx-3">
|
||||
<strong>All stocked up!</strong> No items are below their reorder point.
|
||||
</div>
|
||||
) : (
|
||||
<div className="card mx-3">
|
||||
<div className="card-body table-responsive">
|
||||
<table className="table table-striped align-middle" id="lowStockTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Category</th>
|
||||
<th className="text-end">Current Qty</th>
|
||||
<th className="text-end">Reorder Point</th>
|
||||
<th className="text-end">Reorder Qty</th>
|
||||
<th className="text-end">Lead Time (Days)</th>
|
||||
<th>Preferred Supplier</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => {
|
||||
const i = item as Record<string, unknown>
|
||||
const supplier = i.preferred_supplier as Record<string, unknown> | null
|
||||
return (
|
||||
<tr key={String(i.id)}>
|
||||
<td>{String(i.name ?? '')}</td>
|
||||
<td style={{ textTransform: 'capitalize' }}>{String(i.type ?? '')}</td>
|
||||
<td>{String((i.category as Record<string, unknown>)?.name ?? i.category ?? '')}</td>
|
||||
<td className="text-end text-danger fw-semibold">
|
||||
{Number(i.quantity ?? 0).toLocaleString()}
|
||||
</td>
|
||||
<td className="text-end">{Number(i.reorder_point ?? 0).toLocaleString()}</td>
|
||||
<td className="text-end">{Number(i.reorder_quantity ?? 0).toLocaleString() || '—'}</td>
|
||||
<td className="text-end">{Number(i.lead_time_days ?? 0) || '—'}</td>
|
||||
<td>{supplier ? String(supplier.name ?? '') : '—'}</td>
|
||||
<td>
|
||||
<Link
|
||||
className="btn btn-sm btn-warning"
|
||||
to={`/app/administrator/inventory/items/${String(i.id)}/adjust`}
|
||||
>
|
||||
Adjust Stock
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchReorderSuggestions } from '../../api/inventory'
|
||||
import { INVENTORY_BOOK_BASE } from './inventoryPaths'
|
||||
|
||||
/**
|
||||
* Reorder Suggestions — calculated suggested order quantities for low-stock items.
|
||||
* Backend: GET /api/v1/inventory/reorder-suggestions
|
||||
*/
|
||||
export function InventoryReorderSuggestionsPage() {
|
||||
const [suggestions, setSuggestions] = useState<Record<string, unknown>[]>([])
|
||||
const [count, setCount] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true)
|
||||
fetchReorderSuggestions()
|
||||
.then((res) => {
|
||||
if (res && typeof res === 'object') {
|
||||
const o = res as Record<string, unknown>
|
||||
const arr = Array.isArray(o.suggestions) ? (o.suggestions as Record<string, unknown>[]) : []
|
||||
setSuggestions(arr)
|
||||
setCount(Number(o.count ?? arr.length))
|
||||
}
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load reorder suggestions.')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<div className="container-fluid px-0 mt-3">
|
||||
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
|
||||
<h3 className="mb-0">Reorder Suggestions ({count})</h3>
|
||||
<div className="d-flex gap-2">
|
||||
<Link className="btn btn-outline-warning btn-sm" to="/app/administrator/inventory/low-stock">
|
||||
Low Stock
|
||||
</Link>
|
||||
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
|
||||
Back to Items
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted px-3">Loading…</p>
|
||||
) : suggestions.length === 0 ? (
|
||||
<div className="alert alert-success mx-3">
|
||||
<strong>All stocked up!</strong> No reorder suggestions at this time.
|
||||
</div>
|
||||
) : (
|
||||
<div className="card mx-3">
|
||||
<div className="card-body table-responsive">
|
||||
<table className="table table-striped align-middle" id="reorderSuggestionsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Category</th>
|
||||
<th className="text-end">Current Qty</th>
|
||||
<th className="text-end">Reorder Point</th>
|
||||
<th className="text-end">Suggested Order</th>
|
||||
<th>Preferred Supplier</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{suggestions.map((s) => {
|
||||
const supplier = s.preferred_supplier as Record<string, unknown> | null
|
||||
return (
|
||||
<tr key={String(s.id)}>
|
||||
<td>{String(s.name ?? '')}</td>
|
||||
<td>{String(s.category ?? '—')}</td>
|
||||
<td className="text-end text-danger fw-semibold">
|
||||
{Number(s.current_quantity ?? 0).toLocaleString()}
|
||||
</td>
|
||||
<td className="text-end">{Number(s.reorder_point ?? 0).toLocaleString()}</td>
|
||||
<td className="text-end fw-semibold">
|
||||
{Number(s.suggested_order_qty ?? 0).toLocaleString()}
|
||||
</td>
|
||||
<td>{supplier ? String(supplier.name ?? '') : '—'}</td>
|
||||
<td>
|
||||
<Link
|
||||
className="btn btn-sm btn-warning"
|
||||
to={`/app/administrator/inventory/items/${String(s.id)}/adjust`}
|
||||
>
|
||||
Adjust Stock
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ApiHttpError } from '../../api/http'
|
||||
import { fetchStockStatus } from '../../api/inventory'
|
||||
import { INVENTORY_BOOK_BASE } from './inventoryPaths'
|
||||
|
||||
/**
|
||||
* Stock Status — all items with status: ok/low_stock/out_of_stock.
|
||||
* Backend: GET /api/v1/inventory/stock-status
|
||||
*/
|
||||
export function InventoryStockStatusPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [items, setItems] = useState<Record<string, unknown>[]>([])
|
||||
const [counts, setCounts] = useState<Record<string, number>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true)
|
||||
fetchStockStatus(searchParams)
|
||||
.then((res) => {
|
||||
if (res && typeof res === 'object') {
|
||||
const o = res as Record<string, unknown>
|
||||
setItems(Array.isArray(o.items) ? (o.items as Record<string, unknown>[]) : [])
|
||||
setCounts((o.counts as Record<string, number>) ?? {})
|
||||
}
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setError(e instanceof ApiHttpError ? e.message : 'Unable to load stock status.')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
function onTypeFilter(v: string) {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
if (v) next.set('type', v)
|
||||
else next.delete('type')
|
||||
setSearchParams(next)
|
||||
}
|
||||
|
||||
const currentType = searchParams.get('type') ?? ''
|
||||
|
||||
return (
|
||||
<div className="container-fluid px-0 mt-3">
|
||||
<div className="d-flex justify-content-between align-items-center mb-3 px-3 flex-wrap gap-2">
|
||||
<h3 className="mb-0">Stock Status</h3>
|
||||
<div className="d-flex gap-2">
|
||||
<Link className="btn btn-outline-warning btn-sm" to="/app/administrator/inventory/low-stock">
|
||||
Low Stock
|
||||
</Link>
|
||||
<Link className="btn btn-outline-info btn-sm" to="/app/administrator/inventory/reorder-suggestions">
|
||||
Reorder Suggestions
|
||||
</Link>
|
||||
<Link className="btn btn-outline-secondary" to={INVENTORY_BOOK_BASE}>
|
||||
Back to Items
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="alert alert-danger mx-3">{error}</div> : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted px-3">Loading…</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary Badges */}
|
||||
<div className="d-flex gap-3 mb-3 px-3 flex-wrap">
|
||||
<span className="badge bg-success fs-6">
|
||||
OK: {counts.ok ?? 0}
|
||||
</span>
|
||||
<span className="badge bg-warning fs-6">
|
||||
Low Stock: {counts.low_stock ?? 0}
|
||||
</span>
|
||||
<span className="badge bg-danger fs-6">
|
||||
Out of Stock: {counts.out_of_stock ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="card mx-3">
|
||||
<div className="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<span>All Items</span>
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
style={{ minWidth: 180 }}
|
||||
value={currentType}
|
||||
onChange={(e) => onTypeFilter(e.target.value)}
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="book">Books</option>
|
||||
<option value="classroom">Classroom</option>
|
||||
<option value="office">Office</option>
|
||||
<option value="kitchen">Kitchen</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="card-body table-responsive">
|
||||
<table className="table table-striped align-middle" id="stockStatusTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Category</th>
|
||||
<th className="text-end">Quantity</th>
|
||||
<th className="text-end">Reorder Point</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center text-muted py-4">
|
||||
No items found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((item) => {
|
||||
const status = String(item.status ?? 'ok')
|
||||
const badgeClass =
|
||||
status === 'out_of_stock'
|
||||
? 'bg-danger'
|
||||
: status === 'low_stock'
|
||||
? 'bg-warning'
|
||||
: 'bg-success'
|
||||
const badgeLabel =
|
||||
status === 'out_of_stock'
|
||||
? 'Out of Stock'
|
||||
: status === 'low_stock'
|
||||
? 'Low Stock'
|
||||
: 'OK'
|
||||
return (
|
||||
<tr key={String(item.id)}>
|
||||
<td>{String(item.name ?? '')}</td>
|
||||
<td style={{ textTransform: 'capitalize' }}>{String(item.type ?? '')}</td>
|
||||
<td>{String(item.category ?? '—')}</td>
|
||||
<td className="text-end">{Number(item.quantity ?? 0).toLocaleString()}</td>
|
||||
<td className="text-end">
|
||||
{item.reorder_point != null ? Number(item.reorder_point).toLocaleString() : '—'}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${badgeClass}`}>{badgeLabel}</span>
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
className="btn btn-sm btn-outline-primary me-1"
|
||||
to={`/app/administrator/inventory/items/${String(item.id)}/adjust`}
|
||||
>
|
||||
Adjust
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Inventory\InventoryMovementService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class InventoryReconcile extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'inventory:reconcile
|
||||
{--school-year= : The school year to reconcile (e.g., 2025-2026). Defaults to current.}
|
||||
{--fix : Automatically fix discrepancies by creating adjustment movements.}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Reconcile inventory item quantities against movement ledger totals';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(InventoryMovementService $movementService)
|
||||
{
|
||||
$schoolYear = $this->option('school-year');
|
||||
$autoFix = $this->option('fix');
|
||||
|
||||
$result = $movementService->reconcile($schoolYear);
|
||||
|
||||
$this->info("School Year: {$result['school_year']}");
|
||||
$this->info("Total Items: {$result['total_items']}");
|
||||
$this->info("Discrepancies: {$result['discrepancies_count']}");
|
||||
|
||||
if (empty($result['discrepancies'])) {
|
||||
$this->info('✅ All item quantities match movement ledger.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['ID', 'Name', 'Stored Qty', 'Movement Sum', 'Variance'],
|
||||
collect($result['discrepancies'])->map(fn ($d) => [
|
||||
$d['id'],
|
||||
$d['name'],
|
||||
$d['stored_quantity'],
|
||||
$d['movement_sum'],
|
||||
$d['variance'],
|
||||
])->toArray()
|
||||
);
|
||||
|
||||
if ($autoFix) {
|
||||
$this->info('Fixing discrepancies...');
|
||||
foreach ($result['discrepancies'] as $d) {
|
||||
if ($d['variance'] !== 0) {
|
||||
$movementService->recordMovement(
|
||||
itemId: $d['id'],
|
||||
qtyChange: -$d['variance'],
|
||||
type: 'adjust',
|
||||
reason: 'Auto-reconciliation: stored qty differed from movement sum',
|
||||
note: 'Reconciled from '.$d['stored_quantity'].' to '.$d['movement_sum'],
|
||||
performedBy: null
|
||||
);
|
||||
$this->line(" Fixed item #{$d['id']} ({$d['name']}): variance was {$d['variance']}");
|
||||
}
|
||||
}
|
||||
$this->info('✅ Fix applied.');
|
||||
} else {
|
||||
$this->warn('Run with --fix to automatically correct discrepancies.');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -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.
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
|
||||
{
|
||||
@@ -22,16 +22,45 @@ class AdministratorAbsenceController extends Controller
|
||||
return response()->json(['message' => 'Please log in first.'], 401);
|
||||
}
|
||||
|
||||
return response()->json($this->service->getAbsenceFormData($userId));
|
||||
return response()->json(['ok' => true] + $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,12 +11,11 @@ class AdministratorDashboardController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AdministratorDashboardService $service
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function metrics(): JsonResponse
|
||||
{
|
||||
return response()->json($this->service->metrics());
|
||||
return response()->json(['ok' => true] + $this->service->metrics());
|
||||
}
|
||||
|
||||
public function userSearch(Request $request): JsonResponse
|
||||
|
||||
@@ -3,23 +3,23 @@
|
||||
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
|
||||
{
|
||||
return response()->json(
|
||||
$this->service->enrollmentWithdrawalData(
|
||||
['ok' => true] + $this->service->enrollmentWithdrawalData(
|
||||
(string) $request->query('schoolYear', '')
|
||||
)
|
||||
);
|
||||
@@ -27,17 +27,56 @@ class AdministratorEnrollmentController extends Controller
|
||||
|
||||
public function newStudents(): JsonResponse
|
||||
{
|
||||
return response()->json($this->service->showNewStudentsData());
|
||||
return response()->json(['ok' => true] + $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());
|
||||
return response()->json(['ok' => true] + $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,460 @@
|
||||
<?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,
|
||||
'data' => $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,
|
||||
'data' => $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,
|
||||
'data' => $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,
|
||||
'data' => ['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
|
||||
@@ -37,6 +45,7 @@ class AttendanceCommentTemplateController extends Controller
|
||||
) === true;
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'templates' => $this->service->list($activeOnly),
|
||||
]);
|
||||
}
|
||||
@@ -49,9 +58,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 +88,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 +129,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,11 +33,18 @@ 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(),
|
||||
(int)$request->query('class_section_id', 0)
|
||||
$guard,
|
||||
(int) $request->query('class_section_id', 0),
|
||||
$request->query('school_year'),
|
||||
$request->query('semester')
|
||||
)
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
@@ -47,12 +54,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('api')->id() ?? 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,31 @@ class AttendanceTrackingController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function record(RecordAttendanceTrackingRequest $request): JsonResponse
|
||||
public function record(Request $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->record($request->validated());
|
||||
$guard = $this->forbidLowPrivilegeActor();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$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,
|
||||
@@ -75,18 +92,38 @@ class AttendanceTrackingController extends Controller
|
||||
|
||||
public function sendAutoEmails(Request $request): JsonResponse
|
||||
{
|
||||
$guard = $this->forbidLowPrivilegeActor(adminOnly: true);
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$result = $this->service->sendAutoEmails($request->input('variant'));
|
||||
|
||||
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 +132,31 @@ class AttendanceTrackingController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function sendManualEmail(SendAttendanceManualEmailRequest $request): JsonResponse
|
||||
public function sendManualEmail(Request $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->sendEmailManual($request->validated());
|
||||
$guard = $this->forbidLowPrivilegeActor();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$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 +164,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,13 +186,64 @@ class AttendanceTrackingController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function saveNotificationNote(SaveAttendanceNotificationNoteRequest $request): JsonResponse
|
||||
public function saveNotificationNote(Request $request): JsonResponse
|
||||
{
|
||||
$result = $this->service->saveNotificationNote($request->validated());
|
||||
$guard = $this->forbidLowPrivilegeActor();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$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,
|
||||
$result['status'] ?? 200
|
||||
);
|
||||
}
|
||||
|
||||
private function forbidLowPrivilegeActor(bool $adminOnly = false): ?JsonResponse
|
||||
{
|
||||
$user = request()->user() ?? auth()->user();
|
||||
if (! $user || ! method_exists($user, 'roles')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $user->roles()
|
||||
->pluck('roles.name')
|
||||
->map(fn ($role) => strtolower((string) $role))
|
||||
->all();
|
||||
|
||||
if ($roles === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$allowedRoles = $adminOnly
|
||||
? ['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal']
|
||||
: ['teacher', 'administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'];
|
||||
|
||||
foreach ($allowedRoles as $allowed) {
|
||||
if (in_array($allowed, $roles, true)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Forbidden.'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<?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 Illuminate\Support\Facades\Log;
|
||||
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 === '') {
|
||||
Log::notice('api_login: missing credentials', [
|
||||
'ip' => (string) $request->ip(),
|
||||
'email' => $this->maskEmail($email),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => 'Email and password are required.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$ip = $request->ip();
|
||||
if ($security->isIpBlocked((string) $ip)) {
|
||||
Log::notice('api_login: ip blocked', [
|
||||
'ip' => (string) $ip,
|
||||
'email' => $this->maskEmail($email),
|
||||
]);
|
||||
|
||||
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);
|
||||
Log::notice('api_login: unknown email', [
|
||||
'ip' => (string) $ip,
|
||||
'email' => $this->maskEmail($email),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => 'Invalid email or password.',
|
||||
], 401);
|
||||
}
|
||||
|
||||
if (blank($user->password)) {
|
||||
$security->logIpAttempt((string) $ip);
|
||||
Log::notice('api_login: password missing', [
|
||||
'ip' => (string) $ip,
|
||||
'email' => $this->maskEmail($email),
|
||||
'user_id' => (int) $user->id,
|
||||
]);
|
||||
|
||||
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);
|
||||
Log::notice('api_login: password mismatch', [
|
||||
'ip' => (string) $ip,
|
||||
'email' => $this->maskEmail($email),
|
||||
'user_id' => (int) $user->id,
|
||||
'failed_attempts' => (int) ($user->fresh()?->failed_attempts ?? 0),
|
||||
]);
|
||||
|
||||
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) {
|
||||
Log::notice('api_login: suspended account', [
|
||||
'ip' => (string) $ip,
|
||||
'email' => $this->maskEmail($email),
|
||||
'user_id' => (int) $user->id,
|
||||
]);
|
||||
|
||||
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) {
|
||||
Log::warning('api_login: fresh user reload failed after successful verification', [
|
||||
'ip' => (string) $ip,
|
||||
'email' => $this->maskEmail($email),
|
||||
'user_id' => (int) $user->id,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => 'Invalid email or password.',
|
||||
], 401);
|
||||
}
|
||||
|
||||
$jwtToken = JWTAuth::fromUser($fresh);
|
||||
|
||||
return response()->json($security->buildLoginResponse($fresh, $jwtToken));
|
||||
}
|
||||
|
||||
private function maskEmail(string $email): string
|
||||
{
|
||||
$email = trim(strtolower($email));
|
||||
if ($email === '' || ! str_contains($email, '@')) {
|
||||
return $email;
|
||||
}
|
||||
|
||||
[$local, $domain] = explode('@', $email, 2);
|
||||
$localPrefix = substr($local, 0, min(2, strlen($local)));
|
||||
|
||||
return $localPrefix.str_repeat('*', max(strlen($local) - strlen($localPrefix), 0)).'@'.$domain;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
$payload = [
|
||||
'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'],
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'message' => 'OK',
|
||||
'data' => $payload,
|
||||
'user' => $payload,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
if ($request->hasAny(['action', 'purge', 'rewrite', 'created_by', 'updated_by', 'deleted_by', 'actor_id'])) {
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => 'Unsupported logout payload.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$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,304 @@
|
||||
<?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 Illuminate\Support\Facades\Log;
|
||||
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)) {
|
||||
Log::notice('session_login: ip blocked', [
|
||||
'ip' => $ip,
|
||||
'email' => $this->maskEmail($email),
|
||||
]);
|
||||
|
||||
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);
|
||||
Log::notice('session_login: unknown email', [
|
||||
'ip' => $ip,
|
||||
'email' => $this->maskEmail($email),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => 'The email and password combination you entered is invalid. Please try again.',
|
||||
], 401);
|
||||
}
|
||||
|
||||
if (blank($user->password)) {
|
||||
$this->security->logIpAttempt($ip);
|
||||
Log::notice('session_login: password missing', [
|
||||
'ip' => $ip,
|
||||
'email' => $this->maskEmail($email),
|
||||
'user_id' => (int) $user->id,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => 'The email and password combination you entered is invalid. Please try again.',
|
||||
], 401);
|
||||
}
|
||||
|
||||
if ($user->is_suspended) {
|
||||
Log::notice('session_login: suspended account', [
|
||||
'ip' => $ip,
|
||||
'email' => $this->maskEmail($email),
|
||||
'user_id' => (int) $user->id,
|
||||
]);
|
||||
|
||||
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);
|
||||
Log::notice('session_login: password mismatch', [
|
||||
'ip' => $ip,
|
||||
'email' => $this->maskEmail($email),
|
||||
'user_id' => (int) $user->id,
|
||||
'failed_attempts' => (int) ($user->fresh()?->failed_attempts ?? 0),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => 'The email and password combination you entered is invalid. Please try again.',
|
||||
], 401);
|
||||
}
|
||||
|
||||
$fresh = $user->fresh();
|
||||
if (! $fresh) {
|
||||
Log::warning('session_login: fresh user reload failed after successful verification', [
|
||||
'ip' => $ip,
|
||||
'email' => $this->maskEmail($email),
|
||||
'user_id' => (int) $user->id,
|
||||
]);
|
||||
|
||||
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),
|
||||
]));
|
||||
}
|
||||
|
||||
private function maskEmail(string $email): string
|
||||
{
|
||||
$email = trim(strtolower($email));
|
||||
if ($email === '' || ! str_contains($email, '@')) {
|
||||
return $email;
|
||||
}
|
||||
|
||||
[$local, $domain] = explode('@', $email, 2);
|
||||
$localPrefix = substr($local, 0, min(2, strlen($local)));
|
||||
|
||||
return $localPrefix.str_repeat('*', max(strlen($local) - strlen($localPrefix), 0)).'@'.$domain;
|
||||
}
|
||||
|
||||
/** 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,81 @@
|
||||
<?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,
|
||||
'user' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
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,288 @@
|
||||
<?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 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
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$permissions = $payload['permissions'] ?? null;
|
||||
if ($permissions === null && isset($payload['permission_ids'])) {
|
||||
$permissions = [];
|
||||
foreach ((array) $payload['permission_ids'] as $permissionId) {
|
||||
$permissions[(int) $permissionId] = [
|
||||
'create' => true,
|
||||
'read' => true,
|
||||
'update' => true,
|
||||
'delete' => true,
|
||||
'manage' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$this->permissionService->saveRolePermissions($roleId, $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;
|
||||
}
|
||||
|
||||
private function roleCrudService(): object
|
||||
{
|
||||
return app(RoleCrudService::class);
|
||||
}
|
||||
}
|
||||
@@ -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_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
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,417 @@
|
||||
<?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\DB;
|
||||
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 = trim((string) $request->query('school_year', Configuration::getConfig('school_year') ?? ''));
|
||||
$semester = trim((string) $request->query('semester', Configuration::getConfig('semester') ?? ''));
|
||||
$assignments = $this->queryService->teacherAssignments($auth, $schoolYear, $semester);
|
||||
|
||||
$requestedClassId = (int) ($request->validated('class_id') ?? 0);
|
||||
$requestedClassSectionId = (int) $request->query('class_section_id', 0);
|
||||
$requestedAssignment = $requestedClassSectionId > 0
|
||||
? collect($assignments)->first(
|
||||
fn (array $assignment) => (int) ($assignment['class_section_id'] ?? 0) === $requestedClassSectionId
|
||||
)
|
||||
: null;
|
||||
$activeClassId = $requestedClassId > 0
|
||||
? $requestedClassId
|
||||
: (int) (($requestedAssignment['class_id'] ?? null) ?: ($assignments[0]['class_id'] ?? 0));
|
||||
$activeAssignment = collect($assignments)->first(
|
||||
fn (array $assignment) => (int) ($assignment['class_id'] ?? 0) === $activeClassId
|
||||
) ?? $requestedAssignment ?? ($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;
|
||||
}
|
||||
|
||||
if ($this->isLegacyTeacherProgressListRoute($request) && ! $this->hasAnyRole($auth, ['teacher', 'teacher_assistant', 'ta', 'administrator', 'admin'])) {
|
||||
return $this->respondError('Forbidden.', Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$filters = $request->validated();
|
||||
$meta = $this->queryService->meta($filters['school_year'] ?? null, $filters['semester'] ?? null);
|
||||
$filters['school_year'] = $filters['school_year'] ?? ($meta['school_year'] ?? null);
|
||||
if ($this->isLegacyTeacherProgressListRoute($request) && empty($filters['class_section_id'])) {
|
||||
$resolvedClassSectionId = $this->resolveTeacherHistoryClassSectionId($auth, $filters);
|
||||
if ($resolvedClassSectionId instanceof JsonResponse) {
|
||||
return $resolvedClassSectionId;
|
||||
}
|
||||
if ($resolvedClassSectionId > 0) {
|
||||
$filters['class_section_id'] = $resolvedClassSectionId;
|
||||
}
|
||||
}
|
||||
if ($this->isAdminProgressAliasRoute($request)) {
|
||||
$filters['group_by_week'] = true;
|
||||
}
|
||||
$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();
|
||||
$payload = $this->resolveTeacherStorePayload($auth, $request->validated());
|
||||
if ($payload instanceof JsonResponse) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$reports = $this->mutationService->createReports($auth, $payload, $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 isLegacyTeacherProgressListRoute(ClassProgressIndexRequest $request): bool
|
||||
{
|
||||
return $request->is('api/v1/teacher/class-progress-history')
|
||||
|| $request->is('api/v1/teacher/class-progress-view');
|
||||
}
|
||||
|
||||
private function isAdminProgressAliasRoute(ClassProgressIndexRequest $request): bool
|
||||
{
|
||||
return $request->is('api/v1/administrator/progress')
|
||||
|| $request->is('api/v1/admin/progress');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>|JsonResponse
|
||||
*/
|
||||
private function resolveTeacherStorePayload(User $user, array $payload): array|JsonResponse
|
||||
{
|
||||
if ($this->hasAnyRole($user, ['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'])) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
if (! $this->hasAnyRole($user, ['teacher', 'teacher_assistant', 'ta'])) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$assignments = $this->queryService->teacherAssignments(
|
||||
$user,
|
||||
$payload['school_year'] ?? null,
|
||||
$payload['semester'] ?? null
|
||||
);
|
||||
|
||||
if ($assignments === []) {
|
||||
return $this->respondError('No class section assigned to this teacher.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$postedSectionId = (int) ($payload['class_section_id'] ?? 0);
|
||||
$assignment = null;
|
||||
if ($postedSectionId > 0) {
|
||||
$assignment = collect($assignments)->first(function (array $assignment) use ($postedSectionId) {
|
||||
return (int) ($assignment['class_section_id'] ?? 0) === $postedSectionId
|
||||
|| (int) ($assignment['class_section_pk'] ?? 0) === $postedSectionId;
|
||||
});
|
||||
}
|
||||
|
||||
if (! $assignment) {
|
||||
$sessionClassSectionId = (int) session('class_section_id', 0);
|
||||
if ($sessionClassSectionId > 0) {
|
||||
$assignment = collect($assignments)->first(function (array $assignment) use ($sessionClassSectionId) {
|
||||
return (int) ($assignment['class_section_id'] ?? 0) === $sessionClassSectionId
|
||||
|| (int) ($assignment['class_section_pk'] ?? 0) === $sessionClassSectionId;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$assignment ??= $assignments[0];
|
||||
$payload['class_section_id'] = (int) ($assignment['class_section_id'] ?? 0);
|
||||
|
||||
if (empty($payload['school_year']) && ! empty($assignment['school_year'])) {
|
||||
$payload['school_year'] = $assignment['school_year'];
|
||||
}
|
||||
if (empty($payload['semester']) && ! empty($assignment['semester'])) {
|
||||
$payload['semester'] = $assignment['semester'];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
private function resolveTeacherHistoryClassSectionId(User $user, array $filters): int|JsonResponse
|
||||
{
|
||||
if ($this->hasAnyRole($user, ['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$assignments = $this->queryService->teacherAssignments(
|
||||
$user,
|
||||
$filters['school_year'] ?? null,
|
||||
$filters['semester'] ?? null
|
||||
);
|
||||
$assignedSectionIds = collect($assignments)
|
||||
->pluck('class_section_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->filter(fn ($id) => $id > 0)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($assignedSectionIds === []) {
|
||||
return $this->respondError('No class section assigned to this teacher.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
if (count($assignedSectionIds) === 1) {
|
||||
return (int) $assignedSectionIds[0];
|
||||
}
|
||||
|
||||
$sessionClassSectionId = (int) session('class_section_id', 0);
|
||||
if ($sessionClassSectionId > 0 && in_array($sessionClassSectionId, $assignedSectionIds, true)) {
|
||||
return $sessionClassSectionId;
|
||||
}
|
||||
|
||||
return (int) $assignedSectionIds[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
private function hasAnyRole(User $user, array $roles): bool
|
||||
{
|
||||
$roles = array_map('strtolower', $roles);
|
||||
|
||||
return $user->roles()
|
||||
->where(function ($query) use ($roles) {
|
||||
$query->whereIn(DB::raw('LOWER(name)'), $roles)
|
||||
->orWhereIn(DB::raw('LOWER(slug)'), $roles);
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
|
||||
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),
|
||||
'unit_options' => $this->metaService->unitOptions($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,398 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\CompetitionWinners;
|
||||
|
||||
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||
use App\Http\Requests\Admin\SaveCompetitionScoresRequest;
|
||||
use App\Http\Requests\Admin\StoreCompetitionWinnerRequest;
|
||||
use App\Http\Requests\Admin\UpdateCompetitionWinnerRequest;
|
||||
use App\Services\Admin\CompetitionWinners\CompetitionWinnersAdminService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CompetitionWinnersController extends BaseApiController
|
||||
{
|
||||
public function __construct(
|
||||
private CompetitionWinnersAdminService $service,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$guard = $this->authenticatedAdminOrForbidden();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$payload = $this->service->indexData();
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'competitions' => array_values(array_map(
|
||||
fn ($row) => $this->normalizeCompetitionRow($this->rowToArray($row)),
|
||||
$this->iterableToArray($payload['competitions'] ?? [])
|
||||
)),
|
||||
'sectionMap' => $payload['sectionMap'] ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function createForm(): JsonResponse
|
||||
{
|
||||
$guard = $this->authenticatedAdminOrForbidden();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
...$this->service->createFormData(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function settingsForm(int $id): JsonResponse
|
||||
{
|
||||
$guard = $this->authenticatedAdminOrForbidden();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$payload = $this->service->settingsFormData($id);
|
||||
if ($payload === null) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'Competition not found.',
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
...$payload,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreCompetitionWinnerRequest $request): JsonResponse
|
||||
{
|
||||
$guard = $this->authenticatedAdminOrForbidden();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
$id = $this->service->storeCompetition(
|
||||
$validated,
|
||||
(array) ($validated['winner_overrides'] ?? []),
|
||||
(array) ($validated['question_counts'] ?? []),
|
||||
(array) ($validated['prizes'] ?? []),
|
||||
$guard
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => 'Competition created.',
|
||||
'id' => $id,
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
public function update(UpdateCompetitionWinnerRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$guard = $this->authenticatedAdminOrForbidden();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
$updated = $this->service->updateCompetition(
|
||||
$id,
|
||||
$validated,
|
||||
(array) ($validated['winner_overrides'] ?? []),
|
||||
(array) ($validated['question_counts'] ?? []),
|
||||
(array) ($validated['prizes'] ?? [])
|
||||
);
|
||||
|
||||
if (! $updated) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'Competition not found.',
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => 'Competition updated.',
|
||||
'id' => $id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function scores(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$guard = $this->authenticatedAdminOrForbidden();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$payload = $this->service->editScoresData($id, $this->intOrNull($request->query('class_section_id')));
|
||||
if ($payload === null) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'Competition not found.',
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
...$payload,
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveScores(SaveCompetitionScoresRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$guard = $this->authenticatedAdminOrForbidden();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$competition = $this->service->editScoresData($id, $this->intOrNull($request->input('class_section_id')));
|
||||
if ($competition === null) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'Competition not found.',
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$payload = $request->validated();
|
||||
$saved = $this->service->saveScores(
|
||||
$id,
|
||||
(array) ($payload['scores'] ?? []),
|
||||
$this->intOrNull($payload['class_section_id'] ?? null),
|
||||
(array) ($competition['competition'] ?? [])
|
||||
);
|
||||
|
||||
if (! $saved) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'Select a class section before saving scores.',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => 'Scores saved.',
|
||||
'savedCount' => count((array) ($payload['scores'] ?? [])),
|
||||
]);
|
||||
}
|
||||
|
||||
public function preview(int $id): JsonResponse
|
||||
{
|
||||
$guard = $this->authenticatedAdminOrForbidden();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$payload = $this->service->previewData($id);
|
||||
if ($payload === null) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'Competition not found.',
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
...$payload,
|
||||
]);
|
||||
}
|
||||
|
||||
public function winners(int $id): JsonResponse
|
||||
{
|
||||
$guard = $this->authenticatedAdminOrForbidden();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$payload = $this->service->settingsFormData($id);
|
||||
if ($payload === null || ! isset($payload['competition'])) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'Competition not found.',
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'competition' => $payload['competition'],
|
||||
'rows' => $this->service->winnersListingRows($id),
|
||||
'sectionMap' => $this->service->getClassSectionMap(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function publish(int $id): JsonResponse
|
||||
{
|
||||
$guard = $this->authenticatedAdminOrForbidden();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
if (! $this->service->publishWinners($id)) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'Competition not found.',
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => 'Competition winners published.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function lock(int $id): JsonResponse
|
||||
{
|
||||
$guard = $this->authenticatedAdminOrForbidden();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
if (! $this->service->lockCompetition($id, $guard)) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'Competition not found.',
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => 'Competition locked.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function unlock(int $id): JsonResponse
|
||||
{
|
||||
$guard = $this->authenticatedAdminOrForbidden();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
if (! $this->service->unlockCompetition($id)) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'Competition not found.',
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'message' => 'Competition unlocked.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function exportQuiz(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$guard = $this->authenticatedAdminOrForbidden();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'class_section_id' => ['nullable', 'integer'],
|
||||
'semester' => ['nullable', 'string'],
|
||||
'school_year' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$schoolId = $this->intOrNull(optional(auth()->user())->school_id);
|
||||
$result = $this->service->exportCompetitionToQuiz(
|
||||
$id,
|
||||
$this->intOrNull($validated['class_section_id'] ?? null) ?? 0,
|
||||
$schoolId,
|
||||
$guard,
|
||||
$validated['semester'] ?? null,
|
||||
$validated['school_year'] ?? null
|
||||
);
|
||||
|
||||
return response()->json($result, $result['ok'] ? Response::HTTP_OK : Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
private function authenticatedAdminOrForbidden(): int|JsonResponse
|
||||
{
|
||||
$user = $this->getCurrentUser();
|
||||
if (! $user || (int) ($user->id ?? 0) <= 0) {
|
||||
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$roles = array_map('strtolower', (array) ($user->roles ?? []));
|
||||
$allowed = ['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'];
|
||||
if (empty(array_intersect($roles, $allowed))) {
|
||||
return response()->json(['ok' => false, 'message' => 'Forbidden.'], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
return (int) $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<mixed> $rows
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function iterableToArray(iterable $rows): array
|
||||
{
|
||||
return is_array($rows) ? $rows : iterator_to_array($rows, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeCompetitionRow(array $row): array
|
||||
{
|
||||
$id = $this->intOrNull($row['id'] ?? $row['competition_id'] ?? null);
|
||||
if ($id !== null) {
|
||||
$row['id'] = $id;
|
||||
}
|
||||
|
||||
$classSectionId = $this->intOrNull($row['class_section_id'] ?? null);
|
||||
$row['class_section_id'] = $classSectionId;
|
||||
$row['is_locked'] = (bool) ($row['is_locked'] ?? false);
|
||||
$row['is_published'] = (bool) ($row['is_published'] ?? false);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function rowToArray(mixed $row): array
|
||||
{
|
||||
if (is_array($row)) {
|
||||
return $row;
|
||||
}
|
||||
|
||||
if ($row instanceof \JsonSerializable) {
|
||||
$serialized = $row->jsonSerialize();
|
||||
if (is_array($serialized)) {
|
||||
return $serialized;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_object($row) && method_exists($row, 'toArray')) {
|
||||
$array = $row->toArray();
|
||||
if (is_array($array)) {
|
||||
return $array;
|
||||
}
|
||||
}
|
||||
|
||||
return (array) $row;
|
||||
}
|
||||
|
||||
private function intOrNull(mixed $value): ?int
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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,109 @@
|
||||
<?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\Services\Families\FamilyNotificationService;
|
||||
use App\Services\Families\FamilyQueryService;
|
||||
use App\Services\SchoolYears\SelectedSchoolYearContextService;
|
||||
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,
|
||||
private SelectedSchoolYearContextService $schoolYears
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function index(FamilyAdminIndexRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$schoolYear = $this->requestedSchoolYear($request);
|
||||
|
||||
$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 = $this->requestedSchoolYear($request);
|
||||
|
||||
$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));
|
||||
}
|
||||
|
||||
private function requestedSchoolYear($request): string
|
||||
{
|
||||
return $this->schoolYears->fromRequest($request)->name;
|
||||
}
|
||||
|
||||
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,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Family;
|
||||
|
||||
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||
use App\Services\Families\ParentProfileAdminQueryService;
|
||||
use App\Services\SchoolYears\SelectedSchoolYearContextService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ParentProfileAdminController extends BaseApiController
|
||||
{
|
||||
public function __construct(
|
||||
private ParentProfileAdminQueryService $profiles,
|
||||
private SelectedSchoolYearContextService $schoolYears,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
if ($request->has('school_year_id')) {
|
||||
return $this->error('Use school_year by name; school_year_id is not accepted.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$context = $this->schoolYears->fromRequest($request);
|
||||
$page = $this->profiles->index(
|
||||
$context->name,
|
||||
trim((string) $request->query('q', '')),
|
||||
(int) $request->query('per_page', 25),
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
'school_year' => $context->name,
|
||||
'read_only' => $context->isReadOnly,
|
||||
'parents' => $page->getCollection()
|
||||
->map(fn ($row) => $this->profiles->parentSummary((array) $row, $context->name))
|
||||
->values()
|
||||
->all(),
|
||||
'pagination' => [
|
||||
'page' => $page->currentPage(),
|
||||
'per_page' => $page->perPage(),
|
||||
'total' => $page->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function search(Request $request): JsonResponse
|
||||
{
|
||||
if ($request->has('school_year_id')) {
|
||||
return $this->error('Use school_year by name; school_year_id is not accepted.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$context = $this->schoolYears->fromRequest($request);
|
||||
|
||||
return $this->success([
|
||||
'school_year' => $context->name,
|
||||
'read_only' => $context->isReadOnly,
|
||||
'items' => $this->profiles->search(
|
||||
$context->name,
|
||||
trim((string) $request->query('q', '')),
|
||||
(int) $request->query('limit', 10),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $parent): JsonResponse
|
||||
{
|
||||
if ($request->has('school_year_id')) {
|
||||
return $this->error('Use school_year by name; school_year_id is not accepted.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$context = $this->schoolYears->fromRequest($request);
|
||||
$payload = $this->profiles->show($parent, $context->name);
|
||||
|
||||
if (! $payload) {
|
||||
return $this->error('Parent profile not found.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'school_year' => $context->name,
|
||||
'read_only' => $context->isReadOnly,
|
||||
...$payload,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $parent): JsonResponse
|
||||
{
|
||||
if ($request->has('school_year_id')) {
|
||||
return $this->error('Use school_year by name; school_year_id is not accepted.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$payload = $request->validate([
|
||||
'firstname' => ['sometimes', 'required', 'string', 'max:100'],
|
||||
'lastname' => ['sometimes', 'required', 'string', 'max:100'],
|
||||
'email' => ['sometimes', 'nullable', 'email', 'max:255'],
|
||||
'cellphone' => ['sometimes', 'nullable', 'string', 'max:30'],
|
||||
'address_street' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'city' => ['sometimes', 'nullable', 'string', 'max:100'],
|
||||
'state' => ['sometimes', 'nullable', 'string', 'max:100'],
|
||||
'zip' => ['sometimes', 'nullable', 'string', 'max:30'],
|
||||
'status' => ['sometimes', 'nullable', 'string', 'max:30'],
|
||||
]);
|
||||
|
||||
$updated = $this->profiles->updateParent($parent, $payload);
|
||||
if (! $updated) {
|
||||
return $this->error('Parent profile not found.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success(['parent' => $updated], 'Parent profile updated.');
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user