Compare commits
107 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 |
@@ -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
|
WORKDIR /app
|
||||||
COPY composer.json composer.lock ./
|
COPY composer.json composer.lock ./
|
||||||
COPY app/Helpers/ci_helpers.php app/Helpers/
|
COPY app/Helpers/app_helpers.php app/Helpers/
|
||||||
RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts
|
RUN composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts --ignore-platform-req=ext-gd
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN composer dump-autoload --optimize \
|
RUN composer dump-autoload --optimize \
|
||||||
&& php artisan package:discover --ansi
|
&& php artisan package:discover --ansi
|
||||||
|
|
||||||
FROM node:20-alpine AS assets
|
FROM php:8.4-fpm
|
||||||
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
|
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
@@ -30,13 +18,15 @@ RUN apt-get update \
|
|||||||
libonig-dev \
|
libonig-dev \
|
||||||
libxml2-dev \
|
libxml2-dev \
|
||||||
libzip-dev \
|
libzip-dev \
|
||||||
&& docker-php-ext-install pdo_mysql mbstring xml zip \
|
libjpeg-dev \
|
||||||
|
libfreetype6-dev \
|
||||||
|
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||||
|
&& docker-php-ext-install pdo_mysql mbstring xml zip gd \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /var/www/html
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
COPY --from=vendor /app /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
|
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -57,3 +57,4 @@ If you discover a security vulnerability within Laravel, please send an e-mail t
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
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\Scores\SemesterScoreService;
|
|
||||||
|
|
||||||
class Services
|
|
||||||
{
|
|
||||||
public static function email(): EmailService
|
|
||||||
{
|
|
||||||
return new EmailService();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function emailService(): EmailService
|
|
||||||
{
|
|
||||||
return static::email();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function semesterScoreService(): SemesterScoreService
|
|
||||||
{
|
|
||||||
return new SemesterScoreService();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'],
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
|
||||||
@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
|
|||||||
class AttendanceAutoPublishCommand extends Command
|
class AttendanceAutoPublishCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'attendance:auto-publish';
|
protected $signature = 'attendance:auto-publish';
|
||||||
|
|
||||||
protected $description = 'Auto-publish attendance days per "second Sunday backward" rule.';
|
protected $description = 'Auto-publish attendance days per "second Sunday backward" rule.';
|
||||||
|
|
||||||
public function handle(AttendanceAutoPublishJobService $service): int
|
public function handle(AttendanceAutoPublishJobService $service): int
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
|
|||||||
class CheckMissedPaymentsCommand extends Command
|
class CheckMissedPaymentsCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'payments:check-missed';
|
protected $signature = 'payments:check-missed';
|
||||||
|
|
||||||
protected $description = 'Checks for users who missed payments and sends reminders.';
|
protected $description = 'Checks for users who missed payments and sends reminders.';
|
||||||
|
|
||||||
public function handle(PaymentMissedCheckService $service): int
|
public function handle(PaymentMissedCheckService $service): int
|
||||||
@@ -15,6 +16,7 @@ class CheckMissedPaymentsCommand extends Command
|
|||||||
$users = $service->findUsersWithMissedPayments();
|
$users = $service->findUsersWithMissedPayments();
|
||||||
if (empty($users)) {
|
if (empty($users)) {
|
||||||
$this->info('No missed payments found.');
|
$this->info('No missed payments found.');
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
|
|||||||
class CleanupExpiredNotificationsCommand extends Command
|
class CleanupExpiredNotificationsCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'notifications:cleanup';
|
protected $signature = 'notifications:cleanup';
|
||||||
|
|
||||||
protected $description = 'Deletes expired notifications from the database.';
|
protected $description = 'Deletes expired notifications from the database.';
|
||||||
|
|
||||||
public function handle(NotificationCleanupService $service): int
|
public function handle(NotificationCleanupService $service): int
|
||||||
@@ -16,6 +17,7 @@ class CleanupExpiredNotificationsCommand extends Command
|
|||||||
|
|
||||||
if ($count <= 0) {
|
if ($count <= 0) {
|
||||||
$this->info('No expired notifications found to soft delete.');
|
$this->info('No expired notifications found to soft delete.');
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
|
|||||||
class CleanupPasswordResetsCommand extends Command
|
class CleanupPasswordResetsCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'cleanup:password-resets {--days=30}';
|
protected $signature = 'cleanup:password-resets {--days=30}';
|
||||||
|
|
||||||
protected $description = 'Delete password reset requests older than N days.';
|
protected $description = 'Delete password reset requests older than N days.';
|
||||||
|
|
||||||
public function handle(PasswordResetCleanupService $service): int
|
public function handle(PasswordResetCleanupService $service): int
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use Illuminate\Console\Command;
|
|||||||
class ConfigUpdateCommand extends Command
|
class ConfigUpdateCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'config:update {task?} {--task=} {--dry} {--force} {--tz=}';
|
protected $signature = 'config:update {task?} {--task=} {--dry} {--force} {--tz=}';
|
||||||
|
|
||||||
protected $description = 'Run a configuration update task (weekly cron).';
|
protected $description = 'Run a configuration update task (weekly cron).';
|
||||||
|
|
||||||
public function handle(ConfigUpdateService $service): int
|
public function handle(ConfigUpdateService $service): int
|
||||||
@@ -19,18 +20,20 @@ class ConfigUpdateCommand extends Command
|
|||||||
$tzName = (string) ($this->option('tz') ?: (config('School')->attendance['timezone'] ?? 'UTC'));
|
$tzName = (string) ($this->option('tz') ?: (config('School')->attendance['timezone'] ?? 'UTC'));
|
||||||
$tz = new DateTimeZone($tzName);
|
$tz = new DateTimeZone($tzName);
|
||||||
|
|
||||||
if ($task === '' || !in_array($task, $service->availableTasks(), true)) {
|
if ($task === '' || ! in_array($task, $service->availableTasks(), true)) {
|
||||||
$this->error('Invalid or missing --task. Available: ' . implode(', ', $service->availableTasks()));
|
$this->error('Invalid or missing --task. Available: '.implode(', ', $service->availableTasks()));
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $service->runTask($task, $dry, $force, $tz);
|
$result = $service->runTask($task, $dry, $force, $tz);
|
||||||
if (!$result['ok']) {
|
if (! $result['ok']) {
|
||||||
$this->error($result['message'] ?? "Task '{$task}' failed.");
|
$this->error($result['message'] ?? "Task '{$task}' failed.");
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("Task '{$task}' finished successfully." . ($dry ? ' [DRY RUN]' : ''));
|
$this->info("Task '{$task}' finished successfully.".($dry ? ' [DRY RUN]' : ''));
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
|
|||||||
class DeleteInactiveUsersCommand extends Command
|
class DeleteInactiveUsersCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'users:delete-inactive-users {--minutes=15}';
|
protected $signature = 'users:delete-inactive-users {--minutes=15}';
|
||||||
|
|
||||||
protected $description = 'Delete users that are inactive and created more than N minutes ago.';
|
protected $description = 'Delete users that are inactive and created more than N minutes ago.';
|
||||||
|
|
||||||
public function handle(InactiveUserCleanupService $service): int
|
public function handle(InactiveUserCleanupService $service): int
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Event;
|
|||||||
class DeleteUnverifiedUsersCommand extends Command
|
class DeleteUnverifiedUsersCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'users:delete-unverified {--timeout=86400}';
|
protected $signature = 'users:delete-unverified {--timeout=86400}';
|
||||||
|
|
||||||
protected $description = 'Delete unverified users older than the configured timeout (seconds).';
|
protected $description = 'Delete unverified users older than the configured timeout (seconds).';
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,12 +8,13 @@ use Illuminate\Console\Command;
|
|||||||
class RecalculateAttendanceCommand extends Command
|
class RecalculateAttendanceCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'attendance:recalculate-summary';
|
protected $signature = 'attendance:recalculate-summary';
|
||||||
|
|
||||||
protected $description = 'Recalculates the attendance summary records from raw attendance data.';
|
protected $description = 'Recalculates the attendance summary records from raw attendance data.';
|
||||||
|
|
||||||
public function handle(AttendanceSummaryRebuildService $service): int
|
public function handle(AttendanceSummaryRebuildService $service): int
|
||||||
{
|
{
|
||||||
$result = $service->rebuild();
|
$result = $service->rebuild();
|
||||||
$this->info('Attendance summary recalculation finished. Inserted: ' . $result['inserted']);
|
$this->info('Attendance summary recalculation finished. Inserted: '.$result['inserted']);
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
|
|||||||
class SendAbsenteesSummaryCommand extends Command
|
class SendAbsenteesSummaryCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'attendance:absentees-summary';
|
protected $signature = 'attendance:absentees-summary';
|
||||||
|
|
||||||
protected $description = 'Sends daily attendance summaries for absences to parents.';
|
protected $description = 'Sends daily attendance summaries for absences to parents.';
|
||||||
|
|
||||||
public function handle(AttendanceDailySummaryService $service): int
|
public function handle(AttendanceDailySummaryService $service): int
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
|
|||||||
class SendLatesSummaryCommand extends Command
|
class SendLatesSummaryCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'attendance:lates-summary';
|
protected $signature = 'attendance:lates-summary';
|
||||||
|
|
||||||
protected $description = 'Sends daily attendance summaries for lates to parents.';
|
protected $description = 'Sends daily attendance summaries for lates to parents.';
|
||||||
|
|
||||||
public function handle(AttendanceDailySummaryService $service): int
|
public function handle(AttendanceDailySummaryService $service): int
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use Illuminate\Console\Command;
|
|||||||
class SendMonthlyPaymentNotificationsCommand extends Command
|
class SendMonthlyPaymentNotificationsCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'payments:monthly-reminder {--force} {--email=} {--type=}';
|
protected $signature = 'payments:monthly-reminder {--force} {--email=} {--type=}';
|
||||||
|
|
||||||
protected $description = 'Send monthly payment reminders on the first Saturday of every month.';
|
protected $description = 'Send monthly payment reminders on the first Saturday of every month.';
|
||||||
|
|
||||||
public function handle(PaymentNotificationService $service): int
|
public function handle(PaymentNotificationService $service): int
|
||||||
@@ -19,8 +20,9 @@ class SendMonthlyPaymentNotificationsCommand extends Command
|
|||||||
|
|
||||||
if ($email !== '') {
|
if ($email !== '') {
|
||||||
$user = User::query()->where('email', $email)->first();
|
$user = User::query()->where('email', $email)->first();
|
||||||
if (!$user) {
|
if (! $user) {
|
||||||
$this->error('Invalid --email provided.');
|
$this->error('Invalid --email provided.');
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,12 +32,14 @@ class SendMonthlyPaymentNotificationsCommand extends Command
|
|||||||
'force' => true,
|
'force' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->info('Test reminder sent for ' . $email . '.');
|
$this->info('Test reminder sent for '.$email.'.');
|
||||||
|
|
||||||
return $result['failed'] > 0 ? self::FAILURE : self::SUCCESS;
|
return $result['failed'] > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$force && !$this->isFirstSaturday(now())) {
|
if (! $force && ! $this->isFirstSaturday(now())) {
|
||||||
$this->info('Not the first Saturday of the month. Use --force to override.');
|
$this->info('Not the first Saturday of the month. Use --force to override.');
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
|
|||||||
class SendTestPaymentNotificationCommand extends Command
|
class SendTestPaymentNotificationCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'payments:send-test {--email=} {--type=no_payment}';
|
protected $signature = 'payments:send-test {--email=} {--type=no_payment}';
|
||||||
|
|
||||||
protected $description = 'Send a single test non-payment or installment reminder to a specific email.';
|
protected $description = 'Send a single test non-payment or installment reminder to a specific email.';
|
||||||
|
|
||||||
public function handle(PaymentTestNotificationService $service): int
|
public function handle(PaymentTestNotificationService $service): int
|
||||||
@@ -15,18 +16,20 @@ class SendTestPaymentNotificationCommand extends Command
|
|||||||
$email = (string) $this->option('email');
|
$email = (string) $this->option('email');
|
||||||
$type = (string) $this->option('type');
|
$type = (string) $this->option('type');
|
||||||
|
|
||||||
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
$this->error('Usage: php artisan payments:send-test --email=addr@example.com [--type=no_payment|installment]');
|
$this->error('Usage: php artisan payments:send-test --email=addr@example.com [--type=no_payment|installment]');
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $service->send($email, $type);
|
$result = $service->send($email, $type);
|
||||||
if (!$result['ok']) {
|
if (! $result['ok']) {
|
||||||
$this->error('Failed to send test reminder to ' . $email . '.');
|
$this->error('Failed to send test reminder to '.$email.'.');
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info('Sent test reminder to ' . $email . '.');
|
$this->info('Sent test reminder to '.$email.'.');
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
|
|||||||
class SyncPaypalPaymentsCommand extends Command
|
class SyncPaypalPaymentsCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'payments:sync-paypal {--dry-run} {--report-only}';
|
protected $signature = 'payments:sync-paypal {--dry-run} {--report-only}';
|
||||||
|
|
||||||
protected $description = 'Sync PayPal payments to internal payments table from paypal_payments.';
|
protected $description = 'Sync PayPal payments to internal payments table from paypal_payments.';
|
||||||
|
|
||||||
public function handle(PaypalPaymentSyncService $service): int
|
public function handle(PaypalPaymentSyncService $service): int
|
||||||
@@ -18,7 +19,7 @@ class SyncPaypalPaymentsCommand extends Command
|
|||||||
$result = $service->sync($dryRun, $reportOnly);
|
$result = $service->sync($dryRun, $reportOnly);
|
||||||
|
|
||||||
$this->info(sprintf('[%s] %d PayPal payments processed.', $result['mode'], $result['processed']));
|
$this->info(sprintf('[%s] %d PayPal payments processed.', $result['mode'], $result['processed']));
|
||||||
if (!empty($result['failed'])) {
|
if (! empty($result['failed'])) {
|
||||||
$this->error(sprintf('[%s] Failed transactions: %s', $result['mode'], implode(', ', $result['failed'])));
|
$this->error(sprintf('[%s] Failed transactions: %s', $result['mode'], implode(', ', $result['failed'])));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,5 @@ namespace App\Events;
|
|||||||
|
|
||||||
class DeleteUnverifiedUser
|
class DeleteUnverifiedUser
|
||||||
{
|
{
|
||||||
public function __construct(public int $userId)
|
public function __construct(public int $userId) {}
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,5 @@ class WhatsappInvitesSend
|
|||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
public function __construct(public array $payload)
|
public function __construct(public array $payload) {}
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Executable → Regular
+18
-48
@@ -2,65 +2,35 @@
|
|||||||
|
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
if (!function_exists('log_message')) {
|
if (! function_exists('utc_now')) {
|
||||||
function log_message(string $level, string $message, array $context = []): void
|
|
||||||
{
|
|
||||||
$level = strtolower($level);
|
|
||||||
$logger = Log::channel(config('logging.default'));
|
|
||||||
$context = $context;
|
|
||||||
if (!empty($context)) {
|
|
||||||
$message = strtr($message, array_combine(
|
|
||||||
array_map(fn($k) => "{" . $k . "}", array_keys($context)),
|
|
||||||
array_map('strval', $context)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
$logger->{$level}($message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!function_exists('esc')) {
|
|
||||||
function esc($value): string
|
|
||||||
{
|
|
||||||
return e($value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!function_exists('utc_now')) {
|
|
||||||
function utc_now(): string
|
function utc_now(): string
|
||||||
{
|
{
|
||||||
return CarbonImmutable::now('UTC')->toDateTimeString();
|
return CarbonImmutable::now('UTC')->toDateTimeString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!function_exists('local_date')) {
|
if (! function_exists('local_date')) {
|
||||||
function local_date(string $utcDateTime, string $format = 'Y-m-d H:i:s'): string
|
function local_date(string $utcDateTime, string $format = 'Y-m-d H:i:s'): string
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$carbon = CarbonImmutable::parse($utcDateTime)->setTimezone(config('app.timezone'));
|
$carbon = CarbonImmutable::parse($utcDateTime)->setTimezone(config('app.timezone'));
|
||||||
|
|
||||||
return $carbon->format($format);
|
return $carbon->format($format);
|
||||||
} catch (\Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
return CarbonImmutable::parse($utcDateTime)->format($format);
|
return CarbonImmutable::parse($utcDateTime)->format($format);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!function_exists('user_timezone')) {
|
if (! function_exists('user_timezone')) {
|
||||||
function user_timezone(): string
|
function user_timezone(): string
|
||||||
{
|
{
|
||||||
return (string) (config('School')->attendance['timezone'] ?? config('app.timezone', 'America/New_York'));
|
return (string) (config('School')->attendance['timezone'] ?? config('app.timezone', 'America/New_York'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!function_exists('model')) {
|
if (! function_exists('pbkdf2_hash')) {
|
||||||
function model(string $class)
|
|
||||||
{
|
|
||||||
return app()->make($class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!function_exists('pbkdf2_hash')) {
|
|
||||||
function pbkdf2_hash(string $password, string $algo = 'sha256', int $iterations = 100000, int $length = 64): string
|
function pbkdf2_hash(string $password, string $algo = 'sha256', int $iterations = 100000, int $length = 64): string
|
||||||
{
|
{
|
||||||
$salt = random_bytes(16);
|
$salt = random_bytes(16);
|
||||||
@@ -72,7 +42,7 @@ if (!function_exists('pbkdf2_hash')) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!function_exists('pbkdf2_verify')) {
|
if (! function_exists('pbkdf2_verify')) {
|
||||||
function pbkdf2_verify(string $password, string $storedHash): bool
|
function pbkdf2_verify(string $password, string $storedHash): bool
|
||||||
{
|
{
|
||||||
$delim = strpos($storedHash, '$') !== false ? '$' : ':';
|
$delim = strpos($storedHash, '$') !== false ? '$' : ':';
|
||||||
@@ -93,25 +63,25 @@ if (!function_exists('pbkdf2_verify')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$calc = hash_pbkdf2($algo, $password, $salt, (int) $iterations, strlen($derived), true);
|
$calc = hash_pbkdf2($algo, $password, $salt, (int) $iterations, strlen($derived), true);
|
||||||
|
|
||||||
return hash_equals($derived, $calc);
|
return hash_equals($derived, $calc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!function_exists('legacy_password_verify')) {
|
if (! function_exists('verify_stored_password')) {
|
||||||
function legacy_password_verify(string $password, ?string $storedHash): bool
|
function verify_stored_password(string $password, ?string $storedHash): bool
|
||||||
{
|
{
|
||||||
return ci_password_verify($password, $storedHash);
|
if (! $storedHash) {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!function_exists('ci_password_verify')) {
|
|
||||||
function ci_password_verify(string $password, ?string $storedHash): bool
|
|
||||||
{
|
|
||||||
if (!$storedHash) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (strpos($storedHash, ':') !== false || strpos($storedHash, '$') !== false) {
|
$hashInfo = password_get_info($storedHash);
|
||||||
|
if (($hashInfo['algo'] ?? null) !== null) {
|
||||||
|
return Hash::check($password, $storedHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = preg_split('/[:$]/', $storedHash);
|
||||||
|
if (is_array($parts) && count($parts) === 4) {
|
||||||
return pbkdf2_verify($password, $storedHash);
|
return pbkdf2_verify($password, $storedHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -13,8 +13,7 @@ class AdministratorAbsenceController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected AdministratorAbsenceService $service
|
protected AdministratorAbsenceService $service
|
||||||
) {
|
) {}
|
||||||
}
|
|
||||||
|
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -23,7 +22,7 @@ class AdministratorAbsenceController extends Controller
|
|||||||
return response()->json(['message' => 'Please log in first.'], 401);
|
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(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
@@ -34,7 +33,7 @@ class AdministratorAbsenceController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$payload = $request->all();
|
$payload = $request->all();
|
||||||
if (!array_key_exists('dates', $payload) || !array_key_exists('reason', $payload)) {
|
if (! array_key_exists('dates', $payload) || ! array_key_exists('reason', $payload)) {
|
||||||
$json = json_decode($request->getContent() ?: '', true);
|
$json = json_decode($request->getContent() ?: '', true);
|
||||||
if (is_array($json)) {
|
if (is_array($json)) {
|
||||||
$payload = array_merge($payload, $json);
|
$payload = array_merge($payload, $json);
|
||||||
|
|||||||
@@ -11,12 +11,11 @@ class AdministratorDashboardController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected AdministratorDashboardService $service
|
protected AdministratorDashboardService $service
|
||||||
) {
|
) {}
|
||||||
}
|
|
||||||
|
|
||||||
public function metrics(): JsonResponse
|
public function metrics(): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json($this->service->metrics());
|
return response()->json(['ok' => true] + $this->service->metrics());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function userSearch(Request $request): JsonResponse
|
public function userSearch(Request $request): JsonResponse
|
||||||
|
|||||||
@@ -14,13 +14,12 @@ class AdministratorEnrollmentController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected AdministratorEnrollmentService $service
|
protected AdministratorEnrollmentService $service
|
||||||
) {
|
) {}
|
||||||
}
|
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json(
|
return response()->json(
|
||||||
$this->service->enrollmentWithdrawalData(
|
['ok' => true] + $this->service->enrollmentWithdrawalData(
|
||||||
(string) $request->query('schoolYear', '')
|
(string) $request->query('schoolYear', '')
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -28,7 +27,7 @@ class AdministratorEnrollmentController extends Controller
|
|||||||
|
|
||||||
public function newStudents(): JsonResponse
|
public function newStudents(): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json($this->service->showNewStudentsData());
|
return response()->json(['ok' => true] + $this->service->showNewStudentsData());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateStatuses(Request $request): JsonResponse
|
public function updateStatuses(Request $request): JsonResponse
|
||||||
@@ -39,13 +38,13 @@ class AdministratorEnrollmentController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$payload = $request->all();
|
$payload = $request->all();
|
||||||
if (!array_key_exists('enrollment_status', $payload)) {
|
if (! array_key_exists('enrollment_status', $payload)) {
|
||||||
$json = json_decode($request->getContent() ?: '', true);
|
$json = json_decode($request->getContent() ?: '', true);
|
||||||
if (is_array($json) && array_key_exists('enrollment_status', $json)) {
|
if (is_array($json) && array_key_exists('enrollment_status', $json)) {
|
||||||
$payload['enrollment_status'] = $json['enrollment_status'];
|
$payload['enrollment_status'] = $json['enrollment_status'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!array_key_exists('enrollment_status', $payload)) {
|
if (! array_key_exists('enrollment_status', $payload)) {
|
||||||
$payload['enrollment_status'] = $request->query('enrollment_status');
|
$payload['enrollment_status'] = $request->query('enrollment_status');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,18 +12,17 @@ class AdministratorNotificationController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected AdministratorNotificationService $service
|
protected AdministratorNotificationService $service
|
||||||
) {
|
) {}
|
||||||
}
|
|
||||||
|
|
||||||
public function alerts(): JsonResponse
|
public function alerts(): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json($this->service->notificationsAlertsData());
|
return response()->json(['ok' => true] + $this->service->notificationsAlertsData());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveAlerts(Request $request): JsonResponse
|
public function saveAlerts(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$payload = $request->all();
|
$payload = $request->all();
|
||||||
if (!array_key_exists('subjects', $payload)) {
|
if (! array_key_exists('subjects', $payload)) {
|
||||||
$json = json_decode($request->getContent() ?: '', true);
|
$json = json_decode($request->getContent() ?: '', true);
|
||||||
if (is_array($json)) {
|
if (is_array($json)) {
|
||||||
$payload = array_merge($payload, $json);
|
$payload = array_merge($payload, $json);
|
||||||
@@ -60,7 +59,7 @@ class AdministratorNotificationController extends Controller
|
|||||||
public function savePrintRecipients(Request $request): JsonResponse
|
public function savePrintRecipients(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$payload = $request->all();
|
$payload = $request->all();
|
||||||
if (!array_key_exists('notify', $payload)) {
|
if (! array_key_exists('notify', $payload)) {
|
||||||
$json = json_decode($request->getContent() ?: '', true);
|
$json = json_decode($request->getContent() ?: '', true);
|
||||||
if (is_array($json)) {
|
if (is_array($json)) {
|
||||||
$payload = array_merge($payload, $json);
|
$payload = array_merge($payload, $json);
|
||||||
|
|||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,12 +13,11 @@ class AdministratorTeacherSubmissionController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected AdministratorTeacherSubmissionService $service
|
protected AdministratorTeacherSubmissionService $service
|
||||||
) {
|
) {}
|
||||||
}
|
|
||||||
|
|
||||||
public function index(): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json($this->service->report());
|
return response()->json($this->service->report($request->query()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function notify(Request $request): JsonResponse
|
public function notify(Request $request): JsonResponse
|
||||||
|
|||||||
@@ -3,24 +3,49 @@
|
|||||||
namespace App\Http\Controllers\Api\Administrator;
|
namespace App\Http\Controllers\Api\Administrator;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\EmergencyContacts\EmergencyContactParentRequest;
|
||||||
use App\Http\Requests\EmergencyContacts\EmergencyContactUpdateRequest;
|
use App\Http\Requests\EmergencyContacts\EmergencyContactUpdateRequest;
|
||||||
use App\Http\Resources\EmergencyContacts\EmergencyContactGroupResource;
|
use App\Http\Resources\EmergencyContacts\EmergencyContactGroupResource;
|
||||||
use App\Http\Resources\EmergencyContacts\EmergencyContactResource;
|
use App\Http\Resources\EmergencyContacts\EmergencyContactResource;
|
||||||
use App\Services\EmergencyContacts\EmergencyContactCrudService;
|
use App\Services\EmergencyContacts\EmergencyContactCrudService;
|
||||||
use App\Services\EmergencyContacts\EmergencyContactDirectoryService;
|
use App\Services\EmergencyContacts\EmergencyContactDirectoryService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
class EmergencyContactController extends Controller
|
class EmergencyContactController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private EmergencyContactDirectoryService $directoryService,
|
private EmergencyContactDirectoryService $directoryService,
|
||||||
private EmergencyContactCrudService $crudService
|
private EmergencyContactCrudService $crudService
|
||||||
) {
|
) {}
|
||||||
}
|
|
||||||
|
|
||||||
public function index(): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$groups = $this->directoryService->groups();
|
$validator = Validator::make($request->query->all(), [
|
||||||
|
'parent_id' => ['nullable', 'integer', 'min:1'],
|
||||||
|
'parent_ids' => ['nullable', 'array'],
|
||||||
|
'parent_ids.*' => ['integer', 'min:1'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => $validator->errors(),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $validator->validated();
|
||||||
|
$parentIds = [];
|
||||||
|
if (! empty($payload['parent_ids'])) {
|
||||||
|
$parentIds = array_values(array_unique(array_map('intval', $payload['parent_ids'])));
|
||||||
|
}
|
||||||
|
if (! empty($payload['parent_id'])) {
|
||||||
|
$parentIds[] = (int) $payload['parent_id'];
|
||||||
|
}
|
||||||
|
$parentIds = array_values(array_unique(array_filter($parentIds)));
|
||||||
|
|
||||||
|
$groups = $this->directoryService->groups($parentIds ?: null);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
@@ -28,9 +53,10 @@ class EmergencyContactController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(EmergencyContactUpdateRequest $request, int $contactId): JsonResponse
|
public function show(EmergencyContactParentRequest $request, int $contactId): JsonResponse
|
||||||
{
|
{
|
||||||
$contact = $this->crudService->update($contactId, $request->validated());
|
$parentId = (int) $request->validated()['parent_id'];
|
||||||
|
$contact = $this->crudService->findForParent($contactId, $parentId);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
@@ -38,9 +64,21 @@ class EmergencyContactController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(int $contactId): JsonResponse
|
public function update(EmergencyContactUpdateRequest $request, int $contactId): JsonResponse
|
||||||
{
|
{
|
||||||
$this->crudService->delete($contactId);
|
$payload = $request->validated();
|
||||||
|
$contact = $this->crudService->update($contactId, (int) $payload['parent_id'], $payload);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'contact' => new EmergencyContactResource($contact),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(EmergencyContactParentRequest $request, int $contactId): JsonResponse
|
||||||
|
{
|
||||||
|
$parentId = (int) $request->validated()['parent_id'];
|
||||||
|
$this->crudService->delete($contactId, $parentId);
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Administrator;
|
namespace App\Http\Controllers\Api\Administrator;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
use App\Http\Requests\Teachers\TeacherAssignmentDeleteRequest;
|
use App\Http\Requests\Teachers\TeacherAssignmentDeleteRequest;
|
||||||
use App\Http\Requests\Teachers\TeacherAssignmentListRequest;
|
use App\Http\Requests\Teachers\TeacherAssignmentListRequest;
|
||||||
use App\Http\Requests\Teachers\TeacherAssignmentStoreRequest;
|
use App\Http\Requests\Teachers\TeacherAssignmentStoreRequest;
|
||||||
@@ -32,8 +32,13 @@ class TeacherClassAssignmentController extends BaseApiController
|
|||||||
|
|
||||||
public function store(TeacherAssignmentStoreRequest $request): JsonResponse
|
public function store(TeacherAssignmentStoreRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$userId = $this->authenticatedUserIdOrUnauthorized();
|
||||||
|
if ($userId instanceof JsonResponse) {
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
|
|
||||||
$payload = $request->validated();
|
$payload = $request->validated();
|
||||||
$payload['updated_by'] = (int) (auth()->id() ?? 0);
|
$payload['updated_by'] = $userId;
|
||||||
|
|
||||||
$result = $this->assignmentService->assign($payload);
|
$result = $this->assignmentService->assign($payload);
|
||||||
$status = $result['ok'] ? 200 : 422;
|
$status = $result['ok'] ? 200 : 422;
|
||||||
@@ -55,4 +60,14 @@ class TeacherClassAssignmentController extends BaseApiController
|
|||||||
'message' => $result['message'] ?? '',
|
'message' => $result['message'] ?? '',
|
||||||
], $status);
|
], $status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
|
||||||
|
{
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Administrator;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
|
use App\Http\Requests\Administrator\TrophyReportRequest;
|
||||||
|
use App\Services\Administrator\Trophy\TrophyReportService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class TrophyController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(private TrophyReportService $service)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(TrophyReportRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => $this->service->projection(
|
||||||
|
$payload['school_year'] ?? null,
|
||||||
|
isset($payload['percentile']) ? (float) $payload['percentile'] : null
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function winners(TrophyReportRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => $this->service->winners(
|
||||||
|
$payload['school_year'] ?? null,
|
||||||
|
isset($payload['percentile']) ? (float) $payload['percentile'] : null
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function final(TrophyReportRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => $this->service->final(
|
||||||
|
$payload['school_year'] ?? null,
|
||||||
|
isset($payload['percentile']) ? (float) $payload['percentile'] : null
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,9 +46,14 @@ class AssignmentApiController extends Controller
|
|||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$userId = (int) ($request->user()?->id ?? 0);
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
$assignment = $this->assignmentService->storeAssignment(
|
$assignment = $this->assignmentService->storeAssignment(
|
||||||
data: $validator->validated(),
|
data: $validator->validated(),
|
||||||
updatedBy: $request->user()?->id
|
updatedBy: $userId
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|||||||
@@ -23,17 +23,22 @@ class AdminAttendanceApiController extends Controller
|
|||||||
{
|
{
|
||||||
return new DailyAttendanceResource(
|
return new DailyAttendanceResource(
|
||||||
$this->queryService->buildDailyAttendanceData(
|
$this->queryService->buildDailyAttendanceData(
|
||||||
(string)$request->query('semester', $this->attendanceService->currentSemester()),
|
(string) $request->query('semester', $this->attendanceService->currentSemester()),
|
||||||
(string)$request->query('school_year', $this->attendanceService->currentSchoolYear())
|
(string) $request->query('school_year', $this->attendanceService->currentSchoolYear())
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateManagement(UpdateAttendanceManagementRequest $request): JsonResponse
|
public function updateManagement(UpdateAttendanceManagementRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
if ($user === null) {
|
||||||
|
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return response()->json(
|
return response()->json(
|
||||||
$this->attendanceService->updateAttendanceManagement(auth()->user(), $request->validated())
|
$this->attendanceService->updateAttendanceManagement($user, $request->validated())
|
||||||
);
|
);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
return response()->json(['message' => $e->getMessage()], 422);
|
return response()->json(['message' => $e->getMessage()], 422);
|
||||||
@@ -42,9 +47,14 @@ class AdminAttendanceApiController extends Controller
|
|||||||
|
|
||||||
public function addEntry(AdminAddAttendanceEntryRequest $request): JsonResponse
|
public function addEntry(AdminAddAttendanceEntryRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
if ($user === null) {
|
||||||
|
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return response()->json(
|
return response()->json(
|
||||||
$this->attendanceService->adminAddEntry(auth()->user(), $request->validated())
|
$this->attendanceService->adminAddEntry($user, $request->validated())
|
||||||
);
|
);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
return response()->json(['message' => $e->getMessage()], 422);
|
return response()->json(['message' => $e->getMessage()], 422);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Api\Attendance;
|
namespace App\Http\Controllers\Api\Attendance;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\ApplicationUrlService;
|
||||||
use App\Services\AttendanceCommentTemplateService;
|
use App\Services\AttendanceCommentTemplateService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -11,8 +12,16 @@ use Illuminate\Support\Facades\Validator;
|
|||||||
class AttendanceCommentTemplateController extends Controller
|
class AttendanceCommentTemplateController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected AttendanceCommentTemplateService $service
|
protected AttendanceCommentTemplateService $service,
|
||||||
) {
|
protected ApplicationUrlService $urls,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function bootstrapUrls(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'status' => true,
|
||||||
|
'data' => $this->urls->attendanceCommentTemplateBootstrapData(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
@@ -36,6 +45,7 @@ class AttendanceCommentTemplateController extends Controller
|
|||||||
) === true;
|
) === true;
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
'templates' => $this->service->list($activeOnly),
|
'templates' => $this->service->list($activeOnly),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -65,7 +75,7 @@ class AttendanceCommentTemplateController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$data = $validator->validated();
|
$data = $validator->validated();
|
||||||
if (!array_key_exists('is_active', $data)) {
|
if (! array_key_exists('is_active', $data)) {
|
||||||
$data['is_active'] = true;
|
$data['is_active'] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,4 +129,76 @@ class AttendanceCommentTemplateController extends Controller
|
|||||||
'message' => 'Template deleted successfully.',
|
'message' => 'Template deleted successfully.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function legacySave(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$id = $request->input('id');
|
||||||
|
$hasId = $id !== null && $id !== '' && (int) $id > 0;
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'min_score' => ['required', 'integer', 'min:0', 'max:100'],
|
||||||
|
'max_score' => ['required', 'integer', 'min:0', 'max:100'],
|
||||||
|
'template_text' => ['required', 'string', 'max:5000'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$validator = Validator::make($request->all(), $rules);
|
||||||
|
|
||||||
|
$validator->after(function ($v) use ($request) {
|
||||||
|
$min = (int) $request->input('min_score');
|
||||||
|
$max = (int) $request->input('max_score');
|
||||||
|
if ($max < $min) {
|
||||||
|
$v->errors()->add('max_score', 'The max_score field must be greater than or equal to min_score.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => $validator->errors(),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$isActive = $this->legacyIsActive($request);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'min_score' => (int) $request->input('min_score'),
|
||||||
|
'max_score' => (int) $request->input('max_score'),
|
||||||
|
'template_text' => (string) $request->input('template_text'),
|
||||||
|
'is_active' => $isActive,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($hasId) {
|
||||||
|
$this->service->update((int) $id, $payload);
|
||||||
|
} else {
|
||||||
|
$this->service->create($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['status' => 'success']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function legacyDelete(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$id = $request->input('id');
|
||||||
|
if ($id === null || $id === '' || (int) $id <= 0) {
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'ID not provided',
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->service->delete((int) $id);
|
||||||
|
|
||||||
|
return response()->json(['status' => 'success']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function legacyIsActive(Request $request): bool
|
||||||
|
{
|
||||||
|
$raw = $request->input('is_active');
|
||||||
|
|
||||||
|
return $raw === 'on'
|
||||||
|
|| $raw === 1
|
||||||
|
|| $raw === '1'
|
||||||
|
|| $raw === true
|
||||||
|
|| $raw === 'true';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Attendance;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
|
use App\Models\Configuration;
|
||||||
|
use App\Models\EarlyDismissalSignature;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EarlyDismissalsController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/attendance/early-dismissals
|
||||||
|
* Returns early dismissal records grouped by report_date, plus a signature map.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$schoolYear = $request->query('school_year');
|
||||||
|
$semester = $request->query('semester');
|
||||||
|
|
||||||
|
$query = DB::table('parent_attendance_reports as par')
|
||||||
|
->select([
|
||||||
|
'par.id',
|
||||||
|
'par.report_date',
|
||||||
|
'par.dismiss_time',
|
||||||
|
'par.reason',
|
||||||
|
'par.status',
|
||||||
|
'par.school_year',
|
||||||
|
'par.semester',
|
||||||
|
'par.class_section_id',
|
||||||
|
's.firstname',
|
||||||
|
's.lastname',
|
||||||
|
'cs.class_section_name',
|
||||||
|
])
|
||||||
|
->join('students as s', 's.id', '=', 'par.student_id')
|
||||||
|
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'par.class_section_id')
|
||||||
|
->where('par.type', 'early_dismissal')
|
||||||
|
->orderBy('par.report_date', 'desc')
|
||||||
|
->orderByRaw("CONCAT(s.firstname, ' ', s.lastname) ASC");
|
||||||
|
|
||||||
|
if ($schoolYear) {
|
||||||
|
$query->where('par.school_year', $schoolYear);
|
||||||
|
}
|
||||||
|
if ($semester) {
|
||||||
|
$query->where('par.semester', $semester);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $query->get();
|
||||||
|
|
||||||
|
$groups = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$date = $row->report_date ?? 'Unknown';
|
||||||
|
$groups[$date][] = [
|
||||||
|
'id' => $row->id,
|
||||||
|
'firstname' => $row->firstname,
|
||||||
|
'lastname' => $row->lastname,
|
||||||
|
'class_section_name' => $row->class_section_name,
|
||||||
|
'dismiss_time' => $row->dismiss_time,
|
||||||
|
'reason' => $row->reason,
|
||||||
|
'status' => $row->status,
|
||||||
|
'school_year' => $row->school_year,
|
||||||
|
'semester' => $row->semester,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$dates = array_keys($groups);
|
||||||
|
$signatures = EarlyDismissalSignature::query()
|
||||||
|
->whereIn('report_date', $dates)
|
||||||
|
->get(['report_date', 'filename', 'original_name']);
|
||||||
|
|
||||||
|
$signatureByDate = [];
|
||||||
|
foreach ($signatures as $sig) {
|
||||||
|
$signatureByDate[(string) $sig->report_date] = [
|
||||||
|
'filename' => $sig->filename,
|
||||||
|
'original_name' => $sig->original_name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'groups' => $groups,
|
||||||
|
'signatureByDate' => $signatureByDate,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/attendance/early-dismissals/student-options
|
||||||
|
* Returns active students with their current class section.
|
||||||
|
*/
|
||||||
|
public function studentOptions(): JsonResponse
|
||||||
|
{
|
||||||
|
$students = DB::table('students as s')
|
||||||
|
->select([
|
||||||
|
's.id',
|
||||||
|
's.firstname',
|
||||||
|
's.lastname',
|
||||||
|
'cs.class_section_name',
|
||||||
|
])
|
||||||
|
->leftJoin('student_class as sc', function ($j) {
|
||||||
|
$j->on('sc.student_id', '=', 's.id')
|
||||||
|
->where('sc.school_year', '=', Configuration::getConfigValueByKey('school_year'));
|
||||||
|
})
|
||||||
|
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||||
|
->where('s.is_active', 1)
|
||||||
|
->orderByRaw("CONCAT(s.firstname, ' ', s.lastname) ASC")
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $this->success(['students' => $students]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/attendance/early-dismissals
|
||||||
|
* Creates a new early dismissal record.
|
||||||
|
*/
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'student_id' => ['required', 'integer', 'min:1', 'exists:students,id'],
|
||||||
|
'date' => ['required', 'date_format:Y-m-d'],
|
||||||
|
'dismiss_time' => ['required', 'date_format:H:i'],
|
||||||
|
'reason' => ['nullable', 'string', 'max:2000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return $this->respondValidationError($validator->errors()->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $validator->validated();
|
||||||
|
|
||||||
|
$schoolYear = (string) Configuration::getConfigValueByKey('school_year');
|
||||||
|
$semester = (string) Configuration::getConfigValueByKey('semester');
|
||||||
|
|
||||||
|
$sc = DB::table('student_class')
|
||||||
|
->where('student_id', $data['student_id'])
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->orderBy('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$classSectionId = $sc?->class_section_id ?? null;
|
||||||
|
|
||||||
|
$parentId = DB::table('students')
|
||||||
|
->where('id', $data['student_id'])
|
||||||
|
->value('parent_id') ?? Auth::id();
|
||||||
|
|
||||||
|
DB::table('parent_attendance_reports')->insert([
|
||||||
|
'parent_id' => $parentId ?? Auth::id(),
|
||||||
|
'student_id' => $data['student_id'],
|
||||||
|
'class_section_id' => $classSectionId,
|
||||||
|
'report_date' => $data['date'],
|
||||||
|
'type' => 'early_dismissal',
|
||||||
|
'dismiss_time' => $data['dismiss_time'],
|
||||||
|
'reason' => $data['reason'] ?? null,
|
||||||
|
'semester' => $semester,
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'status' => 'new',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->success([], 'Early dismissal saved.', Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/attendance/early-dismissals/signature
|
||||||
|
* Uploads (or replaces) the signature file for a report date.
|
||||||
|
*/
|
||||||
|
public function uploadSignature(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'report_date' => ['required', 'date_format:Y-m-d'],
|
||||||
|
'school_year' => ['nullable', 'string', 'max:16'],
|
||||||
|
'semester' => ['nullable', 'string', 'max:32'],
|
||||||
|
'signature_file' => ['required', 'file', 'mimes:jpg,jpeg,png,webp,gif,pdf', 'max:5120'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return $this->respondValidationError($validator->errors()->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $request->file('signature_file');
|
||||||
|
$reportDate = $request->input('report_date');
|
||||||
|
$schoolYear = $request->input('school_year') ?: (string) Configuration::getConfigValueByKey('school_year');
|
||||||
|
$semester = $request->input('semester') ?: (string) Configuration::getConfigValueByKey('semester');
|
||||||
|
|
||||||
|
$dir = storage_path('uploads/early_dismissal_signatures');
|
||||||
|
if (! is_dir($dir)) {
|
||||||
|
mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = EarlyDismissalSignature::query()
|
||||||
|
->where('report_date', $reportDate)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->where('semester', $semester)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing?->filename && file_exists($dir.'/'.$existing->filename)) {
|
||||||
|
unlink($dir.'/'.$existing->filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = uniqid('sig_', true).'.'.$file->getClientOriginalExtension();
|
||||||
|
$file->move($dir, $filename);
|
||||||
|
|
||||||
|
EarlyDismissalSignature::updateOrCreate(
|
||||||
|
['report_date' => $reportDate, 'school_year' => $schoolYear, 'semester' => $semester],
|
||||||
|
[
|
||||||
|
'filename' => $filename,
|
||||||
|
'original_name' => $file->getClientOriginalName(),
|
||||||
|
'mime_type' => $file->getClientMimeType(),
|
||||||
|
'file_size' => $file->getSize(),
|
||||||
|
'uploaded_by' => Auth::id(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->success(['filename' => $filename], 'Signature uploaded.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Attendance;
|
namespace App\Http\Controllers\Api\Attendance;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
use App\Http\Requests\Attendance\LateSlipLogIndexRequest;
|
use App\Http\Requests\Attendance\LateSlipLogIndexRequest;
|
||||||
use App\Http\Resources\Attendance\LateSlipLogCollection;
|
use App\Http\Resources\Attendance\LateSlipLogCollection;
|
||||||
use App\Http\Resources\Attendance\LateSlipLogResource;
|
use App\Http\Resources\Attendance\LateSlipLogResource;
|
||||||
@@ -42,7 +42,7 @@ class LateSlipLogsController extends BaseApiController
|
|||||||
public function show(int $id): JsonResponse
|
public function show(int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$log = $this->queryService->find($id);
|
$log = $this->queryService->find($id);
|
||||||
if (!$log) {
|
if (! $log) {
|
||||||
return $this->error('Late slip log not found.', Response::HTTP_NOT_FOUND);
|
return $this->error('Late slip log not found.', Response::HTTP_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ class LateSlipLogsController extends BaseApiController
|
|||||||
public function destroy(int $id): JsonResponse
|
public function destroy(int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$log = $this->queryService->find($id);
|
$log = $this->queryService->find($id);
|
||||||
if (!$log) {
|
if (! $log) {
|
||||||
return $this->error('Late slip log not found.', Response::HTTP_NOT_FOUND);
|
return $this->error('Late slip log not found.', Response::HTTP_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,11 +65,12 @@ class LateSlipLogsController extends BaseApiController
|
|||||||
try {
|
try {
|
||||||
$deleted = $this->commandService->delete($log);
|
$deleted = $this->commandService->delete($log);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('Late slip log delete failed: ' . $e->getMessage());
|
Log::error('Late slip log delete failed: '.$e->getMessage());
|
||||||
|
|
||||||
return $this->error('Unable to delete late slip log.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
return $this->error('Unable to delete late slip log.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$deleted) {
|
if (! $deleted) {
|
||||||
return $this->error('Unable to delete late slip log.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
return $this->error('Unable to delete late slip log.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ class StaffAttendanceApiController extends Controller
|
|||||||
{
|
{
|
||||||
return new StaffMonthResource(
|
return new StaffMonthResource(
|
||||||
$this->staffAttendanceService->monthData(
|
$this->staffAttendanceService->monthData(
|
||||||
(string)$request->query('semester'),
|
(string) $request->query('semester'),
|
||||||
(string)$request->query('school_year')
|
(string) $request->query('school_year')
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -33,18 +33,23 @@ class StaffAttendanceApiController extends Controller
|
|||||||
{
|
{
|
||||||
return new AdminAttendanceResource(
|
return new AdminAttendanceResource(
|
||||||
$this->staffAttendanceService->adminsAttendanceData(
|
$this->staffAttendanceService->adminsAttendanceData(
|
||||||
(string)$request->query('date'),
|
(string) $request->query('date'),
|
||||||
(string)$request->query('semester'),
|
(string) $request->query('semester'),
|
||||||
(string)$request->query('school_year')
|
(string) $request->query('school_year')
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveAdmins(SaveAdminAttendanceRequest $request): JsonResponse
|
public function saveAdmins(SaveAdminAttendanceRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
if ($user === null) {
|
||||||
|
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return response()->json(
|
return response()->json(
|
||||||
$this->staffAttendanceService->saveAdmins(auth()->user(), $request->validated())
|
$this->staffAttendanceService->saveAdmins($user, $request->validated())
|
||||||
);
|
);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
return response()->json(['message' => $e->getMessage()], 422);
|
return response()->json(['message' => $e->getMessage()], 422);
|
||||||
@@ -53,9 +58,14 @@ class StaffAttendanceApiController extends Controller
|
|||||||
|
|
||||||
public function saveCell(SaveStaffAttendanceCellRequest $request): JsonResponse
|
public function saveCell(SaveStaffAttendanceCellRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
if ($user === null) {
|
||||||
|
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return response()->json(
|
return response()->json(
|
||||||
$this->staffAttendanceService->saveCell(auth()->user(), $request->validated())
|
$this->staffAttendanceService->saveCell($user, $request->validated())
|
||||||
);
|
);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
return response()->json(['message' => $e->getMessage()], 422);
|
return response()->json(['message' => $e->getMessage()], 422);
|
||||||
@@ -65,9 +75,9 @@ class StaffAttendanceApiController extends Controller
|
|||||||
public function monthCsv(Request $request): StreamedResponse
|
public function monthCsv(Request $request): StreamedResponse
|
||||||
{
|
{
|
||||||
return $this->staffAttendanceService->monthCsv(
|
return $this->staffAttendanceService->monthCsv(
|
||||||
(string)$request->query('semester'),
|
(string) $request->query('semester'),
|
||||||
(string)$request->query('school_year'),
|
(string) $request->query('school_year'),
|
||||||
(string)$request->query('scope')
|
(string) $request->query('scope')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,10 +21,10 @@ class TeacherAttendanceApiController extends Controller
|
|||||||
|
|
||||||
public function grid(Request $request): TeacherGridResource
|
public function grid(Request $request): TeacherGridResource
|
||||||
{
|
{
|
||||||
$semester = (string)($request->query('semester') ?: $this->attendanceService->currentSemester());
|
$semester = (string) ($request->query('semester') ?: $this->attendanceService->currentSemester());
|
||||||
$schoolYear = (string)($request->query('school_year') ?: $this->attendanceService->currentSchoolYear());
|
$schoolYear = (string) ($request->query('school_year') ?: $this->attendanceService->currentSchoolYear());
|
||||||
$date = (string)($request->query('date') ?: now()->toDateString());
|
$date = (string) ($request->query('date') ?: now()->toDateString());
|
||||||
$sectionCode = (int)$request->query('class_section_id', 0);
|
$sectionCode = (int) $request->query('class_section_id', 0);
|
||||||
|
|
||||||
return new TeacherGridResource(
|
return new TeacherGridResource(
|
||||||
$this->queryService->teacherGrid($semester, $schoolYear, $date, $sectionCode)
|
$this->queryService->teacherGrid($semester, $schoolYear, $date, $sectionCode)
|
||||||
@@ -33,11 +33,18 @@ class TeacherAttendanceApiController extends Controller
|
|||||||
|
|
||||||
public function form(Request $request): JsonResponse|TeacherAttendanceFormResource
|
public function form(Request $request): JsonResponse|TeacherAttendanceFormResource
|
||||||
{
|
{
|
||||||
|
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||||
|
if ($guard instanceof JsonResponse) {
|
||||||
|
return $guard;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return new TeacherAttendanceFormResource(
|
return new TeacherAttendanceFormResource(
|
||||||
$this->queryService->teacherAttendanceFormData(
|
$this->queryService->teacherAttendanceFormData(
|
||||||
auth()->id(),
|
$guard,
|
||||||
(int)$request->query('class_section_id', 0)
|
(int) $request->query('class_section_id', 0),
|
||||||
|
$request->query('school_year'),
|
||||||
|
$request->query('semester')
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
@@ -47,12 +54,27 @@ class TeacherAttendanceApiController extends Controller
|
|||||||
|
|
||||||
public function submit(TeacherSubmitAttendanceRequest $request): JsonResponse
|
public function submit(TeacherSubmitAttendanceRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
if ($user === null) {
|
||||||
|
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return response()->json(
|
return response()->json(
|
||||||
$this->attendanceService->submitTeacherAttendance(auth()->user(), $request->validated())
|
$this->attendanceService->submitTeacherAttendance($user, $request->validated())
|
||||||
);
|
);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
return response()->json(['message' => $e->getMessage()], 422);
|
return response()->json(['message' => $e->getMessage()], 422);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
|
||||||
|
{
|
||||||
|
$userId = (int) (auth('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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,7 @@ class AttendanceTrackingController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected AttendanceTrackingService $service
|
protected AttendanceTrackingService $service
|
||||||
) {
|
) {}
|
||||||
}
|
|
||||||
|
|
||||||
public function pendingViolations(Request $request): JsonResponse
|
public function pendingViolations(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -61,6 +60,11 @@ class AttendanceTrackingController extends Controller
|
|||||||
|
|
||||||
public function record(Request $request): JsonResponse
|
public function record(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$guard = $this->forbidLowPrivilegeActor();
|
||||||
|
if ($guard instanceof JsonResponse) {
|
||||||
|
return $guard;
|
||||||
|
}
|
||||||
|
|
||||||
$validator = Validator::make($request->all(), [
|
$validator = Validator::make($request->all(), [
|
||||||
'student_id' => ['required', 'integer', 'min:1'],
|
'student_id' => ['required', 'integer', 'min:1'],
|
||||||
'date' => ['required', 'date_format:Y-m-d'],
|
'date' => ['required', 'date_format:Y-m-d'],
|
||||||
@@ -88,6 +92,11 @@ class AttendanceTrackingController extends Controller
|
|||||||
|
|
||||||
public function sendAutoEmails(Request $request): JsonResponse
|
public function sendAutoEmails(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$guard = $this->forbidLowPrivilegeActor(adminOnly: true);
|
||||||
|
if ($guard instanceof JsonResponse) {
|
||||||
|
return $guard;
|
||||||
|
}
|
||||||
|
|
||||||
$result = $this->service->sendAutoEmails($request->input('variant'));
|
$result = $this->service->sendAutoEmails($request->input('variant'));
|
||||||
|
|
||||||
return response()->json($result);
|
return response()->json($result);
|
||||||
@@ -125,6 +134,11 @@ class AttendanceTrackingController extends Controller
|
|||||||
|
|
||||||
public function sendManualEmail(Request $request): JsonResponse
|
public function sendManualEmail(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$guard = $this->forbidLowPrivilegeActor();
|
||||||
|
if ($guard instanceof JsonResponse) {
|
||||||
|
return $guard;
|
||||||
|
}
|
||||||
|
|
||||||
$validator = Validator::make($request->all(), [
|
$validator = Validator::make($request->all(), [
|
||||||
'student_id' => ['required', 'integer', 'min:1'],
|
'student_id' => ['required', 'integer', 'min:1'],
|
||||||
'to' => ['required', 'email'],
|
'to' => ['required', 'email'],
|
||||||
@@ -174,6 +188,11 @@ class AttendanceTrackingController extends Controller
|
|||||||
|
|
||||||
public function saveNotificationNote(Request $request): JsonResponse
|
public function saveNotificationNote(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$guard = $this->forbidLowPrivilegeActor();
|
||||||
|
if ($guard instanceof JsonResponse) {
|
||||||
|
return $guard;
|
||||||
|
}
|
||||||
|
|
||||||
$validator = Validator::make($request->all(), [
|
$validator = Validator::make($request->all(), [
|
||||||
'student_id' => ['required', 'integer', 'min:1'],
|
'student_id' => ['required', 'integer', 'min:1'],
|
||||||
'code' => ['required', 'string'],
|
'code' => ['required', 'string'],
|
||||||
@@ -198,4 +217,33 @@ class AttendanceTrackingController extends Controller
|
|||||||
$result['status'] ?? 200
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,63 +2,144 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Auth;
|
namespace App\Http\Controllers\Api\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\ApiLoginSecurityService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Log;
|
||||||
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
|
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
|
||||||
|
|
||||||
class AuthController extends BaseApiController
|
class AuthController extends BaseApiController
|
||||||
{
|
{
|
||||||
public function login(Request $request): JsonResponse
|
public function login(Request $request, ApiLoginSecurityService $security): JsonResponse
|
||||||
{
|
{
|
||||||
$validator = Validator::make($request->all(), [
|
$payload = $request->all();
|
||||||
'email' => ['required', 'email'],
|
if ($request->isJson() && ($payload === [] || $payload === null)) {
|
||||||
'password' => ['required', 'string'],
|
$decoded = json_decode((string) $request->getContent(), true);
|
||||||
]);
|
$payload = is_array($decoded) ? $decoded : [];
|
||||||
|
|
||||||
if ($validator->fails()) {
|
|
||||||
return $this->respondValidationError($validator->errors()->toArray());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$email = (string) $request->input('email');
|
$email = strtolower(trim((string) ($payload['email'] ?? '')));
|
||||||
$password = (string) $request->input('password');
|
$password = (string) ($payload['password'] ?? '');
|
||||||
|
|
||||||
$user = User::query()->where('email', $email)->first();
|
if ($email === '' || $password === '') {
|
||||||
if (!$user || !ci_password_verify($password, (string) $user->password)) {
|
Log::notice('api_login: missing credentials', [
|
||||||
return $this->respondError('Invalid credentials.', 401);
|
'ip' => (string) $request->ip(),
|
||||||
|
'email' => $this->maskEmail($email),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => false,
|
||||||
|
'message' => 'Email and password are required.',
|
||||||
|
], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) ($user->is_verified ?? 0) !== 1) {
|
$ip = $request->ip();
|
||||||
return $this->respondError('Account not verified.', 403);
|
if ($security->isIpBlocked((string) $ip)) {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (strcasecmp((string) ($user->status ?? ''), 'Active') !== 0) {
|
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
|
||||||
return $this->respondError('Account is inactive.', 403);
|
if (! $user) {
|
||||||
|
$security->logIpAttempt((string) $ip);
|
||||||
|
Log::notice('api_login: unknown email', [
|
||||||
|
'ip' => (string) $ip,
|
||||||
|
'email' => $this->maskEmail($email),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => false,
|
||||||
|
'message' => 'Invalid email or password.',
|
||||||
|
], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
$jwtToken = JWTAuth::fromUser($user);
|
if (blank($user->password)) {
|
||||||
$sanctumToken = $user->createToken('api')->plainTextToken;
|
$security->logIpAttempt((string) $ip);
|
||||||
|
Log::notice('api_login: password missing', [
|
||||||
|
'ip' => (string) $ip,
|
||||||
|
'email' => $this->maskEmail($email),
|
||||||
|
'user_id' => (int) $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->respondSuccess([
|
return response()->json([
|
||||||
'user' => [
|
'status' => false,
|
||||||
'id' => (int) $user->id,
|
'message' => 'Invalid email or password.',
|
||||||
'firstname' => $user->firstname,
|
], 401);
|
||||||
'lastname' => $user->lastname,
|
}
|
||||||
'email' => $user->email,
|
|
||||||
],
|
if (! verify_stored_password($password, (string) $user->password)) {
|
||||||
'jwt' => [
|
$security->handleFailedLogin($user, $email, (string) $ip);
|
||||||
'access_token' => $jwtToken,
|
Log::notice('api_login: password mismatch', [
|
||||||
'token_type' => 'bearer',
|
'ip' => (string) $ip,
|
||||||
'expires_in' => (int) config('jwt.ttl') * 60,
|
'email' => $this->maskEmail($email),
|
||||||
],
|
'user_id' => (int) $user->id,
|
||||||
'sanctum' => [
|
'failed_attempts' => (int) ($user->fresh()?->failed_attempts ?? 0),
|
||||||
'access_token' => $sanctumToken,
|
]);
|
||||||
'token_type' => 'bearer',
|
|
||||||
],
|
return response()->json([
|
||||||
], 'Authenticated.');
|
'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
|
public function refresh(Request $request): JsonResponse
|
||||||
@@ -79,20 +160,38 @@ class AuthController extends BaseApiController
|
|||||||
public function me(): JsonResponse
|
public function me(): JsonResponse
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
if (!$user) {
|
if (! $user) {
|
||||||
return $this->respondError('Unauthorized.', 401);
|
return $this->respondError('Unauthorized.', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->respondSuccess([
|
$teacherContext = $user->teacherSessionContext();
|
||||||
|
|
||||||
|
$payload = [
|
||||||
'id' => (int) $user->id,
|
'id' => (int) $user->id,
|
||||||
'firstname' => $user->firstname,
|
'firstname' => $user->firstname,
|
||||||
'lastname' => $user->lastname,
|
'lastname' => $user->lastname,
|
||||||
'email' => $user->email,
|
'email' => $user->email,
|
||||||
], 'OK');
|
'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
|
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();
|
$token = $request->bearerToken();
|
||||||
if ($token && substr_count($token, '.') === 2) {
|
if ($token && substr_count($token, '.') === 2) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Auth;
|
namespace App\Http\Controllers\Api\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
use App\Http\Requests\Security\IpBanIndexRequest;
|
use App\Http\Requests\Security\IpBanIndexRequest;
|
||||||
use App\Http\Requests\Security\IpBanRequest;
|
use App\Http\Requests\Security\IpBanRequest;
|
||||||
use App\Http\Requests\Security\IpUnbanRequest;
|
use App\Http\Requests\Security\IpUnbanRequest;
|
||||||
@@ -44,7 +44,7 @@ class IpBanController extends BaseApiController
|
|||||||
public function show(int $id): JsonResponse
|
public function show(int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$ban = $this->queryService->find($id);
|
$ban = $this->queryService->find($id);
|
||||||
if (!$ban) {
|
if (! $ban) {
|
||||||
return $this->error('IP record not found.', Response::HTTP_NOT_FOUND);
|
return $this->error('IP record not found.', Response::HTTP_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,11 +67,12 @@ class IpBanController extends BaseApiController
|
|||||||
try {
|
try {
|
||||||
$ban = $this->commandService->banNow($id ? (int) $id : null, $ip ? (string) $ip : null, $hours);
|
$ban = $this->commandService->banNow($id ? (int) $id : null, $ip ? (string) $ip : null, $hours);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('IP ban failed: ' . $e->getMessage());
|
Log::error('IP ban failed: '.$e->getMessage());
|
||||||
|
|
||||||
return $this->error('Unable to ban IP.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
return $this->error('Unable to ban IP.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$ban) {
|
if (! $ban) {
|
||||||
return $this->error('IP record not found.', Response::HTTP_NOT_FOUND);
|
return $this->error('IP record not found.', Response::HTTP_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,9 +91,10 @@ class IpBanController extends BaseApiController
|
|||||||
try {
|
try {
|
||||||
if ($all) {
|
if ($all) {
|
||||||
$count = $this->commandService->unbanAll();
|
$count = $this->commandService->unbanAll();
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'count' => $count,
|
'count' => $count,
|
||||||
], $count . ' IP(s) unbanned.');
|
], $count.' IP(s) unbanned.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$ban = $this->commandService->unbanOne(
|
$ban = $this->commandService->unbanOne(
|
||||||
@@ -100,11 +102,12 @@ class IpBanController extends BaseApiController
|
|||||||
$validated['ip'] ?? null
|
$validated['ip'] ?? null
|
||||||
);
|
);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('IP unban failed: ' . $e->getMessage());
|
Log::error('IP unban failed: '.$e->getMessage());
|
||||||
|
|
||||||
return $this->error('Unable to unban IP.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
return $this->error('Unable to unban IP.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$ban) {
|
if (! $ban) {
|
||||||
return $this->error('IP record not found.', Response::HTTP_NOT_FOUND);
|
return $this->error('IP record not found.', Response::HTTP_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Auth;
|
namespace App\Http\Controllers\Api\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
|
use App\Http\Requests\Auth\RegisterRequest;
|
||||||
use App\Http\Resources\Auth\RegisterResource;
|
use App\Http\Resources\Auth\RegisterResource;
|
||||||
|
use App\Services\Auth\ApiRegistrationService;
|
||||||
use App\Services\Auth\RegistrationCaptchaService;
|
use App\Services\Auth\RegistrationCaptchaService;
|
||||||
use App\Services\Auth\RegistrationService;
|
use App\Services\Auth\RegistrationService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -26,11 +28,17 @@ class RegisterController extends BaseApiController
|
|||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'captcha' => $captcha,
|
'captcha' => $captcha,
|
||||||
|
'user' => null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
// Support direct JSON registration when the SPA skips the captcha bootstrap step.
|
||||||
|
if (! $request->has('captcha') && $request->filled('password')) {
|
||||||
|
return app(ApiRegistrationService::class)->registerResponse($request);
|
||||||
|
}
|
||||||
|
|
||||||
if (app()->runningUnitTests() && $request->header('X-Debug-Request') === '1') {
|
if (app()->runningUnitTests() && $request->header('X-Debug-Request') === '1') {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
@@ -44,7 +52,7 @@ class RegisterController extends BaseApiController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$validator = Validator::make($request->all(), \App\Http\Requests\Auth\RegisterRequest::ruleset());
|
$validator = Validator::make($request->all(), RegisterRequest::ruleset());
|
||||||
if ($validator->fails()) {
|
if ($validator->fails()) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Validation failed.',
|
'message' => 'Validation failed.',
|
||||||
|
|||||||
@@ -0,0 +1,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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,19 +17,34 @@ class BadgeController extends Controller
|
|||||||
protected BadgePdfService $badgePdfService,
|
protected BadgePdfService $badgePdfService,
|
||||||
protected BadgePrintLogService $badgePrintLogService,
|
protected BadgePrintLogService $badgePrintLogService,
|
||||||
protected BadgeFormDataService $badgeFormDataService
|
protected BadgeFormDataService $badgeFormDataService
|
||||||
) {
|
) {}
|
||||||
}
|
|
||||||
|
|
||||||
public function generatePdf(Request $request)
|
public function generatePdf(Request $request)
|
||||||
{
|
{
|
||||||
|
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||||
|
if ($guard instanceof JsonResponse) {
|
||||||
|
return $guard;
|
||||||
|
}
|
||||||
|
|
||||||
$validator = Validator::make($request->all(), [
|
$validator = Validator::make($request->all(), [
|
||||||
'user_ids' => ['required', 'array', 'min:1'],
|
'user_ids' => ['nullable', 'array'],
|
||||||
'user_ids.*' => ['integer', 'min:1'],
|
'user_ids.*' => ['integer', 'exists:users,id'],
|
||||||
|
'student_ids' => ['nullable', 'array'],
|
||||||
|
'student_ids.*' => ['integer', 'exists:students,id'],
|
||||||
'school_year' => ['nullable', 'string', 'max:50'],
|
'school_year' => ['nullable', 'string', 'max:50'],
|
||||||
'roles' => ['nullable', 'array'],
|
'roles' => ['nullable', 'array'],
|
||||||
'classes' => ['nullable', 'array'],
|
'classes' => ['nullable', 'array'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$validator->after(function ($validator): void {
|
||||||
|
$data = $validator->getData();
|
||||||
|
$users = array_values(array_filter((array) ($data['user_ids'] ?? []), static fn ($v) => (int) $v > 0));
|
||||||
|
$students = array_values(array_filter((array) ($data['student_ids'] ?? []), static fn ($v) => (int) $v > 0));
|
||||||
|
if ($users === [] && $students === []) {
|
||||||
|
$validator->errors()->add('user_ids', 'Provide at least one user id or student id.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if ($validator->fails()) {
|
if ($validator->fails()) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Validation failed.',
|
'message' => 'Validation failed.',
|
||||||
@@ -40,11 +55,12 @@ class BadgeController extends Controller
|
|||||||
$data = $validator->validated();
|
$data = $validator->validated();
|
||||||
|
|
||||||
return $this->badgePdfService->generate(
|
return $this->badgePdfService->generate(
|
||||||
|
studentIds: $data['student_ids'] ?? [],
|
||||||
userIds: $data['user_ids'] ?? [],
|
userIds: $data['user_ids'] ?? [],
|
||||||
schoolYear: $data['school_year'] ?? null,
|
schoolYear: $data['school_year'] ?? null,
|
||||||
rolesMap: $data['roles'] ?? [],
|
rolesMap: $data['roles'] ?? [],
|
||||||
classesMap: $data['classes'] ?? [],
|
classesMap: $data['classes'] ?? [],
|
||||||
actorId: optional($request->user())->id
|
actorId: $guard
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +70,7 @@ class BadgeController extends Controller
|
|||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'data' => $this->badgePrintLogService->getStatus(
|
'data' => $this->badgePrintLogService->getStatus(
|
||||||
$this->parseUserIds($request),
|
$this->parseCombinedBadgeIds($request),
|
||||||
$request->input('school_year')
|
$request->input('school_year')
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -69,14 +85,30 @@ class BadgeController extends Controller
|
|||||||
public function logPrint(Request $request): JsonResponse
|
public function logPrint(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||||
|
if ($guard instanceof JsonResponse) {
|
||||||
|
return $guard;
|
||||||
|
}
|
||||||
|
|
||||||
$validator = Validator::make($request->all(), [
|
$validator = Validator::make($request->all(), [
|
||||||
'user_ids' => ['required', 'array', 'min:1'],
|
'user_ids' => ['nullable', 'array'],
|
||||||
'user_ids.*' => ['integer', 'min:1'],
|
'user_ids.*' => ['integer', 'exists:users,id'],
|
||||||
|
'student_ids' => ['nullable', 'array'],
|
||||||
|
'student_ids.*' => ['integer', 'exists:students,id'],
|
||||||
'school_year' => ['nullable', 'string', 'max:50'],
|
'school_year' => ['nullable', 'string', 'max:50'],
|
||||||
'roles' => ['nullable', 'array'],
|
'roles' => ['nullable', 'array'],
|
||||||
'classes' => ['nullable', 'array'],
|
'classes' => ['nullable', 'array'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$validator->after(function ($validator): void {
|
||||||
|
$data = $validator->getData();
|
||||||
|
$users = array_values(array_filter((array) ($data['user_ids'] ?? []), static fn ($v) => (int) $v > 0));
|
||||||
|
$students = array_values(array_filter((array) ($data['student_ids'] ?? []), static fn ($v) => (int) $v > 0));
|
||||||
|
if ($users === [] && $students === []) {
|
||||||
|
$validator->errors()->add('user_ids', 'Provide at least one user id or student id.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if ($validator->fails()) {
|
if ($validator->fails()) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Validation failed.',
|
'message' => 'Validation failed.',
|
||||||
@@ -87,8 +119,11 @@ class BadgeController extends Controller
|
|||||||
$data = $validator->validated();
|
$data = $validator->validated();
|
||||||
|
|
||||||
$inserted = $this->badgePrintLogService->log(
|
$inserted = $this->badgePrintLogService->log(
|
||||||
userIds: $data['user_ids'] ?? [],
|
userIds: array_values(array_unique(array_merge(
|
||||||
actorId: optional($request->user())->id,
|
$data['user_ids'] ?? [],
|
||||||
|
$data['student_ids'] ?? []
|
||||||
|
))),
|
||||||
|
actorId: $guard,
|
||||||
schoolYear: $data['school_year'] ?? null,
|
schoolYear: $data['school_year'] ?? null,
|
||||||
rolesMap: $data['roles'] ?? [],
|
rolesMap: $data['roles'] ?? [],
|
||||||
classesMap: $data['classes'] ?? []
|
classesMap: $data['classes'] ?? []
|
||||||
@@ -112,24 +147,73 @@ class BadgeController extends Controller
|
|||||||
'ok' => true,
|
'ok' => true,
|
||||||
'data' => $this->badgeFormDataService->build(
|
'data' => $this->badgeFormDataService->build(
|
||||||
schoolYear: $request->input('school_year'),
|
schoolYear: $request->input('school_year'),
|
||||||
selectedUserIds: $this->parseUserIds($request),
|
selectedUserIds: $this->parseStaffUserIds($request),
|
||||||
|
selectedStudentIds: $this->parseStudentIds($request),
|
||||||
activeRole: $request->input('active_role')
|
activeRole: $request->input('active_role')
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function parseUserIds(Request $request): array
|
/**
|
||||||
|
* Staff user IDs for the badge picker (`users.id`).
|
||||||
|
*
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
private function parseStaffUserIds(Request $request): array
|
||||||
{
|
{
|
||||||
$userIds = $request->input('user_ids', $request->query('user_ids', []));
|
$ids = $request->input('user_ids', $request->query('user_ids', []));
|
||||||
|
|
||||||
if (is_string($userIds)) {
|
if (is_string($ids)) {
|
||||||
$userIds = array_filter(array_map('trim', explode(',', $userIds)), 'strlen');
|
$ids = array_filter(array_map('trim', explode(',', $ids)), 'strlen');
|
||||||
} elseif (!is_array($userIds)) {
|
} elseif (! is_array($ids)) {
|
||||||
$userIds = $userIds ? [$userIds] : [];
|
$ids = $ids ? [$ids] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$userIds = array_values(array_unique(array_map(static fn ($v) => (int) $v, $userIds)));
|
$ids = array_values(array_unique(array_map(static fn ($v) => (int) $v, $ids)));
|
||||||
|
|
||||||
return array_values(array_filter($userIds, static fn ($v) => $v > 0));
|
return array_values(array_filter($ids, static fn ($v) => $v > 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined `users.id` and `students.id` for print-status (same numeric space as stored in logs).
|
||||||
|
*
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
private function parseCombinedBadgeIds(Request $request): array
|
||||||
|
{
|
||||||
|
return array_values(array_unique(array_merge(
|
||||||
|
$this->parseStaffUserIds($request),
|
||||||
|
$this->parseStudentIds($request)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Student primary keys (`students.id`) from query or body.
|
||||||
|
*
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
private function parseStudentIds(Request $request): array
|
||||||
|
{
|
||||||
|
$ids = $request->input('student_ids', $request->query('student_ids', []));
|
||||||
|
|
||||||
|
if (is_string($ids)) {
|
||||||
|
$ids = array_filter(array_map('trim', explode(',', $ids)), 'strlen');
|
||||||
|
} elseif (! is_array($ids)) {
|
||||||
|
$ids = $ids ? [$ids] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = array_values(array_unique(array_map(static fn ($v) => (int) $v, $ids)));
|
||||||
|
|
||||||
|
return array_values(array_filter($ids, static fn ($v) => $v > 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
|
||||||
|
{
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $userId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable → Regular
+2
-272
@@ -2,276 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Api\Core\BaseApiController as CoreBaseApiController;
|
||||||
use App\Http\Controllers\Api\CiRequestAdapter;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Contracts\Validation\Validator as ValidatorContract;
|
|
||||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class BaseApiController extends Controller
|
class BaseApiController extends CoreBaseApiController {}
|
||||||
{
|
|
||||||
protected Request $laravelRequest;
|
|
||||||
protected CiRequestAdapter $request;
|
|
||||||
protected ValidatorContract|null $validator = null;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->laravelRequest = request();
|
|
||||||
$this->request = new CiRequestAdapter($this->laravelRequest);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function success($data = null, string $message = 'Success', int $code = Response::HTTP_OK): JsonResponse
|
|
||||||
{
|
|
||||||
return response()->json([
|
|
||||||
'status' => true,
|
|
||||||
'message' => $message,
|
|
||||||
'data' => $data,
|
|
||||||
], $code);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function error(string $message = 'An error occurred', int $code = Response::HTTP_BAD_REQUEST, ?array $errors = null): JsonResponse
|
|
||||||
{
|
|
||||||
$payload = [
|
|
||||||
'status' => false,
|
|
||||||
'message' => $message,
|
|
||||||
];
|
|
||||||
if (!empty($errors)) {
|
|
||||||
$payload['errors'] = $errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json($payload, $code);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function respondSuccess($data = null, string $message = 'Success', int $code = Response::HTTP_OK): JsonResponse
|
|
||||||
{
|
|
||||||
return $this->success($data, $message, $code);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function respondCreated($data = null, string $message = 'Resource created successfully'): JsonResponse
|
|
||||||
{
|
|
||||||
return $this->success($data, $message, Response::HTTP_CREATED);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function respondDeleted($data = null, string $message = 'Resource deleted successfully'): JsonResponse
|
|
||||||
{
|
|
||||||
return $this->success($data, $message, Response::HTTP_OK);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function respondError(string $message = 'An error occurred', int $code = Response::HTTP_BAD_REQUEST): JsonResponse
|
|
||||||
{
|
|
||||||
return $this->error($message, $code);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function respondValidationError(array $errors, string $message = 'Validation failed'): JsonResponse
|
|
||||||
{
|
|
||||||
return $this->error($message, Response::HTTP_UNPROCESSABLE_ENTITY, $errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function validateRequest(array $data, array $rules): array
|
|
||||||
{
|
|
||||||
$normalized = [];
|
|
||||||
foreach ($rules as $field => $ruleSet) {
|
|
||||||
$normalized[$field] = $this->normalizeRules($ruleSet);
|
|
||||||
}
|
|
||||||
|
|
||||||
$validator = Validator::make($data, $normalized);
|
|
||||||
$this->validator = $validator;
|
|
||||||
|
|
||||||
if ($validator->fails()) {
|
|
||||||
return $validator->errors()->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function validate(array $rules): bool
|
|
||||||
{
|
|
||||||
$payload = $this->payloadData();
|
|
||||||
$errors = $this->validateRequest($payload, $rules);
|
|
||||||
return empty($errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function payloadData(): array
|
|
||||||
{
|
|
||||||
$json = json_decode($this->laravelRequest->getContent() ?: '', true);
|
|
||||||
if (is_array($json)) {
|
|
||||||
return $json;
|
|
||||||
}
|
|
||||||
return array_merge($this->laravelRequest->query->all(), $this->laravelRequest->request->all());
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function normalizeRules(array|string $ruleSet): array|string
|
|
||||||
{
|
|
||||||
if (is_array($ruleSet)) {
|
|
||||||
return $ruleSet;
|
|
||||||
}
|
|
||||||
$parts = explode('|', $ruleSet);
|
|
||||||
$result = [];
|
|
||||||
foreach ($parts as $part) {
|
|
||||||
$part = trim($part);
|
|
||||||
if ($part === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (preg_match('/^max_length\\[(\\d+)\\]$/', $part, $m)) {
|
|
||||||
$result[] = 'max:' . $m[1];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (preg_match('/^min_length\\[(\\d+)\\]$/', $part, $m)) {
|
|
||||||
$result[] = 'min:' . $m[1];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (preg_match('/^in_list\\[(.+)\\]$/', $part, $m)) {
|
|
||||||
$result[] = 'in:' . $m[1];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (preg_match('/^greater_than_equal_to\\[(.+)\\]$/', $part, $m)) {
|
|
||||||
$result[] = 'gte:' . $m[1];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (preg_match('/^less_than_equal_to\\[(.+)\\]$/', $part, $m)) {
|
|
||||||
$result[] = 'lte:' . $m[1];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (preg_match('/^valid_date\\[(.+)\\]$/', $part, $m)) {
|
|
||||||
$result[] = 'date_format:' . $m[1];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($part === 'valid_email') {
|
|
||||||
$result[] = 'email';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($part === 'permit_empty') {
|
|
||||||
$result[] = 'nullable';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (preg_match('/^is_unique\\[([^\\.]+)\\.([^\\]]+)\\]$/', $part, $m)) {
|
|
||||||
$result[] = 'unique:' . $m[1] . ',' . $m[2];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($part === 'string') {
|
|
||||||
$result[] = 'string';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($part === 'integer') {
|
|
||||||
$result[] = 'integer';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($part === 'numeric') {
|
|
||||||
$result[] = 'numeric';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($part === 'required') {
|
|
||||||
$result[] = 'required';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($part === 'alpha_numeric') {
|
|
||||||
$result[] = 'alpha_num';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($part === 'in_array') {
|
|
||||||
$result[] = 'in_array';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($part === 'valid_url') {
|
|
||||||
$result[] = 'url';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (strpos($part, 'max_size') === 0) {
|
|
||||||
$result[] = str_replace('max_size', 'max', $part);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$result[] = $part;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function paginate($source, int $page = 1, int $perPage = 20): array
|
|
||||||
{
|
|
||||||
$page = max(1, $page);
|
|
||||||
$perPage = max(1, $perPage);
|
|
||||||
$offset = ($page - 1) * $perPage;
|
|
||||||
|
|
||||||
if ($source instanceof EloquentBuilder || $source instanceof \Illuminate\Database\Query\Builder) {
|
|
||||||
$total = $source->toBase()->getCountForPagination();
|
|
||||||
$items = $source->offset($offset)->limit($perPage)->get()->toArray();
|
|
||||||
} elseif (is_array($source)) {
|
|
||||||
$total = count($source);
|
|
||||||
$items = array_slice($source, $offset, $perPage);
|
|
||||||
} else {
|
|
||||||
return [
|
|
||||||
'data' => [],
|
|
||||||
'pagination' => [
|
|
||||||
'current_page' => $page,
|
|
||||||
'per_page' => $perPage,
|
|
||||||
'total' => 0,
|
|
||||||
'total_pages' => 0,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'data' => array_values($items),
|
|
||||||
'pagination' => [
|
|
||||||
'current_page' => $page,
|
|
||||||
'per_page' => $perPage,
|
|
||||||
'total' => $total,
|
|
||||||
'total_pages' => (int) ceil($total / $perPage),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getCurrentUserId(): ?int
|
|
||||||
{
|
|
||||||
$userId = (int) (auth()->id() ?? 0);
|
|
||||||
return $userId > 0 ? $userId : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getCurrentUser(): ?object
|
|
||||||
{
|
|
||||||
$userId = $this->getCurrentUserId();
|
|
||||||
if (!$userId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = app(User::class);
|
|
||||||
$row = $user->find($userId);
|
|
||||||
if (!$row) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$name = trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? ''));
|
|
||||||
if ($name === '') {
|
|
||||||
$name = $row['email'] ?? 'User #' . $userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$roles = DB::table('user_roles ur')
|
|
||||||
->select('LOWER(r.name) AS name')
|
|
||||||
->join('roles r', 'r.id', '=', 'ur.role_id')
|
|
||||||
->where('ur.user_id', $userId)
|
|
||||||
->whereNull('ur.deleted_at')
|
|
||||||
->pluck('name')
|
|
||||||
->map(fn($name) => strtolower((string) $name))
|
|
||||||
->unique()
|
|
||||||
->values()
|
|
||||||
->toArray();
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::error('Unable to load user roles: ' . $e->getMessage());
|
|
||||||
$roles = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return (object) [
|
|
||||||
'id' => (int) $userId,
|
|
||||||
'name' => $name,
|
|
||||||
'email' => $row['email'] ?? null,
|
|
||||||
'roles' => $roles,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Certificates;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
|
use App\Services\Certificates\CertificateAdminService;
|
||||||
|
use App\Services\Certificates\CertificatePdfService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class CertificateController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CertificateAdminService $service,
|
||||||
|
private CertificatePdfService $pdfService,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dashboard(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => $this->service->dashboard($request->query('school_year')),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Certificate dashboard failed', ['exception' => $e]);
|
||||||
|
|
||||||
|
return $this->error('Failed to load certificate dashboard.', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function formOptions(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->dashboard($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function auditLog(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => $this->service->auditLog($request->query('school_year')),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Certificate audit log failed', ['exception' => $e]);
|
||||||
|
|
||||||
|
return $this->error('Failed to load certificate audit log.', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generate(Request $request): Response|JsonResponse
|
||||||
|
{
|
||||||
|
$studentIds = $request->input('student_ids', []);
|
||||||
|
$certDate = trim((string) ($request->input('cert_date') ?? date('m/d/Y')));
|
||||||
|
$classSectionId = $request->input('class_section_id');
|
||||||
|
$schoolYear = $request->input('school_year');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$payload = $this->service->issueCertificates(
|
||||||
|
is_array($studentIds) ? $studentIds : [],
|
||||||
|
$certDate,
|
||||||
|
$classSectionId !== null && $classSectionId !== '' ? (int) $classSectionId : null,
|
||||||
|
is_string($schoolYear) ? $schoolYear : null,
|
||||||
|
$this->getCurrentUserId()
|
||||||
|
);
|
||||||
|
$pdfContent = $this->pdfService->generate(
|
||||||
|
$payload['students'],
|
||||||
|
$payload['cert_date_display']
|
||||||
|
);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
return $this->error($e->getMessage(), 422);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
return $this->error($e->getMessage(), 422);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Certificate generation failed', ['exception' => $e]);
|
||||||
|
|
||||||
|
return $this->error('Failed to generate certificates.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = 'Certificates_'.date('Ymd_His').'.pdf';
|
||||||
|
|
||||||
|
return response($pdfContent, 200, [
|
||||||
|
'Content-Type' => 'application/pdf',
|
||||||
|
'Content-Disposition' => 'inline; filename="'.$filename.'"',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reprint(string $certificateNumber): Response|JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$payload = $this->service->reprintPayload($certificateNumber);
|
||||||
|
if ($payload === null) {
|
||||||
|
return $this->error('Certificate not found: '.$certificateNumber, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdfContent = $this->pdfService->generate(
|
||||||
|
[$payload['student']],
|
||||||
|
$payload['cert_date_display']
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Certificate reprint failed', [
|
||||||
|
'certificate_number' => $certificateNumber,
|
||||||
|
'exception' => $e,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->error('Failed to reprint certificate.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = 'Certificate_'.strtoupper($certificateNumber).'.pdf';
|
||||||
|
|
||||||
|
return response($pdfContent, 200, [
|
||||||
|
'Content-Type' => 'application/pdf',
|
||||||
|
'Content-Disposition' => 'inline; filename="'.$filename.'"',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(string $token): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$payload = $this->service->verifyPayload($token);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Certificate verify failed', ['token' => $token, 'exception' => $e]);
|
||||||
|
|
||||||
|
return $this->error('Failed to verify certificate.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($payload === null) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Certificate not found.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => $payload,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
class CiRequestAdapter
|
|
||||||
{
|
|
||||||
public function __construct(protected Request $request)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getGet(string $key = null, $default = null)
|
|
||||||
{
|
|
||||||
if ($key === null) {
|
|
||||||
return $this->request->query->all();
|
|
||||||
}
|
|
||||||
return $this->request->query->get($key, $default);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPost(string $key = null, $default = null)
|
|
||||||
{
|
|
||||||
if ($key === null) {
|
|
||||||
return $this->request->request->all();
|
|
||||||
}
|
|
||||||
return $this->request->request->get($key, $default);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getJSON(bool $assoc = false)
|
|
||||||
{
|
|
||||||
$content = $this->request->getContent();
|
|
||||||
if ($content === '') {
|
|
||||||
return $assoc ? [] : null;
|
|
||||||
}
|
|
||||||
return json_decode($content, $assoc);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getVar(string $key, $default = null)
|
|
||||||
{
|
|
||||||
return $this->request->input($key, $default);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getHeader(string $key)
|
|
||||||
{
|
|
||||||
return $this->request->headers->get($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getHeaderLine(string $key)
|
|
||||||
{
|
|
||||||
return $this->request->header($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getFile(string $key)
|
|
||||||
{
|
|
||||||
return $this->request->file($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRawInput(): string
|
|
||||||
{
|
|
||||||
return $this->request->getContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function all(): array
|
|
||||||
{
|
|
||||||
return array_merge(
|
|
||||||
$this->request->query->all(),
|
|
||||||
$this->request->request->all(),
|
|
||||||
$this->request->json()->all()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\ClassPrep;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
|
||||||
use App\Services\ClassPrep\ClassRosterService;
|
|
||||||
use App\Services\ClassPrep\StickerCountService;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class ClassPrepController extends BaseApiController
|
|
||||||
{
|
|
||||||
private StickerCountService $stickerCounts;
|
|
||||||
private ClassRosterService $roster;
|
|
||||||
|
|
||||||
public function __construct(StickerCountService $stickerCounts, ClassRosterService $roster)
|
|
||||||
{
|
|
||||||
parent::__construct();
|
|
||||||
$this->stickerCounts = $stickerCounts;
|
|
||||||
$this->roster = $roster;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function stickerCounts(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$schoolYear = (string) ($request->query('school_year') ?? '');
|
|
||||||
$semester = (string) ($request->query('semester') ?? '');
|
|
||||||
$classSectionId = $request->query('class_section_id') ?? $request->query('class_id');
|
|
||||||
$classSectionId = is_numeric($classSectionId) ? (int) $classSectionId : null;
|
|
||||||
|
|
||||||
if ($classSectionId !== null && $classSectionId > 0) {
|
|
||||||
$payload = $this->stickerCounts->listForClass($schoolYear, $semester, $classSectionId);
|
|
||||||
} else {
|
|
||||||
$payload = $this->stickerCounts->listAll($schoolYear, $semester);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'ok' => true,
|
|
||||||
'data' => $payload,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function studentsByClass(Request $request, int $classSectionId): JsonResponse
|
|
||||||
{
|
|
||||||
$schoolYear = (string) ($request->query('school_year') ?? '');
|
|
||||||
|
|
||||||
$students = $this->roster->listStudentsByClass($classSectionId, $schoolYear !== '' ? $schoolYear : null);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'ok' => true,
|
|
||||||
'students' => $students,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,15 +2,18 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\ClassPreparation;
|
namespace App\Http\Controllers\Api\ClassPreparation;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
use App\Http\Requests\ClassPreparation\ClassPreparationAdjustmentRequest;
|
use App\Http\Requests\ClassPreparation\ClassPreparationAdjustmentRequest;
|
||||||
use App\Http\Requests\ClassPreparation\ClassPreparationIndexRequest;
|
use App\Http\Requests\ClassPreparation\ClassPreparationIndexRequest;
|
||||||
use App\Http\Requests\ClassPreparation\ClassPreparationMarkPrintedRequest;
|
use App\Http\Requests\ClassPreparation\ClassPreparationMarkPrintedRequest;
|
||||||
use App\Http\Requests\ClassPreparation\ClassPreparationPrintRequest;
|
use App\Http\Requests\ClassPreparation\ClassPreparationPrintRequest;
|
||||||
use App\Http\Resources\ClassPreparation\ClassPreparationPrintResource;
|
use App\Http\Resources\ClassPreparation\ClassPreparationPrintResource;
|
||||||
use App\Http\Resources\ClassPreparation\ClassPreparationResultResource;
|
use App\Http\Resources\ClassPreparation\ClassPreparationResultResource;
|
||||||
|
use App\Services\ClassPrep\ClassRosterService;
|
||||||
|
use App\Services\ClassPrep\StickerCountService;
|
||||||
use App\Services\ClassPreparation\ClassPreparationService;
|
use App\Services\ClassPreparation\ClassPreparationService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
@@ -18,10 +21,19 @@ class ClassPreparationController extends BaseApiController
|
|||||||
{
|
{
|
||||||
private ClassPreparationService $service;
|
private ClassPreparationService $service;
|
||||||
|
|
||||||
public function __construct(ClassPreparationService $service)
|
private StickerCountService $stickerCounts;
|
||||||
{
|
|
||||||
|
private ClassRosterService $roster;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
ClassPreparationService $service,
|
||||||
|
StickerCountService $stickerCounts,
|
||||||
|
ClassRosterService $roster
|
||||||
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
$this->service = $service;
|
$this->service = $service;
|
||||||
|
$this->stickerCounts = $stickerCounts;
|
||||||
|
$this->roster = $roster;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index(ClassPreparationIndexRequest $request): JsonResponse
|
public function index(ClassPreparationIndexRequest $request): JsonResponse
|
||||||
@@ -66,7 +78,8 @@ class ClassPreparationController extends BaseApiController
|
|||||||
try {
|
try {
|
||||||
$count = $this->service->saveAdjustments($classSectionId, $schoolYear, $validated['adjustments']);
|
$count = $this->service->saveAdjustments($classSectionId, $schoolYear, $validated['adjustments']);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('Class prep adjustment save failed: ' . $e->getMessage());
|
Log::error('Class prep adjustment save failed: '.$e->getMessage());
|
||||||
|
|
||||||
return $this->error('Unable to save adjustments.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
return $this->error('Unable to save adjustments.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,11 +99,42 @@ class ClassPreparationController extends BaseApiController
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (empty($payload['ok'])) {
|
if (empty($payload['ok'])) {
|
||||||
return $this->error('Unable to log class preparation print.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
return $this->error('Unable to log class preparation print.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'print' => new ClassPreparationPrintResource($payload),
|
'print' => new ClassPreparationPrintResource($payload),
|
||||||
], 'Class preparation logged.');
|
], 'Class preparation logged.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function stickerCounts(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$schoolYear = (string) ($request->query('school_year') ?? '');
|
||||||
|
$semester = (string) ($request->query('semester') ?? '');
|
||||||
|
$classSectionId = $request->query('class_section_id') ?? $request->query('class_id');
|
||||||
|
$classSectionId = is_numeric($classSectionId) ? (int) $classSectionId : null;
|
||||||
|
|
||||||
|
if ($classSectionId !== null && $classSectionId > 0) {
|
||||||
|
$payload = $this->stickerCounts->listForClass($schoolYear, $semester, $classSectionId);
|
||||||
|
} else {
|
||||||
|
$payload = $this->stickerCounts->listAll($schoolYear, $semester);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'data' => $payload,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function studentsByClass(Request $request, int $classSectionId): JsonResponse
|
||||||
|
{
|
||||||
|
$schoolYear = (string) ($request->query('school_year') ?? '');
|
||||||
|
|
||||||
|
$students = $this->roster->listStudentsByClass($classSectionId, $schoolYear !== '' ? $schoolYear : null);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'students' => $students,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Classes;
|
namespace App\Http\Controllers\Api\Classes;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
use App\Http\Requests\Classes\ClassSectionIndexRequest;
|
use App\Http\Requests\Classes\ClassSectionIndexRequest;
|
||||||
use App\Http\Requests\Classes\ClassSectionStoreRequest;
|
use App\Http\Requests\Classes\ClassSectionStoreRequest;
|
||||||
use App\Http\Requests\Classes\ClassSectionUpdateRequest;
|
use App\Http\Requests\Classes\ClassSectionUpdateRequest;
|
||||||
@@ -53,7 +53,7 @@ class ClassController extends BaseApiController
|
|||||||
|
|
||||||
$section = $this->queryService->find((int) $classSection->id);
|
$section = $this->queryService->find((int) $classSection->id);
|
||||||
|
|
||||||
if (!$section) {
|
if (! $section) {
|
||||||
return $this->error('Class section not found.', Response::HTTP_NOT_FOUND);
|
return $this->error('Class section not found.', Response::HTTP_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ class ClassController extends BaseApiController
|
|||||||
{
|
{
|
||||||
$this->authorize('delete', $classSection);
|
$this->authorize('delete', $classSection);
|
||||||
|
|
||||||
if (!$this->commandService->delete($classSection)) {
|
if (! $this->commandService->delete($classSection)) {
|
||||||
return $this->error('Class section not found.', Response::HTTP_NOT_FOUND);
|
return $this->error('Class section not found.', Response::HTTP_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +125,8 @@ class ClassController extends BaseApiController
|
|||||||
try {
|
try {
|
||||||
$created = $this->seedService->seedDefaults();
|
$created = $this->seedService->seedDefaults();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('Seeding default classes failed: ' . $e->getMessage());
|
Log::error('Seeding default classes failed: '.$e->getMessage());
|
||||||
|
|
||||||
return $this->error('Unable to seed default classes.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
return $this->error('Unable to seed default classes.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Communication;
|
namespace App\Http\Controllers\Api\Communication;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
use App\Services\Communication\CommunicationFamilyService;
|
use App\Services\Communication\CommunicationFamilyService;
|
||||||
use App\Services\Communication\CommunicationPreviewService;
|
use App\Services\Communication\CommunicationPreviewService;
|
||||||
use App\Services\Communication\CommunicationSendService;
|
use App\Services\Communication\CommunicationSendService;
|
||||||
@@ -14,9 +14,13 @@ use Illuminate\Http\Request;
|
|||||||
class CommunicationController extends BaseApiController
|
class CommunicationController extends BaseApiController
|
||||||
{
|
{
|
||||||
private CommunicationStudentService $students;
|
private CommunicationStudentService $students;
|
||||||
|
|
||||||
private CommunicationTemplateService $templates;
|
private CommunicationTemplateService $templates;
|
||||||
|
|
||||||
private CommunicationFamilyService $families;
|
private CommunicationFamilyService $families;
|
||||||
|
|
||||||
private CommunicationPreviewService $previewService;
|
private CommunicationPreviewService $previewService;
|
||||||
|
|
||||||
private CommunicationSendService $sendService;
|
private CommunicationSendService $sendService;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -76,7 +80,7 @@ class CommunicationController extends BaseApiController
|
|||||||
$teacherName = $this->resolveTeacherName();
|
$teacherName = $this->resolveTeacherName();
|
||||||
$result = $this->previewService->buildPreview($templateKey, $studentId, $familyId, $vars, $teacherName);
|
$result = $this->previewService->buildPreview($templateKey, $studentId, $familyId, $vars, $teacherName);
|
||||||
|
|
||||||
if (!$result['ok']) {
|
if (! $result['ok']) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'message' => $result['message'] ?? 'Preview failed.',
|
'message' => $result['message'] ?? 'Preview failed.',
|
||||||
@@ -116,13 +120,18 @@ class CommunicationController extends BaseApiController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$student = $this->students->getStudentBasic($studentId);
|
$student = $this->students->getStudentBasic($studentId);
|
||||||
if (!$student) {
|
if (! $student) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'message' => 'Student not found.',
|
'message' => 'Student not found.',
|
||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$senderId = $this->authenticatedUserIdOrUnauthorized();
|
||||||
|
if ($senderId instanceof JsonResponse) {
|
||||||
|
return $senderId;
|
||||||
|
}
|
||||||
|
|
||||||
$result = $this->sendService->send([
|
$result = $this->sendService->send([
|
||||||
'student_id' => $studentId,
|
'student_id' => $studentId,
|
||||||
'family_id' => $familyId,
|
'family_id' => $familyId,
|
||||||
@@ -132,11 +141,11 @@ class CommunicationController extends BaseApiController
|
|||||||
'recipients' => $recipients,
|
'recipients' => $recipients,
|
||||||
'cc' => $cc,
|
'cc' => $cc,
|
||||||
'bcc' => $bcc,
|
'bcc' => $bcc,
|
||||||
'student_name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
|
'student_name' => trim(($student['firstname'] ?? '').' '.($student['lastname'] ?? '')),
|
||||||
'sent_by' => (int) (auth()->id() ?? 0),
|
'sent_by' => $senderId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!$result['ok']) {
|
if (! $result['ok']) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'message' => 'Failed to send email.',
|
'message' => 'Failed to send email.',
|
||||||
@@ -170,9 +179,21 @@ class CommunicationController extends BaseApiController
|
|||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
if ($user) {
|
if ($user) {
|
||||||
$name = trim(($user->firstname ?? '') . ' ' . ($user->lastname ?? ''));
|
$name = trim(($user->firstname ?? '').' '.($user->lastname ?? ''));
|
||||||
|
|
||||||
return $name !== '' ? $name : 'Teacher';
|
return $name !== '' ? $name : 'Teacher';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Teacher';
|
return 'Teacher';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
|
||||||
|
{
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\CompetitionScores;
|
namespace App\Http\Controllers\Api\CompetitionScores;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
use App\Models\Competition;
|
use App\Models\Competition;
|
||||||
use App\Services\CompetitionScores\CompetitionScoresContextService;
|
use App\Services\CompetitionScores\CompetitionScoresContextService;
|
||||||
use App\Services\CompetitionScores\CompetitionScoresQueryService;
|
use App\Services\CompetitionScores\CompetitionScoresQueryService;
|
||||||
@@ -14,8 +14,11 @@ use Illuminate\Http\Request;
|
|||||||
class CompetitionScoresController extends BaseApiController
|
class CompetitionScoresController extends BaseApiController
|
||||||
{
|
{
|
||||||
private CompetitionScoresContextService $contextService;
|
private CompetitionScoresContextService $contextService;
|
||||||
|
|
||||||
private CompetitionScoresQueryService $queryService;
|
private CompetitionScoresQueryService $queryService;
|
||||||
|
|
||||||
private CompetitionScoresRosterService $rosterService;
|
private CompetitionScoresRosterService $rosterService;
|
||||||
|
|
||||||
private CompetitionScoresSaveService $saveService;
|
private CompetitionScoresSaveService $saveService;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -33,8 +36,12 @@ class CompetitionScoresController extends BaseApiController
|
|||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$userId = (int) (auth()->id() ?? 0);
|
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||||
[$assignments, $schoolYear, $semester] = $this->contextService->getTeacherContext($userId);
|
if ($guard instanceof JsonResponse) {
|
||||||
|
return $guard;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$assignments, $schoolYear, $semester] = $this->contextService->getTeacherContext($guard);
|
||||||
|
|
||||||
$activeClassId = $this->contextService->resolveActiveClassId(
|
$activeClassId = $this->contextService->resolveActiveClassId(
|
||||||
$assignments,
|
$assignments,
|
||||||
@@ -80,8 +87,12 @@ class CompetitionScoresController extends BaseApiController
|
|||||||
|
|
||||||
public function edit(Request $request, int $id): JsonResponse
|
public function edit(Request $request, int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$userId = (int) (auth()->id() ?? 0);
|
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||||
[$assignments, $schoolYear, $semester] = $this->contextService->getTeacherContext($userId);
|
if ($guard instanceof JsonResponse) {
|
||||||
|
return $guard;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$assignments, $schoolYear, $semester] = $this->contextService->getTeacherContext($guard);
|
||||||
$allowedClassIds = $this->contextService->getAllowedClassIds($assignments);
|
$allowedClassIds = $this->contextService->getAllowedClassIds($assignments);
|
||||||
|
|
||||||
if (empty($allowedClassIds)) {
|
if (empty($allowedClassIds)) {
|
||||||
@@ -92,7 +103,7 @@ class CompetitionScoresController extends BaseApiController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$competition = Competition::query()->find($id);
|
$competition = Competition::query()->find($id);
|
||||||
if (!$competition) {
|
if (! $competition) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'message' => 'Competition not found.',
|
'message' => 'Competition not found.',
|
||||||
@@ -112,7 +123,7 @@ class CompetitionScoresController extends BaseApiController
|
|||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!in_array($classSectionId, $allowedClassIds, true)) {
|
if (! in_array($classSectionId, $allowedClassIds, true)) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'message' => 'You are not assigned to that class.',
|
'message' => 'You are not assigned to that class.',
|
||||||
@@ -146,8 +157,12 @@ class CompetitionScoresController extends BaseApiController
|
|||||||
|
|
||||||
public function save(Request $request, int $id): JsonResponse
|
public function save(Request $request, int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$userId = (int) (auth()->id() ?? 0);
|
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||||
[$assignments] = $this->contextService->getTeacherContext($userId);
|
if ($guard instanceof JsonResponse) {
|
||||||
|
return $guard;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$assignments] = $this->contextService->getTeacherContext($guard);
|
||||||
$allowedClassIds = $this->contextService->getAllowedClassIds($assignments);
|
$allowedClassIds = $this->contextService->getAllowedClassIds($assignments);
|
||||||
|
|
||||||
if (empty($allowedClassIds)) {
|
if (empty($allowedClassIds)) {
|
||||||
@@ -158,7 +173,7 @@ class CompetitionScoresController extends BaseApiController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$competition = Competition::query()->find($id);
|
$competition = Competition::query()->find($id);
|
||||||
if (!$competition) {
|
if (! $competition) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'message' => 'Competition not found.',
|
'message' => 'Competition not found.',
|
||||||
@@ -182,7 +197,7 @@ class CompetitionScoresController extends BaseApiController
|
|||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!in_array($classSectionId, $allowedClassIds, true)) {
|
if (! in_array($classSectionId, $allowedClassIds, true)) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'message' => 'You are not assigned to that class.',
|
'message' => 'You are not assigned to that class.',
|
||||||
@@ -193,7 +208,7 @@ class CompetitionScoresController extends BaseApiController
|
|||||||
$scores = is_array($scores) ? $scores : [];
|
$scores = is_array($scores) ? $scores : [];
|
||||||
[$cleanScores, $invalidScores] = $this->saveService->filterScores($scores);
|
[$cleanScores, $invalidScores] = $this->saveService->filterScores($scores);
|
||||||
|
|
||||||
if (!empty($invalidScores)) {
|
if (! empty($invalidScores)) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'message' => 'Scores must be whole numbers (no decimals).',
|
'message' => 'Scores must be whole numbers (no decimals).',
|
||||||
@@ -209,4 +224,14 @@ class CompetitionScoresController extends BaseApiController
|
|||||||
'savedCount' => count($cleanScores),
|
'savedCount' => count($cleanScores),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
|
||||||
|
{
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Discounts;
|
namespace App\Http\Controllers\Api\Discounts;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
use App\Http\Requests\Discounts\ApplyDiscountVoucherRequest;
|
use App\Http\Requests\Discounts\ApplyDiscountVoucherRequest;
|
||||||
use App\Http\Requests\Discounts\StoreDiscountVoucherRequest;
|
use App\Http\Requests\Discounts\StoreDiscountVoucherRequest;
|
||||||
use App\Http\Requests\Discounts\UpdateDiscountVoucherRequest;
|
use App\Http\Requests\Discounts\UpdateDiscountVoucherRequest;
|
||||||
@@ -17,8 +17,11 @@ use Illuminate\Http\JsonResponse;
|
|||||||
class DiscountController extends BaseApiController
|
class DiscountController extends BaseApiController
|
||||||
{
|
{
|
||||||
private DiscountContextService $context;
|
private DiscountContextService $context;
|
||||||
|
|
||||||
private DiscountVoucherService $voucherService;
|
private DiscountVoucherService $voucherService;
|
||||||
|
|
||||||
private DiscountParentService $parentService;
|
private DiscountParentService $parentService;
|
||||||
|
|
||||||
private DiscountApplyService $applyService;
|
private DiscountApplyService $applyService;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -50,12 +53,17 @@ class DiscountController extends BaseApiController
|
|||||||
|
|
||||||
public function apply(ApplyDiscountVoucherRequest $request): JsonResponse
|
public function apply(ApplyDiscountVoucherRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||||
|
if ($guard instanceof JsonResponse) {
|
||||||
|
return $guard;
|
||||||
|
}
|
||||||
|
|
||||||
$payload = $request->validated();
|
$payload = $request->validated();
|
||||||
$result = $this->applyService->applyVoucher(
|
$result = $this->applyService->applyVoucher(
|
||||||
(int) $payload['voucher_id'],
|
(int) $payload['voucher_id'],
|
||||||
$payload['parent_ids'],
|
$payload['parent_ids'],
|
||||||
(bool) ($payload['allow_additional'] ?? false),
|
(bool) ($payload['allow_additional'] ?? false),
|
||||||
(int) (auth()->id() ?? 0)
|
$guard
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json($result, $result['ok'] ? 200 : 422);
|
return response()->json($result, $result['ok'] ? 200 : 422);
|
||||||
@@ -64,6 +72,7 @@ class DiscountController extends BaseApiController
|
|||||||
public function listVouchers(): JsonResponse
|
public function listVouchers(): JsonResponse
|
||||||
{
|
{
|
||||||
$vouchers = $this->voucherService->listAll();
|
$vouchers = $this->voucherService->listAll();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'vouchers' => DiscountVoucherResource::collection($vouchers),
|
'vouchers' => DiscountVoucherResource::collection($vouchers),
|
||||||
@@ -73,6 +82,7 @@ class DiscountController extends BaseApiController
|
|||||||
public function storeVoucher(StoreDiscountVoucherRequest $request): JsonResponse
|
public function storeVoucher(StoreDiscountVoucherRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$voucher = $this->voucherService->create($request->validated());
|
$voucher = $this->voucherService->create($request->validated());
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'voucher' => new DiscountVoucherResource($voucher),
|
'voucher' => new DiscountVoucherResource($voucher),
|
||||||
@@ -82,6 +92,7 @@ class DiscountController extends BaseApiController
|
|||||||
public function updateVoucher(UpdateDiscountVoucherRequest $request, int $id): JsonResponse
|
public function updateVoucher(UpdateDiscountVoucherRequest $request, int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$voucher = $this->voucherService->update($id, $request->validated());
|
$voucher = $this->voucherService->update($id, $request->validated());
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'voucher' => new DiscountVoucherResource($voucher),
|
'voucher' => new DiscountVoucherResource($voucher),
|
||||||
@@ -91,6 +102,7 @@ class DiscountController extends BaseApiController
|
|||||||
public function showVoucher(int $id): JsonResponse
|
public function showVoucher(int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$voucher = $this->voucherService->find($id);
|
$voucher = $this->voucherService->find($id);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'voucher' => new DiscountVoucherResource($voucher),
|
'voucher' => new DiscountVoucherResource($voucher),
|
||||||
@@ -100,8 +112,19 @@ class DiscountController extends BaseApiController
|
|||||||
public function deleteVoucher(int $id): JsonResponse
|
public function deleteVoucher(int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$this->voucherService->delete($id);
|
$this->voucherService->delete($id);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
|
||||||
|
{
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Documentation;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Docs\ApiDocsService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated API explorer bootstrap + public read-only variant.
|
||||||
|
*/
|
||||||
|
class ApiDocsAdminController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ApiDocsService $apiDocs,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(
|
||||||
|
$this->apiDocs->bootstrap(Auth::user(), false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function public(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(
|
||||||
|
$this->apiDocs->bootstrap(null, true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Documentation;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Docs\DocsCatalogService;
|
||||||
|
use App\Services\Docs\OpenApiRouteExporter;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Docs hub + multi-spec Swagger catalog (JSON for SPA).
|
||||||
|
*/
|
||||||
|
class DocsCatalogController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private DocsCatalogService $catalog,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
if (! $request->expectsJson()) {
|
||||||
|
return redirect()->to((string) config('docs.client_url', url('/docs')));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => true,
|
||||||
|
'data' => $this->catalog->indexPayload(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ui(): View
|
||||||
|
{
|
||||||
|
return view('docs.swagger');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function swagger(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'status' => true,
|
||||||
|
'data' => $this->catalog->swaggerSpecsPayload(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merged OpenAPI spec (static openapi.json + live route merge).
|
||||||
|
*/
|
||||||
|
public function swaggerMergedJson(): JsonResponse
|
||||||
|
{
|
||||||
|
$path = resource_path('docs/openapi.json');
|
||||||
|
abort_unless(is_file($path), 404);
|
||||||
|
|
||||||
|
$spec = app(OpenApiRouteExporter::class)->exportFromFile($path);
|
||||||
|
|
||||||
|
return response()->json($spec, 200, [
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'Cache-Control' => 'no-store, max-age=0',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Email;
|
namespace App\Http\Controllers\Api\Email;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
use App\Services\BroadcastEmail\BroadcastEmailComposerService;
|
use App\Services\BroadcastEmail\BroadcastEmailComposerService;
|
||||||
use App\Services\BroadcastEmail\BroadcastEmailDispatchService;
|
use App\Services\BroadcastEmail\BroadcastEmailDispatchService;
|
||||||
use App\Services\BroadcastEmail\BroadcastEmailImageService;
|
use App\Services\BroadcastEmail\BroadcastEmailImageService;
|
||||||
@@ -14,9 +14,13 @@ use Illuminate\Http\Request;
|
|||||||
class BroadcastEmailController extends BaseApiController
|
class BroadcastEmailController extends BaseApiController
|
||||||
{
|
{
|
||||||
private BroadcastEmailSenderOptionsService $senderOptions;
|
private BroadcastEmailSenderOptionsService $senderOptions;
|
||||||
|
|
||||||
private BroadcastEmailRecipientService $recipients;
|
private BroadcastEmailRecipientService $recipients;
|
||||||
|
|
||||||
private BroadcastEmailComposerService $composer;
|
private BroadcastEmailComposerService $composer;
|
||||||
|
|
||||||
private BroadcastEmailDispatchService $dispatch;
|
private BroadcastEmailDispatchService $dispatch;
|
||||||
|
|
||||||
private BroadcastEmailImageService $imageService;
|
private BroadcastEmailImageService $imageService;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -140,7 +144,7 @@ class BroadcastEmailController extends BaseApiController
|
|||||||
public function uploadImage(Request $request): JsonResponse
|
public function uploadImage(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$file = $request->file('image');
|
$file = $request->file('image');
|
||||||
if (!$file || !$file->isValid()) {
|
if (! $file || ! $file->isValid()) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'No image uploaded',
|
'error' => 'No image uploaded',
|
||||||
@@ -148,7 +152,7 @@ class BroadcastEmailController extends BaseApiController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$result = $this->imageService->store($file);
|
$result = $this->imageService->store($file);
|
||||||
if (!$result['ok']) {
|
if (! $result['ok']) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => $result['error'] ?? 'Upload failed',
|
'error' => $result['error'] ?? 'Upload failed',
|
||||||
@@ -174,6 +178,7 @@ class BroadcastEmailController extends BaseApiController
|
|||||||
foreach (explode(',', $value) as $piece) {
|
foreach (explode(',', $value) as $piece) {
|
||||||
$ids[] = (int) $piece;
|
$ids[] = (int) $piece;
|
||||||
}
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$ids[] = (int) $value;
|
$ids[] = (int) $value;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Email;
|
namespace App\Http\Controllers\Api\Email;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
use App\Http\Requests\Email\EmailSendRequest;
|
use App\Http\Requests\Email\EmailSendRequest;
|
||||||
use App\Http\Resources\Email\EmailSenderResource;
|
use App\Http\Resources\Email\EmailSenderResource;
|
||||||
use App\Services\Email\EmailAttachmentService;
|
use App\Services\Email\EmailAttachmentService;
|
||||||
@@ -46,7 +46,7 @@ class EmailController extends BaseApiController
|
|||||||
$attachments
|
$attachments
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!$ok) {
|
if (! $ok) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'message' => 'Email failed to send.',
|
'message' => 'Email failed to send.',
|
||||||
|
|||||||
@@ -2,13 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Email;
|
namespace App\Http\Controllers\Api\Email;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
use App\Http\Requests\Email\EmailExtractorCompareRequest;
|
use App\Http\Requests\Email\EmailExtractorCompareRequest;
|
||||||
use App\Http\Resources\Email\EmailExtractorCompareResource;
|
use App\Http\Resources\Email\EmailExtractorCompareResource;
|
||||||
use App\Http\Resources\Email\EmailExtractorEmailsResource;
|
use App\Http\Resources\Email\EmailExtractorEmailsResource;
|
||||||
use App\Services\Email\EmailExtractorService;
|
use App\Services\Email\EmailExtractorService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class EmailExtractorController extends BaseApiController
|
class EmailExtractorController extends BaseApiController
|
||||||
{
|
{
|
||||||
@@ -23,6 +22,8 @@ class EmailExtractorController extends BaseApiController
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
|
'users' => $emails['users'] ?? [],
|
||||||
|
'parents' => $emails['parents'] ?? [],
|
||||||
'emails' => new EmailExtractorEmailsResource($emails),
|
'emails' => new EmailExtractorEmailsResource($emails),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Exams;
|
namespace App\Http\Controllers\Api\Exams;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
use App\Http\Requests\Exams\ExamDraftAdminLegacyRequest;
|
use App\Http\Requests\Exams\ExamDraftAdminLegacyRequest;
|
||||||
use App\Http\Requests\Exams\ExamDraftAdminReviewRequest;
|
use App\Http\Requests\Exams\ExamDraftAdminReviewRequest;
|
||||||
use App\Http\Requests\Exams\ExamDraftTeacherStoreRequest;
|
use App\Http\Requests\Exams\ExamDraftTeacherStoreRequest;
|
||||||
@@ -22,12 +22,12 @@ class ExamDraftController extends BaseApiController
|
|||||||
|
|
||||||
public function teacherIndex(): JsonResponse
|
public function teacherIndex(): JsonResponse
|
||||||
{
|
{
|
||||||
$teacherId = (int) (auth()->id() ?? 0);
|
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||||
if ($teacherId <= 0) {
|
if ($guard instanceof JsonResponse) {
|
||||||
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
|
return $guard;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = $this->teacherService->listForTeacher($teacherId);
|
$data = $this->teacherService->listForTeacher($guard);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
@@ -43,13 +43,13 @@ class ExamDraftController extends BaseApiController
|
|||||||
|
|
||||||
public function teacherStore(ExamDraftTeacherStoreRequest $request): JsonResponse
|
public function teacherStore(ExamDraftTeacherStoreRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$teacherId = (int) (auth()->id() ?? 0);
|
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||||
if ($teacherId <= 0) {
|
if ($guard instanceof JsonResponse) {
|
||||||
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
|
return $guard;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $this->teacherService->store($teacherId, $request->validated(), $request->file('draft_file'));
|
$result = $this->teacherService->store($guard, $request->validated(), $request->file('draft_file'));
|
||||||
if (!$result['ok']) {
|
if (! $result['ok']) {
|
||||||
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422);
|
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,13 +79,13 @@ class ExamDraftController extends BaseApiController
|
|||||||
|
|
||||||
public function adminUploadLegacy(ExamDraftAdminLegacyRequest $request): JsonResponse
|
public function adminUploadLegacy(ExamDraftAdminLegacyRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$adminId = (int) (auth()->id() ?? 0);
|
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||||
if ($adminId <= 0) {
|
if ($guard instanceof JsonResponse) {
|
||||||
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
|
return $guard;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $this->adminService->uploadLegacy($adminId, $request->validated(), $request->file('old_exam_file'));
|
$result = $this->adminService->uploadLegacy($guard, $request->validated(), $request->file('old_exam_file'));
|
||||||
if (!$result['ok']) {
|
if (! $result['ok']) {
|
||||||
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422);
|
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,13 +97,13 @@ class ExamDraftController extends BaseApiController
|
|||||||
|
|
||||||
public function adminReview(ExamDraftAdminReviewRequest $request): JsonResponse
|
public function adminReview(ExamDraftAdminReviewRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$adminId = (int) (auth()->id() ?? 0);
|
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||||
if ($adminId <= 0) {
|
if ($guard instanceof JsonResponse) {
|
||||||
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
|
return $guard;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $this->adminService->review($adminId, $request->validated(), $request->file('final_file'));
|
$result = $this->adminService->review($guard, $request->validated(), $request->file('final_file'));
|
||||||
if (!$result['ok']) {
|
if (! $result['ok']) {
|
||||||
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422);
|
return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to save.'], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,4 +112,14 @@ class ExamDraftController extends BaseApiController
|
|||||||
'draft' => new ExamDraftResource($result['draft']),
|
'draft' => new ExamDraftResource($result['draft']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
|
||||||
|
{
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Expenses;
|
namespace App\Http\Controllers\Api\Expenses;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
use App\Http\Requests\Expenses\StoreExpenseRequest;
|
use App\Http\Requests\Expenses\StoreExpenseRequest;
|
||||||
use App\Http\Requests\Expenses\UpdateExpenseRequest;
|
use App\Http\Requests\Expenses\UpdateExpenseRequest;
|
||||||
use App\Http\Requests\Expenses\UpdateExpenseStatusRequest;
|
use App\Http\Requests\Expenses\UpdateExpenseStatusRequest;
|
||||||
@@ -51,6 +51,7 @@ class ExpenseController extends BaseApiController
|
|||||||
$rows = $this->queryService->listAll();
|
$rows = $this->queryService->listAll();
|
||||||
$rows = array_map(function ($row) {
|
$rows = array_map(function ($row) {
|
||||||
$row['receipt_url'] = $this->receiptService->receiptUrl($row['receipt_path'] ?? null);
|
$row['receipt_url'] = $this->receiptService->receiptUrl($row['receipt_path'] ?? null);
|
||||||
|
|
||||||
return $row;
|
return $row;
|
||||||
}, $rows);
|
}, $rows);
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ class ExpenseController extends BaseApiController
|
|||||||
public function show(int $id): JsonResponse
|
public function show(int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$row = $this->queryService->findById($id);
|
$row = $this->queryService->findById($id);
|
||||||
if (!$row) {
|
if (! $row) {
|
||||||
return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404);
|
return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,8 +78,12 @@ class ExpenseController extends BaseApiController
|
|||||||
|
|
||||||
public function store(StoreExpenseRequest $request): JsonResponse
|
public function store(StoreExpenseRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||||
|
if ($guard instanceof JsonResponse) {
|
||||||
|
return $guard;
|
||||||
|
}
|
||||||
|
|
||||||
$payload = $request->validated();
|
$payload = $request->validated();
|
||||||
$userId = (int) (auth()->id() ?? 0);
|
|
||||||
|
|
||||||
$receiptName = null;
|
$receiptName = null;
|
||||||
if ($request->hasFile('receipt')) {
|
if ($request->hasFile('receipt')) {
|
||||||
@@ -95,10 +100,10 @@ class ExpenseController extends BaseApiController
|
|||||||
'retailor' => $payload['retailor'] ?? null,
|
'retailor' => $payload['retailor'] ?? null,
|
||||||
'date_of_purchase' => $payload['date_of_purchase'],
|
'date_of_purchase' => $payload['date_of_purchase'],
|
||||||
'purchased_by' => (int) $payload['purchased_by'],
|
'purchased_by' => (int) $payload['purchased_by'],
|
||||||
'added_by' => $userId,
|
'added_by' => $guard,
|
||||||
'status' => $isDonation ? 'approved' : 'pending',
|
'status' => $isDonation ? 'approved' : 'pending',
|
||||||
'status_reason' => $isDonation ? 'Marked as Donation (non-reimbursable).' : null,
|
'status_reason' => $isDonation ? 'Marked as Donation (non-reimbursable).' : null,
|
||||||
'approved_by' => $isDonation ? $userId : null,
|
'approved_by' => $isDonation ? $guard : null,
|
||||||
'school_year' => $payload['school_year'] ?? null,
|
'school_year' => $payload['school_year'] ?? null,
|
||||||
'semester' => $payload['semester'] ?? null,
|
'semester' => $payload['semester'] ?? null,
|
||||||
]);
|
]);
|
||||||
@@ -111,19 +116,23 @@ class ExpenseController extends BaseApiController
|
|||||||
|
|
||||||
public function update(UpdateExpenseRequest $request, int $id): JsonResponse
|
public function update(UpdateExpenseRequest $request, int $id): JsonResponse
|
||||||
{
|
{
|
||||||
|
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||||
|
if ($guard instanceof JsonResponse) {
|
||||||
|
return $guard;
|
||||||
|
}
|
||||||
|
|
||||||
$expense = Expense::query()->find($id);
|
$expense = Expense::query()->find($id);
|
||||||
if (!$expense) {
|
if (! $expense) {
|
||||||
return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404);
|
return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = $request->validated();
|
$payload = $request->validated();
|
||||||
$userId = (int) (auth()->id() ?? 0);
|
|
||||||
|
|
||||||
$receiptName = $expense->receipt_path;
|
$receiptName = $expense->receipt_path;
|
||||||
if ($request->hasFile('receipt')) {
|
if ($request->hasFile('receipt')) {
|
||||||
$receiptName = $this->receiptService->storeReceipt($request->file('receipt'));
|
$receiptName = $this->receiptService->storeReceipt($request->file('receipt'));
|
||||||
}
|
}
|
||||||
if (!empty($payload['remove_receipt'])) {
|
if (! empty($payload['remove_receipt'])) {
|
||||||
$receiptName = null;
|
$receiptName = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,13 +147,13 @@ class ExpenseController extends BaseApiController
|
|||||||
'date_of_purchase' => $payload['date_of_purchase'],
|
'date_of_purchase' => $payload['date_of_purchase'],
|
||||||
'purchased_by' => (int) $payload['purchased_by'],
|
'purchased_by' => (int) $payload['purchased_by'],
|
||||||
'receipt_path' => $receiptName,
|
'receipt_path' => $receiptName,
|
||||||
'updated_by' => $userId,
|
'updated_by' => $guard,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($isDonation) {
|
if ($isDonation) {
|
||||||
$updateData['status'] = 'approved';
|
$updateData['status'] = 'approved';
|
||||||
$updateData['status_reason'] = 'Marked as Donation (non-reimbursable).';
|
$updateData['status_reason'] = 'Marked as Donation (non-reimbursable).';
|
||||||
$updateData['approved_by'] = $userId ?: null;
|
$updateData['approved_by'] = $guard ?: null;
|
||||||
$updateData['reimbursement_id'] = null;
|
$updateData['reimbursement_id'] = null;
|
||||||
} elseif (($expense->category ?? '') === 'Donation') {
|
} elseif (($expense->category ?? '') === 'Donation') {
|
||||||
$updateData['status_reason'] = null;
|
$updateData['status_reason'] = null;
|
||||||
@@ -162,19 +171,33 @@ class ExpenseController extends BaseApiController
|
|||||||
|
|
||||||
public function updateStatus(UpdateExpenseStatusRequest $request, int $id): JsonResponse
|
public function updateStatus(UpdateExpenseStatusRequest $request, int $id): JsonResponse
|
||||||
{
|
{
|
||||||
|
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||||
|
if ($guard instanceof JsonResponse) {
|
||||||
|
return $guard;
|
||||||
|
}
|
||||||
|
|
||||||
$expense = Expense::query()->find($id);
|
$expense = Expense::query()->find($id);
|
||||||
if (!$expense) {
|
if (! $expense) {
|
||||||
return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404);
|
return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = $request->validated();
|
$payload = $request->validated();
|
||||||
$userId = (int) (auth()->id() ?? 0);
|
|
||||||
|
|
||||||
$updated = $this->statusService->updateStatus($expense, $payload['status'], $payload['reason'] ?? '', $userId);
|
$updated = $this->statusService->updateStatus($expense, $payload['status'], $payload['reason'] ?? '', $guard);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'expense' => new ExpenseResource($updated->toArray()),
|
'expense' => new ExpenseResource($updated->toArray()),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
|
||||||
|
{
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\ExtraCharges;
|
namespace App\Http\Controllers\Api\ExtraCharges;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
use App\Http\Requests\ExtraCharges\StoreExtraChargeRequest;
|
use App\Http\Requests\ExtraCharges\StoreExtraChargeRequest;
|
||||||
use App\Http\Requests\ExtraCharges\UpdateExtraChargeRequest;
|
use App\Http\Requests\ExtraCharges\UpdateExtraChargeRequest;
|
||||||
use App\Http\Resources\ExtraCharges\ExtraChargeInvoiceOptionResource;
|
use App\Http\Resources\ExtraCharges\ExtraChargeInvoiceOptionResource;
|
||||||
@@ -87,8 +87,13 @@ class ExtraChargesController extends BaseApiController
|
|||||||
|
|
||||||
public function store(StoreExtraChargeRequest $request): JsonResponse
|
public function store(StoreExtraChargeRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
$userId = $this->authenticatedUserIdOrUnauthorized();
|
||||||
|
if ($userId instanceof JsonResponse) {
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
|
|
||||||
$payload = $request->validated();
|
$payload = $request->validated();
|
||||||
$payload['created_by'] = (int) (auth()->id() ?? 0);
|
$payload['created_by'] = $userId;
|
||||||
|
|
||||||
$result = $this->chargeService->createCharge($payload);
|
$result = $this->chargeService->createCharge($payload);
|
||||||
|
|
||||||
@@ -98,12 +103,17 @@ class ExtraChargesController extends BaseApiController
|
|||||||
public function update(UpdateExtraChargeRequest $request, int $id): JsonResponse
|
public function update(UpdateExtraChargeRequest $request, int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$charge = AdditionalCharge::query()->find($id);
|
$charge = AdditionalCharge::query()->find($id);
|
||||||
if (!$charge) {
|
if (! $charge) {
|
||||||
return response()->json(['ok' => false, 'error' => 'Charge not found'], 404);
|
return response()->json(['ok' => false, 'error' => 'Charge not found'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$userId = $this->authenticatedUserIdOrUnauthorized();
|
||||||
|
if ($userId instanceof JsonResponse) {
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
|
|
||||||
$payload = $request->validated();
|
$payload = $request->validated();
|
||||||
$payload['updated_by'] = (int) (auth()->id() ?? 0);
|
$payload['updated_by'] = $userId;
|
||||||
|
|
||||||
$ok = $this->chargeService->updateCharge($charge, $payload);
|
$ok = $this->chargeService->updateCharge($charge, $payload);
|
||||||
|
|
||||||
@@ -116,7 +126,7 @@ class ExtraChargesController extends BaseApiController
|
|||||||
public function void(int $id): JsonResponse
|
public function void(int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$charge = AdditionalCharge::query()->find($id);
|
$charge = AdditionalCharge::query()->find($id);
|
||||||
if (!$charge) {
|
if (! $charge) {
|
||||||
return response()->json(['ok' => false, 'error' => 'Charge not found'], 404);
|
return response()->json(['ok' => false, 'error' => 'Charge not found'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +140,7 @@ class ExtraChargesController extends BaseApiController
|
|||||||
public function reverse(int $id): JsonResponse
|
public function reverse(int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$charge = AdditionalCharge::query()->find($id);
|
$charge = AdditionalCharge::query()->find($id);
|
||||||
if (!$charge) {
|
if (! $charge) {
|
||||||
return response()->json(['ok' => false, 'error' => 'Charge not found'], 404);
|
return response()->json(['ok' => false, 'error' => 'Charge not found'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,4 +150,14 @@ class ExtraChargesController extends BaseApiController
|
|||||||
'ok' => $ok,
|
'ok' => $ok,
|
||||||
], $ok ? 200 : 422);
|
], $ok ? 200 : 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
|
||||||
|
{
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Family;
|
namespace App\Http\Controllers\Api\Family;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
use App\Http\Requests\Families\FamilyAdminCardRequest;
|
use App\Http\Requests\Families\FamilyAdminCardRequest;
|
||||||
use App\Http\Requests\Families\FamilyAdminIndexRequest;
|
use App\Http\Requests\Families\FamilyAdminIndexRequest;
|
||||||
use App\Http\Requests\Families\FamilyAdminSearchRequest;
|
use App\Http\Requests\Families\FamilyAdminSearchRequest;
|
||||||
use App\Http\Requests\Families\FamilyComposeEmailRequest;
|
use App\Http\Requests\Families\FamilyComposeEmailRequest;
|
||||||
use App\Http\Resources\Families\FamilyResource;
|
use App\Http\Resources\Families\FamilyResource;
|
||||||
use App\Http\Resources\Families\FamilySearchItemResource;
|
use App\Http\Resources\Families\FamilySearchItemResource;
|
||||||
use App\Models\Configuration;
|
|
||||||
use App\Services\Families\FamilyNotificationService;
|
use App\Services\Families\FamilyNotificationService;
|
||||||
use App\Services\Families\FamilyQueryService;
|
use App\Services\Families\FamilyQueryService;
|
||||||
|
use App\Services\SchoolYears\SelectedSchoolYearContextService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -20,7 +20,8 @@ class FamilyAdminController extends BaseApiController
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private FamilyQueryService $queryService,
|
private FamilyQueryService $queryService,
|
||||||
private FamilyNotificationService $notificationService
|
private FamilyNotificationService $notificationService,
|
||||||
|
private SelectedSchoolYearContextService $schoolYears
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -28,7 +29,7 @@ class FamilyAdminController extends BaseApiController
|
|||||||
public function index(FamilyAdminIndexRequest $request): JsonResponse
|
public function index(FamilyAdminIndexRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$payload = $request->validated();
|
$payload = $request->validated();
|
||||||
$schoolYear = trim((string) (Configuration::getConfig('school_year') ?? ''));
|
$schoolYear = $this->requestedSchoolYear($request);
|
||||||
|
|
||||||
$data = $this->queryService->adminIndexData(
|
$data = $this->queryService->adminIndexData(
|
||||||
$payload['student_id'] ?? null,
|
$payload['student_id'] ?? null,
|
||||||
@@ -63,7 +64,7 @@ class FamilyAdminController extends BaseApiController
|
|||||||
public function card(FamilyAdminCardRequest $request): JsonResponse
|
public function card(FamilyAdminCardRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$payload = $request->validated();
|
$payload = $request->validated();
|
||||||
$schoolYear = trim((string) (Configuration::getConfig('school_year') ?? ''));
|
$schoolYear = $this->requestedSchoolYear($request);
|
||||||
|
|
||||||
$family = $this->queryService->familyCard(
|
$family = $this->queryService->familyCard(
|
||||||
$payload['student_id'] ?? null,
|
$payload['student_id'] ?? null,
|
||||||
@@ -72,13 +73,18 @@ class FamilyAdminController extends BaseApiController
|
|||||||
$schoolYear
|
$schoolYear
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!$family) {
|
if (! $family) {
|
||||||
return $this->error('Family not found.', Response::HTTP_NOT_FOUND);
|
return $this->error('Family not found.', Response::HTTP_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->success(new FamilyResource($family));
|
return $this->success(new FamilyResource($family));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function requestedSchoolYear($request): string
|
||||||
|
{
|
||||||
|
return $this->schoolYears->fromRequest($request)->name;
|
||||||
|
}
|
||||||
|
|
||||||
public function composeEmail(FamilyComposeEmailRequest $request): JsonResponse
|
public function composeEmail(FamilyComposeEmailRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$payload = $request->validated();
|
$payload = $request->validated();
|
||||||
@@ -92,8 +98,9 @@ class FamilyAdminController extends BaseApiController
|
|||||||
$payload['reply_to_name'] ?? null
|
$payload['reply_to_name'] ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!$ok) {
|
if (! $ok) {
|
||||||
Log::warning('Compose email failed for family admin', ['recipient' => $payload['recipient']]);
|
Log::warning('Compose email failed for family admin', ['recipient' => $payload['recipient']]);
|
||||||
|
|
||||||
return $this->error('Unable to send email.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
return $this->error('Unable to send email.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Family;
|
namespace App\Http\Controllers\Api\Family;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
use App\Http\Requests\Families\FamiliesByStudentRequest;
|
use App\Http\Requests\Families\FamiliesByStudentRequest;
|
||||||
use App\Http\Requests\Families\FamilyAttachSecondByEmailRequest;
|
use App\Http\Requests\Families\FamilyAttachSecondByEmailRequest;
|
||||||
use App\Http\Requests\Families\FamilyAttachSecondByUserRequest;
|
use App\Http\Requests\Families\FamilyAttachSecondByUserRequest;
|
||||||
@@ -53,7 +53,8 @@ class FamilyController extends BaseApiController
|
|||||||
try {
|
try {
|
||||||
$result = $this->mutationService->bootstrapFamilies();
|
$result = $this->mutationService->bootstrapFamilies();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('Family bootstrap failed: ' . $e->getMessage());
|
Log::error('Family bootstrap failed: '.$e->getMessage());
|
||||||
|
|
||||||
return $this->error('Family bootstrap failed.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
return $this->error('Family bootstrap failed.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ class FamilyController extends BaseApiController
|
|||||||
(string) ($payload['relation'] ?? 'secondary')
|
(string) ($payload['relation'] ?? 'secondary')
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!($result['ok'] ?? false)) {
|
if (! ($result['ok'] ?? false)) {
|
||||||
return $this->error($result['message'] ?? 'Unable to attach guardian.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
return $this->error($result['message'] ?? 'Unable to attach guardian.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,11 +89,12 @@ class FamilyController extends BaseApiController
|
|||||||
(string) ($payload['relation'] ?? 'secondary')
|
(string) ($payload['relation'] ?? 'secondary')
|
||||||
);
|
);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('Attach guardian by email failed: ' . $e->getMessage());
|
Log::error('Attach guardian by email failed: '.$e->getMessage());
|
||||||
|
|
||||||
return $this->error('Unable to attach guardian.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
return $this->error('Unable to attach guardian.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!($result['ok'] ?? false)) {
|
if (! ($result['ok'] ?? false)) {
|
||||||
return $this->error($result['message'] ?? 'Unable to attach guardian.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
return $this->error($result['message'] ?? 'Unable to attach guardian.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,11 +158,12 @@ class FamilyController extends BaseApiController
|
|||||||
try {
|
try {
|
||||||
$result = $this->mutationService->importSecondParentsFromLegacy();
|
$result = $this->mutationService->importSecondParentsFromLegacy();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('Legacy second parent import failed: ' . $e->getMessage());
|
Log::error('Legacy second parent import failed: '.$e->getMessage());
|
||||||
|
|
||||||
return $this->error('Legacy import failed.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
return $this->error('Legacy import failed.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!($result['ok'] ?? true)) {
|
if (! ($result['ok'] ?? true)) {
|
||||||
return $this->error($result['message'] ?? 'Legacy import failed.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
return $this->error($result['message'] ?? 'Legacy import failed.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Finance;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
|
use App\Http\Requests\Finance\ApplyExtraChargeRequest;
|
||||||
|
use App\Http\Requests\Finance\ChargeListRequest;
|
||||||
|
use App\Http\Requests\Finance\MarkEventChargePaidRequest;
|
||||||
|
use App\Http\Requests\Finance\StoreEventChargeRequest;
|
||||||
|
use App\Http\Requests\Finance\StoreExtraChargeRequest;
|
||||||
|
use App\Http\Resources\Fees\ChargeActionResource;
|
||||||
|
use App\Http\Resources\Fees\ChargeListResource;
|
||||||
|
use App\Services\Billing\ChargeService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ChargeController extends BaseApiController
|
||||||
|
{
|
||||||
|
public function __construct(private ChargeService $charges)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(ChargeListRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$result = $this->charges->listForParent(
|
||||||
|
(int) $payload['parent_id'],
|
||||||
|
$payload['school_year'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'charges' => new ChargeListResource($result),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeExtra(StoreExtraChargeRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||||
|
if ($guard instanceof JsonResponse) {
|
||||||
|
return $guard;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $request->validated();
|
||||||
|
$payload['created_by'] = $guard;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->charges->createExtraCharge($payload);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Store extra charge failed', ['exception' => $e]);
|
||||||
|
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Unable to create extra charge.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = isset($result['error']) && $result['error'] === 'duplicate_charge'
|
||||||
|
? 409
|
||||||
|
: (($result['ok'] ?? false) ? 201 : 422);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => (bool) ($result['ok'] ?? false),
|
||||||
|
'charge' => new ChargeActionResource($result),
|
||||||
|
], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeEvent(StoreEventChargeRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||||
|
if ($guard instanceof JsonResponse) {
|
||||||
|
return $guard;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $request->validated();
|
||||||
|
$payload['created_by'] = $guard;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->charges->createEventCharge($payload);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Store event charge failed', ['exception' => $e]);
|
||||||
|
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Unable to create event charge.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = isset($result['error']) && $result['error'] === 'duplicate_charge'
|
||||||
|
? 409
|
||||||
|
: (($result['ok'] ?? false) ? 201 : 422);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => (bool) ($result['ok'] ?? false),
|
||||||
|
'charge' => new ChargeActionResource($result),
|
||||||
|
], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelExtra(int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$result = $this->charges->cancelExtraCharge($id);
|
||||||
|
$status = ($result['ok'] ?? false) ? 200 : 422;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => (bool) ($result['ok'] ?? false),
|
||||||
|
'charge' => new ChargeActionResource($result),
|
||||||
|
], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyExtra(ApplyExtraChargeRequest $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$result = $this->charges->applyExtraCharge($id, (int) $payload['invoice_id']);
|
||||||
|
$status = ($result['ok'] ?? false) ? 200 : 422;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => (bool) ($result['ok'] ?? false),
|
||||||
|
'charge' => new ChargeActionResource($result),
|
||||||
|
], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelEvent(Request $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$issueCredit = $request->boolean('issue_credit', true);
|
||||||
|
$result = $this->charges->cancelEventCharge($id, $issueCredit);
|
||||||
|
$status = ($result['ok'] ?? false) ? 200 : 422;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => (bool) ($result['ok'] ?? false),
|
||||||
|
'charge' => new ChargeActionResource($result),
|
||||||
|
], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markEventPaid(MarkEventChargePaidRequest $request, int $id): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
$result = $this->charges->markEventChargePaid(
|
||||||
|
$id,
|
||||||
|
(bool) $payload['paid'],
|
||||||
|
isset($payload['payment_id']) ? (int) $payload['payment_id'] : null
|
||||||
|
);
|
||||||
|
$status = ($result['ok'] ?? false) ? 200 : 422;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => (bool) ($result['ok'] ?? false),
|
||||||
|
'charge' => new ChargeActionResource($result),
|
||||||
|
], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
|
||||||
|
{
|
||||||
|
$userId = (int) (auth()->id() ?? 0);
|
||||||
|
if ($userId <= 0) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Finance;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Finance\EventChargeLifecycleRequest;
|
||||||
|
use App\Models\EventCharge;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
class EventChargeController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$q = EventCharge::query();
|
||||||
|
foreach (['parent_id', 'student_id', 'event_id', 'school_year', 'semester'] as $field) {
|
||||||
|
if ($request->filled($field)) {
|
||||||
|
$q->where($field, $request->query($field));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$status = $request->query('status');
|
||||||
|
if (Schema::hasColumn('event_charges', 'status')) {
|
||||||
|
$q->where('status', $status);
|
||||||
|
} elseif ($status === 'paid') {
|
||||||
|
$q->where('event_paid', 1);
|
||||||
|
} elseif ($status === 'pending') {
|
||||||
|
$q->where(function ($qq) {
|
||||||
|
$qq->whereNull('event_paid')->orWhere('event_paid', 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'event_charges' => $q->orderByDesc('id')->paginate((int) $request->query('per_page', 25))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(EventChargeLifecycleRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $this->normalizePayload($request->validated());
|
||||||
|
$data['created_by'] = optional($request->user())->id;
|
||||||
|
if (Schema::hasColumn('event_charges', 'status')) {
|
||||||
|
$data['status'] = $data['status'] ?? 'draft';
|
||||||
|
}
|
||||||
|
$charge = EventCharge::query()->create($data);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'event_charge' => $charge], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(int $eventCharge): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(['ok' => true, 'event_charge' => EventCharge::query()->findOrFail($eventCharge)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(EventChargeLifecycleRequest $request, int $eventCharge): JsonResponse
|
||||||
|
{
|
||||||
|
$charge = EventCharge::query()->findOrFail($eventCharge);
|
||||||
|
$charge->fill($this->normalizePayload($request->validated()))->save();
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'event_charge' => $charge->fresh()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(int $eventCharge): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->void($eventCharge);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approve(int $eventCharge): JsonResponse
|
||||||
|
{
|
||||||
|
$charge = EventCharge::query()->findOrFail($eventCharge);
|
||||||
|
if (Schema::hasColumn('event_charges', 'status')) {
|
||||||
|
$charge->status = 'approved';
|
||||||
|
}
|
||||||
|
if (Schema::hasColumn('event_charges', 'charged')) {
|
||||||
|
$charge->charged = 1;
|
||||||
|
}
|
||||||
|
$charge->save();
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'event_charge' => $charge->fresh()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function void(int $eventCharge): JsonResponse
|
||||||
|
{
|
||||||
|
$charge = EventCharge::query()->findOrFail($eventCharge);
|
||||||
|
if (Schema::hasColumn('event_charges', 'status')) {
|
||||||
|
$charge->status = 'voided';
|
||||||
|
}
|
||||||
|
if (Schema::hasColumn('event_charges', 'charged')) {
|
||||||
|
$charge->charged = 0;
|
||||||
|
}
|
||||||
|
$charge->save();
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'event_charge' => $charge->fresh()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachToInvoice(Request $request, int $eventCharge): JsonResponse
|
||||||
|
{
|
||||||
|
$request->validate(['invoice_id' => ['required', 'integer']]);
|
||||||
|
$charge = EventCharge::query()->findOrFail($eventCharge);
|
||||||
|
$invoiceId = (int) $request->input('invoice_id');
|
||||||
|
abort_if(! DB::table('invoices')->where('id', $invoiceId)->exists(), 404, 'Invoice not found.');
|
||||||
|
if (Schema::hasColumn('event_charges', 'invoice_id')) {
|
||||||
|
$charge->invoice_id = $invoiceId;
|
||||||
|
}
|
||||||
|
if (Schema::hasColumn('event_charges', 'status')) {
|
||||||
|
$charge->status = 'invoiced';
|
||||||
|
}
|
||||||
|
if (Schema::hasColumn('event_charges', 'charged')) {
|
||||||
|
$charge->charged = 1;
|
||||||
|
}
|
||||||
|
$charge->save();
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'event_charge' => $charge->fresh(), 'warning' => Schema::hasColumn('event_charges', 'invoice_id') ? null : 'invoice_id column does not exist; charge marked as charged only.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizePayload(array $data): array
|
||||||
|
{
|
||||||
|
if (isset($data['title']) && ! isset($data['event_name'])) {
|
||||||
|
$data['event_name'] = $data['title'];
|
||||||
|
}
|
||||||
|
unset($data['title'], $data['invoice_id']);
|
||||||
|
|
||||||
|
return array_filter($data, fn ($v) => $v !== null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,19 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\Finance;
|
namespace App\Http\Controllers\Api\Finance;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\BaseApiController;
|
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||||
|
use App\Http\Requests\Finance\FeeFamilyBalanceRequest;
|
||||||
use App\Http\Requests\Finance\FeeRefundRequest;
|
use App\Http\Requests\Finance\FeeRefundRequest;
|
||||||
|
use App\Http\Requests\Finance\FeeTuitionBreakdownRequest;
|
||||||
use App\Http\Requests\Finance\FeeTuitionTotalRequest;
|
use App\Http\Requests\Finance\FeeTuitionTotalRequest;
|
||||||
|
use App\Http\Resources\Fees\FeeFamilyBalanceResource;
|
||||||
use App\Http\Resources\Fees\FeeRefundResource;
|
use App\Http\Resources\Fees\FeeRefundResource;
|
||||||
|
use App\Http\Resources\Fees\FeeTuitionBreakdownResource;
|
||||||
use App\Http\Resources\Fees\FeeTuitionTotalResource;
|
use App\Http\Resources\Fees\FeeTuitionTotalResource;
|
||||||
|
use App\Services\Billing\BalanceCalculationService;
|
||||||
use App\Services\Fees\FeeRefundCalculatorService;
|
use App\Services\Fees\FeeRefundCalculatorService;
|
||||||
use App\Services\Fees\FeeStudentFeeService;
|
use App\Services\Fees\FeeStudentFeeService;
|
||||||
|
use App\Services\Fees\TuitionCalculationService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
@@ -16,7 +22,9 @@ class FeeCalculationController extends BaseApiController
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private FeeRefundCalculatorService $refunds,
|
private FeeRefundCalculatorService $refunds,
|
||||||
private FeeStudentFeeService $tuition
|
private FeeStudentFeeService $tuition,
|
||||||
|
private TuitionCalculationService $tuitionCalculator,
|
||||||
|
private BalanceCalculationService $balance
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -69,4 +77,55 @@ class FeeCalculationController extends BaseApiController
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function tuitionBreakdown(FeeTuitionBreakdownRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->tuitionCalculator->calculate($payload['students']);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Tuition breakdown calculation failed', [
|
||||||
|
'exception' => $e,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Unable to calculate tuition breakdown.',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'tuition' => new FeeTuitionBreakdownResource($result),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function familyBalance(FeeFamilyBalanceRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validated();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->balance->calculateFamilyAccount(
|
||||||
|
$payload['students'],
|
||||||
|
(int) $payload['parent_id'],
|
||||||
|
$payload['school_year'] ?? null
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Family balance calculation failed', [
|
||||||
|
'parent_id' => $payload['parent_id'] ?? null,
|
||||||
|
'exception' => $e,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'Unable to calculate family balance.',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'account' => new FeeFamilyBalanceResource($result),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user