Compare commits
71 Commits
b5fd4a4ca1
...
develop
| 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 |
@@ -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}"
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
APP_NAME=Alrahma_API
|
||||
APP_ENV=production
|
||||
APP_KEY=base64:CHANGE_ME_RUN_php_artisan_key_generate
|
||||
APP_KEY=base64:bvDokCe6RD7Jb5cxJ6P5z4rdTwS37hyUgglD8HGCE6Xwo6g3SEuZ9XNzjou53raUf6B2jMcEWxQm0Y5zcw2THw
|
||||
APP_DEBUG=false
|
||||
APP_TIMEZONE=America/New_York
|
||||
APP_URL=https://api.alrahmaisgl.org
|
||||
@@ -103,7 +103,7 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
# ----------------------------
|
||||
# JWT
|
||||
# ----------------------------
|
||||
JWT_SECRET=CHANGE_ME_USE_STRONG_RANDOM_SECRET
|
||||
JWT_SECRET=d7UbttsiymIIh-5nHw4tYoYqwGjZX5VhBtR-FnWaBTpXX0uP6D4EWx86fxYdLKMk0wIEV5xeNUIRjlNBG-eeHw
|
||||
JWT_ALGO=HS256
|
||||
JWT_TTL=60
|
||||
JWT_REFRESH_TTL=20160
|
||||
|
||||
@@ -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"
|
||||
+85
-26
@@ -1,6 +1,4 @@
|
||||
# GitLab CI for Laravel 12
|
||||
# Save this file as: .gitlab-ci.yml
|
||||
|
||||
# GitLab CI for Laravel 12 API
|
||||
workflow:
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "push"'
|
||||
@@ -12,6 +10,7 @@ stages:
|
||||
- test
|
||||
- build
|
||||
- security
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
APP_ENV: testing
|
||||
@@ -41,16 +40,19 @@ cache:
|
||||
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
|
||||
- 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=
|
||||
APP_KEY=base64:MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
|
||||
@@ -67,21 +69,23 @@ cache:
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
JWT_SECRET=ci_dummy_secret_not_for_production
|
||||
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
|
||||
- bash -o pipefail -c 'composer validate --strict 2>&1 | tee reports/composer-validate.log'
|
||||
- bash -o pipefail -c 'php -v 2>&1 | tee reports/php-version.log'
|
||||
- bash -o pipefail -c 'php artisan --version 2>&1 | tee reports/artisan-version.log'
|
||||
- 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
|
||||
@@ -94,60 +98,78 @@ laravel_pint:
|
||||
stage: validate
|
||||
script:
|
||||
- mkdir -p reports
|
||||
- bash -o pipefail -c 'vendor/bin/pint --test 2>&1 | tee reports/pint.log'
|
||||
- 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
|
||||
- bash -o pipefail -c 'php artisan migrate --force 2>&1 | tee reports/migrate.log'
|
||||
- bash -o pipefail -c 'php artisan test --testsuite=Feature --no-interaction --log-junit reports/phpunit-feature.xml 2>&1 | tee reports/phpunit-feature.log'
|
||||
- bash -o pipefail -c 'php artisan test --testsuite=Unit --no-interaction --log-junit reports/phpunit-unit.xml 2>&1 | tee reports/phpunit-unit.log'
|
||||
- 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-feature.xml
|
||||
- reports/phpunit-unit.xml
|
||||
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
|
||||
- npm ci --cache .npm --prefer-offline
|
||||
script:
|
||||
- mkdir -p reports
|
||||
- sh -c 'npm run build 2>&1 | tee reports/npm-build.log'
|
||||
- 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
|
||||
- bash -o pipefail -c 'composer audit --locked --no-interaction 2>&1 | tee reports/composer-audit.log'
|
||||
- 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
|
||||
@@ -156,7 +178,7 @@ npm_audit:
|
||||
- npm ci
|
||||
script:
|
||||
- mkdir -p reports
|
||||
- sh -c 'npm audit --audit-level=high 2>&1 | tee reports/npm-audit.log'
|
||||
- npm audit --json --audit-level=high | tee reports/npm-audit.json || true
|
||||
artifacts:
|
||||
when: always
|
||||
expire_in: 7 days
|
||||
@@ -172,14 +194,20 @@ secret_detection_basic:
|
||||
script:
|
||||
- mkdir -p reports
|
||||
- |
|
||||
echo "Scanning for obvious committed secrets..." | tee reports/secret-detection-basic.log
|
||||
! grep -RInE "(APP_KEY=base64:|DB_PASSWORD=.+|JWT_SECRET=.+|MAIL_PASSWORD=.+|PAYPAL_(CLIENT|SECRET|MODE)|AKIA[0-9A-Z]{16}|-----BEGIN (RSA|OPENSSH|PRIVATE) KEY-----)" \
|
||||
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 \
|
||||
. 2>&1 | tee -a reports/secret-detection-basic.log
|
||||
--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
|
||||
@@ -187,9 +215,40 @@ secret_detection_basic:
|
||||
- reports/
|
||||
allow_failure: false
|
||||
|
||||
# Optional GitLab-native security scanners.
|
||||
# These run only if your GitLab tier/project supports the templates.
|
||||
# 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
|
||||
- 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
|
||||
+1
-14
@@ -8,19 +8,7 @@ COPY . .
|
||||
RUN composer dump-autoload --optimize \
|
||||
&& php artisan package:discover --ansi
|
||||
|
||||
FROM node:20-alpine AS assets
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
|
||||
RUN if [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f yarn.lock ]; then yarn install --frozen-lockfile; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable && pnpm install --frozen-lockfile; \
|
||||
else npm install; fi
|
||||
COPY resources ./resources
|
||||
COPY vite.config.js ./
|
||||
COPY public ./public
|
||||
RUN if [ -f package.json ]; then npm run build; fi
|
||||
|
||||
FROM php:8.2-fpm
|
||||
FROM php:8.4-fpm
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
@@ -39,7 +27,6 @@ RUN apt-get update \
|
||||
WORKDIR /var/www/html
|
||||
|
||||
COPY --from=vendor /app /var/www/html
|
||||
COPY --from=assets /app/public /var/www/html/public
|
||||
|
||||
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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.
@@ -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;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -22,7 +22,7 @@ class AdministratorAbsenceController extends Controller
|
||||
return response()->json(['message' => 'Please log in first.'], 401);
|
||||
}
|
||||
|
||||
return response()->json($this->service->getAbsenceFormData($userId));
|
||||
return response()->json(['ok' => true] + $this->service->getAbsenceFormData($userId));
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
|
||||
@@ -15,7 +15,7 @@ class AdministratorDashboardController extends Controller
|
||||
|
||||
public function metrics(): JsonResponse
|
||||
{
|
||||
return response()->json($this->service->metrics());
|
||||
return response()->json(['ok' => true] + $this->service->metrics());
|
||||
}
|
||||
|
||||
public function userSearch(Request $request): JsonResponse
|
||||
|
||||
@@ -19,7 +19,7 @@ class AdministratorEnrollmentController extends Controller
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$this->service->enrollmentWithdrawalData(
|
||||
['ok' => true] + $this->service->enrollmentWithdrawalData(
|
||||
(string) $request->query('schoolYear', '')
|
||||
)
|
||||
);
|
||||
@@ -27,7 +27,7 @@ class AdministratorEnrollmentController extends Controller
|
||||
|
||||
public function newStudents(): JsonResponse
|
||||
{
|
||||
return response()->json($this->service->showNewStudentsData());
|
||||
return response()->json(['ok' => true] + $this->service->showNewStudentsData());
|
||||
}
|
||||
|
||||
public function updateStatuses(Request $request): JsonResponse
|
||||
|
||||
@@ -16,7 +16,7 @@ class AdministratorNotificationController extends Controller
|
||||
|
||||
public function alerts(): JsonResponse
|
||||
{
|
||||
return response()->json($this->service->notificationsAlertsData());
|
||||
return response()->json(['ok' => true] + $this->service->notificationsAlertsData());
|
||||
}
|
||||
|
||||
public function saveAlerts(Request $request): JsonResponse
|
||||
|
||||
@@ -138,6 +138,7 @@ class AdministratorPromotionController extends BaseApiController
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'result' => $result,
|
||||
'data' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -331,6 +332,7 @@ class AdministratorPromotionController extends BaseApiController
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'result' => $result,
|
||||
'data' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -375,6 +377,7 @@ class AdministratorPromotionController extends BaseApiController
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'result' => $result,
|
||||
'data' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -438,6 +441,7 @@ class AdministratorPromotionController extends BaseApiController
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'created' => $created,
|
||||
'data' => ['created' => $created],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ class AttendanceCommentTemplateController extends Controller
|
||||
) === true;
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'templates' => $this->service->list($activeOnly),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,9 @@ class TeacherAttendanceApiController extends Controller
|
||||
return new TeacherAttendanceFormResource(
|
||||
$this->queryService->teacherAttendanceFormData(
|
||||
$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) {
|
||||
@@ -68,7 +70,7 @@ class TeacherAttendanceApiController extends Controller
|
||||
|
||||
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
|
||||
{
|
||||
$userId = (int) (auth()->id() ?? 0);
|
||||
$userId = (int) (auth('api')->id() ?? auth()->id() ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,11 @@ class AttendanceTrackingController extends Controller
|
||||
|
||||
public function record(Request $request): JsonResponse
|
||||
{
|
||||
$guard = $this->forbidLowPrivilegeActor();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'student_id' => ['required', 'integer', 'min:1'],
|
||||
'date' => ['required', 'date_format:Y-m-d'],
|
||||
@@ -87,6 +92,11 @@ class AttendanceTrackingController extends Controller
|
||||
|
||||
public function sendAutoEmails(Request $request): JsonResponse
|
||||
{
|
||||
$guard = $this->forbidLowPrivilegeActor(adminOnly: true);
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$result = $this->service->sendAutoEmails($request->input('variant'));
|
||||
|
||||
return response()->json($result);
|
||||
@@ -124,6 +134,11 @@ class AttendanceTrackingController extends Controller
|
||||
|
||||
public function sendManualEmail(Request $request): JsonResponse
|
||||
{
|
||||
$guard = $this->forbidLowPrivilegeActor();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'student_id' => ['required', 'integer', 'min:1'],
|
||||
'to' => ['required', 'email'],
|
||||
@@ -173,6 +188,11 @@ class AttendanceTrackingController extends Controller
|
||||
|
||||
public function saveNotificationNote(Request $request): JsonResponse
|
||||
{
|
||||
$guard = $this->forbidLowPrivilegeActor();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'student_id' => ['required', 'integer', 'min:1'],
|
||||
'code' => ['required', 'string'],
|
||||
@@ -197,4 +217,33 @@ class AttendanceTrackingController extends Controller
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Services\Auth\ApiLoginSecurityService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
|
||||
|
||||
class AuthController extends BaseApiController
|
||||
@@ -24,6 +25,11 @@ class AuthController extends BaseApiController
|
||||
$password = (string) ($payload['password'] ?? '');
|
||||
|
||||
if ($email === '' || $password === '') {
|
||||
Log::notice('api_login: missing credentials', [
|
||||
'ip' => (string) $request->ip(),
|
||||
'email' => $this->maskEmail($email),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => 'Email and password are required.',
|
||||
@@ -32,6 +38,11 @@ class AuthController extends BaseApiController
|
||||
|
||||
$ip = $request->ip();
|
||||
if ($security->isIpBlocked((string) $ip)) {
|
||||
Log::notice('api_login: ip blocked', [
|
||||
'ip' => (string) $ip,
|
||||
'email' => $this->maskEmail($email),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => 'Too many failed attempts from your IP. Please try again later.',
|
||||
@@ -41,6 +52,24 @@ class AuthController extends BaseApiController
|
||||
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
|
||||
if (! $user) {
|
||||
$security->logIpAttempt((string) $ip);
|
||||
Log::notice('api_login: unknown email', [
|
||||
'ip' => (string) $ip,
|
||||
'email' => $this->maskEmail($email),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => 'Invalid email or password.',
|
||||
], 401);
|
||||
}
|
||||
|
||||
if (blank($user->password)) {
|
||||
$security->logIpAttempt((string) $ip);
|
||||
Log::notice('api_login: password missing', [
|
||||
'ip' => (string) $ip,
|
||||
'email' => $this->maskEmail($email),
|
||||
'user_id' => (int) $user->id,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
@@ -50,6 +79,12 @@ class AuthController extends BaseApiController
|
||||
|
||||
if (! verify_stored_password($password, (string) $user->password)) {
|
||||
$security->handleFailedLogin($user, $email, (string) $ip);
|
||||
Log::notice('api_login: password mismatch', [
|
||||
'ip' => (string) $ip,
|
||||
'email' => $this->maskEmail($email),
|
||||
'user_id' => (int) $user->id,
|
||||
'failed_attempts' => (int) ($user->fresh()?->failed_attempts ?? 0),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
@@ -60,6 +95,12 @@ class AuthController extends BaseApiController
|
||||
// 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.',
|
||||
@@ -71,6 +112,12 @@ class AuthController extends BaseApiController
|
||||
|
||||
$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.',
|
||||
@@ -82,6 +129,19 @@ class AuthController extends BaseApiController
|
||||
return response()->json($security->buildLoginResponse($fresh, $jwtToken));
|
||||
}
|
||||
|
||||
private function maskEmail(string $email): string
|
||||
{
|
||||
$email = trim(strtolower($email));
|
||||
if ($email === '' || ! str_contains($email, '@')) {
|
||||
return $email;
|
||||
}
|
||||
|
||||
[$local, $domain] = explode('@', $email, 2);
|
||||
$localPrefix = substr($local, 0, min(2, strlen($local)));
|
||||
|
||||
return $localPrefix.str_repeat('*', max(strlen($local) - strlen($localPrefix), 0)).'@'.$domain;
|
||||
}
|
||||
|
||||
public function refresh(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
@@ -106,18 +166,32 @@ class AuthController extends BaseApiController
|
||||
|
||||
$teacherContext = $user->teacherSessionContext();
|
||||
|
||||
return $this->respondSuccess([
|
||||
$payload = [
|
||||
'id' => (int) $user->id,
|
||||
'firstname' => $user->firstname,
|
||||
'lastname' => $user->lastname,
|
||||
'email' => $user->email,
|
||||
'class_section_id' => $teacherContext['class_section_id'],
|
||||
'class_section_name' => $teacherContext['class_section_name'],
|
||||
], 'OK');
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'message' => 'OK',
|
||||
'data' => $payload,
|
||||
'user' => $payload,
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
if ($request->hasAny(['action', 'purge', 'rewrite', 'created_by', 'updated_by', 'deleted_by', 'actor_id'])) {
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => 'Unsupported logout payload.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$token = $request->bearerToken();
|
||||
if ($token && substr_count($token, '.') === 2) {
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,7 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -89,6 +90,11 @@ class AuthSessionController extends Controller
|
||||
$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.',
|
||||
@@ -99,6 +105,24 @@ class AuthSessionController extends Controller
|
||||
|
||||
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,
|
||||
@@ -107,6 +131,12 @@ class AuthSessionController extends Controller
|
||||
}
|
||||
|
||||
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.',
|
||||
@@ -115,6 +145,12 @@ class AuthSessionController extends Controller
|
||||
|
||||
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,
|
||||
@@ -124,6 +160,12 @@ class AuthSessionController extends Controller
|
||||
|
||||
$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.',
|
||||
@@ -151,6 +193,19 @@ class AuthSessionController extends Controller
|
||||
]));
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
|
||||
@@ -28,6 +28,7 @@ class RegisterController extends BaseApiController
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'captcha' => $captcha,
|
||||
'user' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ class RolePermissionController extends BaseApiController
|
||||
private RoleQueryService $queryService,
|
||||
private RoleAssignmentService $assignmentService,
|
||||
private RolePermissionService $permissionService,
|
||||
private RoleCrudService $roleCrudService,
|
||||
private PermissionCrudService $permissionCrudService
|
||||
) {
|
||||
parent::__construct();
|
||||
@@ -68,7 +67,7 @@ class RolePermissionController extends BaseApiController
|
||||
public function storeRole(RoleStoreRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$role = $this->roleCrudService->create($request->validated());
|
||||
$role = $this->roleCrudService()->create($request->validated());
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Role store failed: '.$e->getMessage());
|
||||
|
||||
@@ -88,7 +87,7 @@ class RolePermissionController extends BaseApiController
|
||||
}
|
||||
|
||||
try {
|
||||
$role = $this->roleCrudService->update($role, $request->validated());
|
||||
$role = $this->roleCrudService()->update($role, $request->validated());
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Role update failed: '.$e->getMessage());
|
||||
|
||||
@@ -108,7 +107,7 @@ class RolePermissionController extends BaseApiController
|
||||
}
|
||||
|
||||
try {
|
||||
$this->roleCrudService->delete($role);
|
||||
$this->roleCrudService()->delete($role);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Role delete failed: '.$e->getMessage());
|
||||
|
||||
@@ -246,8 +245,23 @@ class RolePermissionController extends BaseApiController
|
||||
|
||||
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, $request->validated()['permissions'] ?? []);
|
||||
$this->permissionService->saveRolePermissions($roleId, $permissions ?? []);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Role permissions update failed: '.$e->getMessage());
|
||||
|
||||
@@ -266,4 +280,9 @@ class RolePermissionController extends BaseApiController
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
private function roleCrudService(): object
|
||||
{
|
||||
return app(RoleCrudService::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ class ClassPreparationController extends BaseApiController
|
||||
);
|
||||
|
||||
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([
|
||||
|
||||
@@ -19,6 +19,7 @@ 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;
|
||||
@@ -58,15 +59,23 @@ class ClassProgressController extends BaseApiController
|
||||
return $auth;
|
||||
}
|
||||
|
||||
$schoolYear = (string) (Configuration::getConfig('school_year') ?? '');
|
||||
$semester = (string) (Configuration::getConfig('semester') ?? '');
|
||||
$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);
|
||||
$activeClassId = $requestedClassId > 0 ? $requestedClassId : (int) ($assignments[0]['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
|
||||
) ?? ($assignments[0] ?? []);
|
||||
) ?? $requestedAssignment ?? ($assignments[0] ?? []);
|
||||
$sundayCount = (int) ($request->validated('sunday_count') ?? 12);
|
||||
$payload = $this->buildMetaPayload($activeClassId > 0 ? $activeClassId : null, $sundayCount, $schoolYear, $semester);
|
||||
$payload['classes'] = $assignments;
|
||||
@@ -88,10 +97,25 @@ class ClassProgressController extends BaseApiController
|
||||
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);
|
||||
$filters['semester'] = $filters['semester'] ?? ($meta['semester'] ?? 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'])) {
|
||||
@@ -121,7 +145,12 @@ class ClassProgressController extends BaseApiController
|
||||
|
||||
try {
|
||||
$filesBySubject = $request->filesBySubject();
|
||||
$reports = $this->mutationService->createReports($auth, $request->validated(), $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([
|
||||
@@ -251,6 +280,127 @@ class ClassProgressController extends BaseApiController
|
||||
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);
|
||||
@@ -260,6 +410,7 @@ class ClassProgressController extends BaseApiController
|
||||
'status_options' => $this->metaService->statusOptions(),
|
||||
'sunday_options' => $this->metaService->sundayOptionsAlignedWithCi($sundayCount),
|
||||
'curriculum' => $this->metaService->curriculumOptions($classId, $meta['school_year'] ?? null),
|
||||
'unit_options' => $this->metaService->unitOptions($classId, $meta['school_year'] ?? null),
|
||||
'meta' => $meta,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,9 @@ use App\Http\Requests\Families\FamilyAdminSearchRequest;
|
||||
use App\Http\Requests\Families\FamilyComposeEmailRequest;
|
||||
use App\Http\Resources\Families\FamilyResource;
|
||||
use App\Http\Resources\Families\FamilySearchItemResource;
|
||||
use App\Models\Configuration;
|
||||
use App\Services\Families\FamilyNotificationService;
|
||||
use App\Services\Families\FamilyQueryService;
|
||||
use App\Services\SchoolYears\SelectedSchoolYearContextService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -20,7 +20,8 @@ class FamilyAdminController extends BaseApiController
|
||||
{
|
||||
public function __construct(
|
||||
private FamilyQueryService $queryService,
|
||||
private FamilyNotificationService $notificationService
|
||||
private FamilyNotificationService $notificationService,
|
||||
private SelectedSchoolYearContextService $schoolYears
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -28,7 +29,7 @@ class FamilyAdminController extends BaseApiController
|
||||
public function index(FamilyAdminIndexRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$schoolYear = trim((string) (Configuration::getConfig('school_year') ?? ''));
|
||||
$schoolYear = $this->requestedSchoolYear($request);
|
||||
|
||||
$data = $this->queryService->adminIndexData(
|
||||
$payload['student_id'] ?? null,
|
||||
@@ -63,7 +64,7 @@ class FamilyAdminController extends BaseApiController
|
||||
public function card(FamilyAdminCardRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$schoolYear = trim((string) (Configuration::getConfig('school_year') ?? ''));
|
||||
$schoolYear = $this->requestedSchoolYear($request);
|
||||
|
||||
$family = $this->queryService->familyCard(
|
||||
$payload['student_id'] ?? null,
|
||||
@@ -79,6 +80,11 @@ class FamilyAdminController extends BaseApiController
|
||||
return $this->success(new FamilyResource($family));
|
||||
}
|
||||
|
||||
private function requestedSchoolYear($request): string
|
||||
{
|
||||
return $this->schoolYears->fromRequest($request)->name;
|
||||
}
|
||||
|
||||
public function composeEmail(FamilyComposeEmailRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -54,9 +54,9 @@ class ChargeController extends BaseApiController
|
||||
return response()->json(['ok' => false, 'message' => 'Unable to create extra charge.'], 500);
|
||||
}
|
||||
|
||||
$status = ($result['ok'] ?? false)
|
||||
? (isset($result['error']) && $result['error'] === 'duplicate_charge' ? 409 : 201)
|
||||
: 422;
|
||||
$status = isset($result['error']) && $result['error'] === 'duplicate_charge'
|
||||
? 409
|
||||
: (($result['ok'] ?? false) ? 201 : 422);
|
||||
|
||||
return response()->json([
|
||||
'ok' => (bool) ($result['ok'] ?? false),
|
||||
@@ -82,9 +82,9 @@ class ChargeController extends BaseApiController
|
||||
return response()->json(['ok' => false, 'message' => 'Unable to create event charge.'], 500);
|
||||
}
|
||||
|
||||
$status = ($result['ok'] ?? false)
|
||||
? (isset($result['error']) && $result['error'] === 'duplicate_charge' ? 409 : 201)
|
||||
: 422;
|
||||
$status = isset($result['error']) && $result['error'] === 'duplicate_charge'
|
||||
? 409
|
||||
: (($result['ok'] ?? false) ? 201 : 422);
|
||||
|
||||
return response()->json([
|
||||
'ok' => (bool) ($result['ok'] ?? false),
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Services\Finance\FinanceNotificationLogService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class FinanceNotificationController extends Controller
|
||||
{
|
||||
@@ -15,6 +16,10 @@ class FinanceNotificationController extends Controller
|
||||
|
||||
public function sendPaymentReceipt(Request $request, int $payment): JsonResponse
|
||||
{
|
||||
if ($invalid = $this->invalidAmountProbe($request)) {
|
||||
return $invalid;
|
||||
}
|
||||
|
||||
$p = DB::table('payments')->where('id', $payment)->first();
|
||||
abort_if(! $p, 404, 'Payment not found.');
|
||||
$receipt = $this->service->nextReceiptNumber($p->school_year ?? date('Y'));
|
||||
@@ -33,6 +38,10 @@ class FinanceNotificationController extends Controller
|
||||
|
||||
public function sendRefundReceipt(Request $request, int $refund): JsonResponse
|
||||
{
|
||||
if ($invalid = $this->invalidAmountProbe($request)) {
|
||||
return $invalid;
|
||||
}
|
||||
|
||||
$log = $this->service->log(['refund_id' => $refund, 'notification_type' => 'refund_receipt', 'channel' => 'database', 'subject' => 'Refund receipt'], optional($request->user())->id);
|
||||
|
||||
return response()->json(['ok' => true, 'notification_log' => $log]);
|
||||
@@ -40,6 +49,10 @@ class FinanceNotificationController extends Controller
|
||||
|
||||
public function sendInvoiceStatement(Request $request, int $invoice): JsonResponse
|
||||
{
|
||||
if ($invalid = $this->invalidAmountProbe($request)) {
|
||||
return $invalid;
|
||||
}
|
||||
|
||||
$inv = DB::table('invoices')->where('id', $invoice)->first();
|
||||
abort_if(! $inv, 404, 'Invoice not found.');
|
||||
$log = $this->service->log(['parent_id' => $inv->parent_id ?? null, 'invoice_id' => $invoice, 'notification_type' => 'statement_available', 'channel' => 'database', 'subject' => 'Statement available'], optional($request->user())->id);
|
||||
@@ -49,6 +62,10 @@ class FinanceNotificationController extends Controller
|
||||
|
||||
public function sendOverdueReminder(Request $request, int $parent): JsonResponse
|
||||
{
|
||||
if ($invalid = $this->invalidAmountProbe($request)) {
|
||||
return $invalid;
|
||||
}
|
||||
|
||||
$log = $this->service->log(['parent_id' => $parent, 'notification_type' => 'overdue_balance_reminder', 'channel' => 'database', 'subject' => 'Overdue balance reminder'], optional($request->user())->id);
|
||||
|
||||
return response()->json(['ok' => true, 'notification_log' => $log]);
|
||||
@@ -56,6 +73,10 @@ class FinanceNotificationController extends Controller
|
||||
|
||||
public function sendInstallmentReminder(Request $request, int $installment): JsonResponse
|
||||
{
|
||||
if ($invalid = $this->invalidAmountProbe($request)) {
|
||||
return $invalid;
|
||||
}
|
||||
|
||||
$log = $this->service->log(['installment_id' => $installment, 'notification_type' => 'installment_reminder', 'channel' => 'database', 'subject' => 'Installment reminder'], optional($request->user())->id);
|
||||
|
||||
return response()->json(['ok' => true, 'notification_log' => $log]);
|
||||
@@ -65,4 +86,25 @@ class FinanceNotificationController extends Controller
|
||||
{
|
||||
return response()->json(['ok' => true, 'logs' => $this->service->list($request->validated())]);
|
||||
}
|
||||
|
||||
private function invalidAmountProbe(Request $request): ?JsonResponse
|
||||
{
|
||||
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
|
||||
$validator = Validator::make($data, [
|
||||
'amount' => ['nullable', 'numeric', 'gt:0', 'max:999999999999.99'],
|
||||
'paid_amount' => ['nullable', 'numeric', 'gt:0', 'max:999999999999.99'],
|
||||
'total' => ['nullable', 'numeric', 'gt:0', 'max:999999999999.99'],
|
||||
'unit_cost' => ['nullable', 'numeric', 'gt:0', 'max:999999999999.99'],
|
||||
'fee' => ['nullable', 'numeric', 'gt:0', 'max:999999999999.99'],
|
||||
]);
|
||||
|
||||
if (! $validator->fails()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ class FinancialController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function stakeholderAnalysisCsv(StakeholderFinancialAnalysisRequest $request): StreamedResponse
|
||||
public function stakeholderAnalysisCsv(StakeholderFinancialAnalysisRequest $request): JsonResponse|StreamedResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$analysis = $this->stakeholderAnalysisService->analyze(
|
||||
@@ -161,6 +161,15 @@ class FinancialController extends BaseApiController
|
||||
);
|
||||
$rows = $this->stakeholderAnalysisService->csvRows($analysis);
|
||||
$schoolYear = $analysis['schoolYear'] ?? 'report';
|
||||
$filename = 'stakeholder_financial_analysis_'.$schoolYear.'_'.date('Ymd_His').'.csv';
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'filename' => $filename,
|
||||
'rows' => $rows,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->streamDownload(function () use ($rows) {
|
||||
$out = fopen('php://output', 'w');
|
||||
@@ -168,7 +177,7 @@ class FinancialController extends BaseApiController
|
||||
fputcsv($out, $row);
|
||||
}
|
||||
fclose($out);
|
||||
}, 'stakeholder_financial_analysis_'.$schoolYear.'_'.date('Ymd_His').'.csv', ['Content-Type' => 'text/csv']);
|
||||
}, $filename, ['Content-Type' => 'text/csv']);
|
||||
}
|
||||
|
||||
public function downloadCsv(FinancialReportRequest $request): StreamedResponse
|
||||
|
||||
@@ -51,6 +51,11 @@ class InvoiceController extends BaseApiController
|
||||
$validator = Validator::make($data, [
|
||||
'parent_id' => ['required', 'integer', 'exists:users,id'],
|
||||
'school_year' => ['nullable', 'string', 'max:20'],
|
||||
'amount' => ['nullable', 'numeric', 'gt:0', 'max:999999999999.99'],
|
||||
'paid_amount' => ['nullable', 'numeric', 'gt:0', 'max:999999999999.99'],
|
||||
'total' => ['nullable', 'numeric', 'gt:0', 'max:999999999999.99'],
|
||||
'unit_cost' => ['nullable', 'numeric', 'gt:0', 'max:999999999999.99'],
|
||||
'fee' => ['nullable', 'numeric', 'gt:0', 'max:999999999999.99'],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Requests\Payments\PaymentManualUpdateRequest;
|
||||
use App\Services\Payments\PaymentManualService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class PaymentManualController extends BaseApiController
|
||||
@@ -70,18 +71,26 @@ class PaymentManualController extends BaseApiController
|
||||
|
||||
$payload = $request->validated();
|
||||
|
||||
$result = $this->service->recordPayment(
|
||||
$invoiceId,
|
||||
(float) $payload['amount'],
|
||||
(string) $payload['payment_method'],
|
||||
$payload['payment_date'] ?? null,
|
||||
$payload['check_number'] ?? null,
|
||||
$payload['payment_type'] ?? null,
|
||||
$request->file('payment_file'),
|
||||
$payload['school_year'] ?? null,
|
||||
$payload['semester'] ?? null,
|
||||
$guard
|
||||
);
|
||||
try {
|
||||
$result = $this->service->recordPayment(
|
||||
$invoiceId,
|
||||
(float) $payload['amount'],
|
||||
(string) $payload['payment_method'],
|
||||
$payload['payment_date'] ?? null,
|
||||
$payload['check_number'] ?? null,
|
||||
$payload['payment_type'] ?? null,
|
||||
$request->file('payment_file'),
|
||||
$payload['school_year'] ?? null,
|
||||
$payload['semester'] ?? null,
|
||||
$guard
|
||||
);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Manual payment record failed: '.$e->getMessage());
|
||||
|
||||
return response()->json(['ok' => false, 'message' => 'Unable to record payment.'], 400);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true] + $result);
|
||||
}
|
||||
@@ -95,14 +104,22 @@ class PaymentManualController extends BaseApiController
|
||||
|
||||
$payload = $request->validated();
|
||||
|
||||
$this->service->editPayment(
|
||||
$paymentId,
|
||||
(float) $payload['paid_amount'],
|
||||
(string) $payload['payment_method'],
|
||||
$payload['check_number'] ?? null,
|
||||
$request->file('payment_file'),
|
||||
$guard
|
||||
);
|
||||
try {
|
||||
$this->service->editPayment(
|
||||
$paymentId,
|
||||
(float) $payload['paid_amount'],
|
||||
(string) $payload['payment_method'],
|
||||
$payload['check_number'] ?? null,
|
||||
$request->file('payment_file'),
|
||||
$guard
|
||||
);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Manual payment edit failed: '.$e->getMessage());
|
||||
|
||||
return response()->json(['ok' => false, 'message' => 'Unable to update payment.'], 400);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,13 @@ class PaypalPaymentController extends BaseApiController
|
||||
|
||||
public function create(int $paymentId): JsonResponse
|
||||
{
|
||||
$result = $this->service->createPayment($paymentId);
|
||||
try {
|
||||
$result = $this->service->createPayment($paymentId);
|
||||
} catch (\RuntimeException $e) {
|
||||
$status = str_contains(strtolower($e->getMessage()), 'not found') ? 404 : 422;
|
||||
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], $status);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true] + $result);
|
||||
}
|
||||
@@ -32,11 +38,15 @@ class PaypalPaymentController extends BaseApiController
|
||||
public function execute(PaypalExecuteRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$this->service->executePayment(
|
||||
(string) $payload['payer_id'],
|
||||
$payload['paypal_payment_id'] ?? null,
|
||||
isset($payload['payment_id']) ? (int) $payload['payment_id'] : null
|
||||
);
|
||||
try {
|
||||
$this->service->executePayment(
|
||||
(string) $payload['payer_id'],
|
||||
$payload['paypal_payment_id'] ?? null,
|
||||
isset($payload['payment_id']) ? (int) $payload['payment_id'] : null
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
@@ -34,35 +34,46 @@ class PaypalTransactionsController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function downloadCsv(PaypalTransactionsListRequest $request): StreamedResponse
|
||||
public function downloadCsv(PaypalTransactionsListRequest $request): JsonResponse|StreamedResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$rows = $this->service->listAll($payload['q'] ?? null);
|
||||
|
||||
$filename = 'paypal_transactions_'.date('Ymd_His').'.csv';
|
||||
$csvRows = [[
|
||||
'ID', 'Transaction ID', 'Order ID', 'Parent School ID',
|
||||
'Email', 'Amount', 'Net Amount', 'Currency',
|
||||
'Status', 'Event Type', 'Created At',
|
||||
]];
|
||||
|
||||
return response()->streamDownload(function () use ($rows) {
|
||||
$out = fopen('php://output', 'w');
|
||||
fputcsv($out, [
|
||||
'ID', 'Transaction ID', 'Order ID', 'Parent School ID',
|
||||
'Email', 'Amount', 'Net Amount', 'Currency',
|
||||
'Status', 'Event Type', 'Created At',
|
||||
foreach ($rows as $row) {
|
||||
$csvRows[] = [
|
||||
$row['id'] ?? null,
|
||||
$row['transaction_id'] ?? null,
|
||||
$row['order_id'] ?? null,
|
||||
$row['parent_school_id'] ?? null,
|
||||
$row['payer_email'] ?? null,
|
||||
$row['amount'] ?? null,
|
||||
$row['net_amount'] ?? null,
|
||||
$row['currency'] ?? null,
|
||||
$row['status'] ?? null,
|
||||
$row['event_type'] ?? null,
|
||||
$row['created_at'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'filename' => $filename,
|
||||
'rows' => $csvRows,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
fputcsv($out, [
|
||||
$row['id'] ?? null,
|
||||
$row['transaction_id'] ?? null,
|
||||
$row['order_id'] ?? null,
|
||||
$row['parent_school_id'] ?? null,
|
||||
$row['payer_email'] ?? null,
|
||||
$row['amount'] ?? null,
|
||||
$row['net_amount'] ?? null,
|
||||
$row['currency'] ?? null,
|
||||
$row['status'] ?? null,
|
||||
$row['event_type'] ?? null,
|
||||
$row['created_at'] ?? null,
|
||||
]);
|
||||
return response()->streamDownload(function () use ($csvRows) {
|
||||
$out = fopen('php://output', 'w');
|
||||
foreach ($csvRows as $row) {
|
||||
fputcsv($out, $row);
|
||||
}
|
||||
|
||||
fclose($out);
|
||||
|
||||
@@ -34,6 +34,7 @@ use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
@@ -112,6 +113,17 @@ class ReimbursementController extends BaseApiController
|
||||
$data = array_merge($request->query->all(), $request->all(), $request->json()->all());
|
||||
$validator = Validator::make($data, [
|
||||
'title' => ['nullable', 'string', 'max:255'],
|
||||
'confirm' => ['nullable', 'boolean'],
|
||||
'date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'from' => ['nullable', 'date_format:Y-m-d'],
|
||||
'to' => ['nullable', 'date_format:Y-m-d'],
|
||||
'start_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date_format:Y-m-d'],
|
||||
'amount' => ['nullable', 'numeric', 'gt:0', 'max:999999999999.99'],
|
||||
'paid_amount' => ['nullable', 'numeric', 'gt:0', 'max:999999999999.99'],
|
||||
'total' => ['nullable', 'numeric', 'gt:0', 'max:999999999999.99'],
|
||||
'unit_cost' => ['nullable', 'numeric', 'gt:0', 'max:999999999999.99'],
|
||||
'fee' => ['nullable', 'numeric', 'gt:0', 'max:999999999999.99'],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@@ -122,6 +134,15 @@ class ReimbursementController extends BaseApiController
|
||||
}
|
||||
|
||||
$payload = $validator->validated();
|
||||
if (trim((string) ($payload['title'] ?? '')) === '' && ($payload['confirm'] ?? false) !== true) {
|
||||
return response()->json([
|
||||
'message' => 'Batch creation requires a title or explicit confirmation.',
|
||||
'errors' => [
|
||||
'title' => ['Provide a batch title or set confirm to true.'],
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$schoolYear = $this->context->getSchoolYear();
|
||||
$semester = $this->context->getSemester();
|
||||
|
||||
@@ -130,6 +151,7 @@ class ReimbursementController extends BaseApiController
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'batch' => $batch,
|
||||
'data' => $batch,
|
||||
], 201);
|
||||
}
|
||||
|
||||
@@ -265,6 +287,59 @@ class ReimbursementController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function createContext(Request $request): JsonResponse
|
||||
{
|
||||
$expenseId = (int) $request->query('expense_id', 0);
|
||||
$prefillAmount = null;
|
||||
$expense = null;
|
||||
|
||||
if ($expenseId > 0) {
|
||||
$expense = DB::table('expenses')
|
||||
->select('id', 'amount', 'description', 'purchased_by', 'receipt_path', 'retailor')
|
||||
->where('id', $expenseId)
|
||||
->first();
|
||||
if ($expense) {
|
||||
$prefillAmount = $expense->amount ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
$data = $this->queryService->index([
|
||||
'school_year' => $request->query('school_year', $this->context->getSchoolYear()),
|
||||
'semester' => $request->query('semester', $this->context->getSemester()),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'users' => ReimbursementRecipientResource::collection($data['users'] ?? []),
|
||||
'expense_id' => $expenseId ?: null,
|
||||
'expense' => $expense ? (array) $expense : null,
|
||||
'prefill_amount' => $prefillAmount,
|
||||
]);
|
||||
}
|
||||
|
||||
public function editData(int $id): JsonResponse
|
||||
{
|
||||
$reimb = Reimbursement::query()->find($id);
|
||||
if (! $reimb) {
|
||||
return response()->json(['ok' => false, 'message' => 'Reimbursement not found.'], 404);
|
||||
}
|
||||
|
||||
$data = $this->queryService->index([
|
||||
'school_year' => $reimb->school_year ?? $this->context->getSchoolYear(),
|
||||
'semester' => $reimb->semester ?? $this->context->getSemester(),
|
||||
]);
|
||||
|
||||
$payload = $reimb->toArray();
|
||||
$payload['receipt_url'] = $this->fileService->receiptUrl($reimb->receipt_path ?? null);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'reimbursement' => new ReimbursementResource($payload),
|
||||
'users' => ReimbursementRecipientResource::collection($data['users'] ?? []),
|
||||
'receipt_url' => $payload['receipt_url'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendBatchEmail(ReimbursementBatchEmailRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
@@ -290,7 +365,7 @@ class ReimbursementController extends BaseApiController
|
||||
return response()->json(['ok' => true, 'message' => 'Email sent successfully.']);
|
||||
}
|
||||
|
||||
public function export(ReimbursementExportRequest $request): StreamedResponse
|
||||
public function export(ReimbursementExportRequest $request): JsonResponse|StreamedResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$type = $payload['type'] ?? 'processed';
|
||||
@@ -301,6 +376,14 @@ class ReimbursementController extends BaseApiController
|
||||
$csv = $this->exportService->buildProcessedCsv($payload);
|
||||
}
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'filename' => $csv['filename'],
|
||||
'rows' => $csv['rows'],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->streamDownload(function () use ($csv) {
|
||||
$out = fopen('php://output', 'w');
|
||||
fprintf($out, chr(0xEF).chr(0xBB).chr(0xBF));
|
||||
|
||||
@@ -35,6 +35,7 @@ use App\Services\Grading\GradingPlacementService;
|
||||
use App\Services\Grading\GradingRefreshService;
|
||||
use App\Services\Grading\GradingScoreService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use RuntimeException;
|
||||
|
||||
class GradingController extends BaseApiController
|
||||
{
|
||||
@@ -104,6 +105,10 @@ class GradingController extends BaseApiController
|
||||
return $userId;
|
||||
}
|
||||
|
||||
if (! $this->currentUserHasAnyRole(['teacher', 'administrator', 'admin'])) {
|
||||
return response()->json(['ok' => false, 'message' => 'Forbidden.'], 403);
|
||||
}
|
||||
|
||||
$payload = $request->validated();
|
||||
$locked = $this->lockService->toggle(
|
||||
(int) $payload['class_section_id'],
|
||||
@@ -122,6 +127,10 @@ class GradingController extends BaseApiController
|
||||
return $userId;
|
||||
}
|
||||
|
||||
if (! $this->currentUserHasAnyRole(['teacher', 'administrator', 'admin'])) {
|
||||
return response()->json(['ok' => false, 'message' => 'Forbidden.'], 403);
|
||||
}
|
||||
|
||||
$payload = $request->validated();
|
||||
$count = $this->lockService->lockAll(
|
||||
(string) $payload['semester'],
|
||||
@@ -135,11 +144,18 @@ class GradingController extends BaseApiController
|
||||
public function refresh(GradingRefreshRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$this->refreshService->refreshSemesterScores(
|
||||
(int) $payload['class_section_id'],
|
||||
(string) $payload['semester'],
|
||||
(string) $payload['school_year']
|
||||
);
|
||||
try {
|
||||
$this->refreshService->refreshSemesterScores(
|
||||
(int) $payload['class_section_id'],
|
||||
(string) $payload['semester'],
|
||||
(string) $payload['school_year']
|
||||
);
|
||||
} catch (RuntimeException $exception) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => $exception->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
@@ -213,7 +229,11 @@ class GradingController extends BaseApiController
|
||||
|
||||
public function showPlacementBatch(int $batchId): JsonResponse
|
||||
{
|
||||
$data = $this->placementService->getPlacementBatch($batchId);
|
||||
try {
|
||||
$data = $this->placementService->getPlacementBatch($batchId);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], 404);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true] + $data);
|
||||
}
|
||||
@@ -226,12 +246,18 @@ class GradingController extends BaseApiController
|
||||
}
|
||||
|
||||
$payload = $request->validated();
|
||||
$this->placementService->updatePlacementBatch(
|
||||
$batchId,
|
||||
(string) $payload['school_year'],
|
||||
$payload['placement_level'] ?? [],
|
||||
$userId
|
||||
);
|
||||
try {
|
||||
$this->placementService->updatePlacementBatch(
|
||||
$batchId,
|
||||
(string) $payload['school_year'],
|
||||
$payload['placement_level'] ?? [],
|
||||
$userId
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
$status = str_contains(strtolower($e->getMessage()), 'not found') ? 404 : 422;
|
||||
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], $status);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
@@ -255,11 +281,15 @@ class GradingController extends BaseApiController
|
||||
public function belowSixtyEmail(BelowSixtyEmailRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$data = $this->belowSixtyService->emailContext(
|
||||
(int) $payload['student_id'],
|
||||
(string) $payload['school_year'],
|
||||
(string) $payload['semester']
|
||||
);
|
||||
try {
|
||||
$data = $this->belowSixtyService->emailContext(
|
||||
(int) $payload['student_id'],
|
||||
(string) $payload['school_year'],
|
||||
(string) $payload['semester']
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true] + $data);
|
||||
}
|
||||
@@ -267,11 +297,15 @@ class GradingController extends BaseApiController
|
||||
public function sendBelowSixtyEmail(BelowSixtySendRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$data = $this->belowSixtyService->emailContext(
|
||||
(int) $payload['student_id'],
|
||||
(string) $payload['school_year'],
|
||||
(string) $payload['semester']
|
||||
);
|
||||
try {
|
||||
$data = $this->belowSixtyService->emailContext(
|
||||
(int) $payload['student_id'],
|
||||
(string) $payload['school_year'],
|
||||
(string) $payload['semester']
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
$subject = trim((string) ($payload['subject'] ?? ''));
|
||||
if ($subject !== '') {
|
||||
@@ -334,7 +368,11 @@ class GradingController extends BaseApiController
|
||||
}
|
||||
|
||||
$payload = $request->validated();
|
||||
$count = $this->belowSixtyService->generateAllDecisions((string) $payload['school_year'], $userId);
|
||||
try {
|
||||
$count = $this->belowSixtyService->generateAllDecisions((string) $payload['school_year'], $userId);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true, 'saved_count' => $count]);
|
||||
}
|
||||
@@ -385,10 +423,14 @@ class GradingController extends BaseApiController
|
||||
public function belowSixtyDecisionEmail(BelowSixtyDecisionDetailsRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$data = $this->belowSixtyService->decisionEmailContext(
|
||||
(int) $payload['student_id'],
|
||||
(string) $payload['school_year']
|
||||
);
|
||||
try {
|
||||
$data = $this->belowSixtyService->decisionEmailContext(
|
||||
(int) $payload['student_id'],
|
||||
(string) $payload['school_year']
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true] + $data);
|
||||
}
|
||||
@@ -396,12 +438,16 @@ class GradingController extends BaseApiController
|
||||
public function sendBelowSixtyDecisionEmail(BelowSixtyDecisionSendRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$this->belowSixtyService->sendDecisionEmail(
|
||||
(int) $payload['student_id'],
|
||||
(string) $payload['school_year'],
|
||||
isset($payload['subject']) ? (string) $payload['subject'] : null,
|
||||
isset($payload['html']) ? (string) $payload['html'] : null
|
||||
);
|
||||
try {
|
||||
$this->belowSixtyService->sendDecisionEmail(
|
||||
(int) $payload['student_id'],
|
||||
(string) $payload['school_year'],
|
||||
isset($payload['subject']) ? (string) $payload['subject'] : null,
|
||||
isset($payload['html']) ? (string) $payload['html'] : null
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
@@ -414,7 +460,11 @@ class GradingController extends BaseApiController
|
||||
}
|
||||
|
||||
$payload = $request->validated();
|
||||
$this->belowSixtyService->saveMeeting($payload, $userId);
|
||||
try {
|
||||
$this->belowSixtyService->saveMeeting($payload, $userId);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
@@ -428,4 +478,24 @@ class GradingController extends BaseApiController
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
private function currentUserHasAnyRole(array $roles): bool
|
||||
{
|
||||
$user = request()->user() ?? auth()->user();
|
||||
if (! $user || ! method_exists($user, 'roles')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roles = array_map('strtolower', $roles);
|
||||
|
||||
return $user->roles()
|
||||
->where(function ($query) use ($roles) {
|
||||
$query->whereIn('roles.name', $roles)
|
||||
->orWhereIn('roles.slug', $roles);
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ use App\Http\Requests\Inventory\InventoryItemUpdateRequest;
|
||||
use App\Http\Requests\Inventory\InventoryTeacherDistributionRequest;
|
||||
use App\Http\Requests\Inventory\InventoryTeacherDistributionStoreRequest;
|
||||
use App\Http\Resources\Inventory\InventoryItemResource;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Services\Inventory\InventoryItemService;
|
||||
use App\Services\Inventory\InventoryMovementService;
|
||||
use App\Services\Inventory\InventorySummaryService;
|
||||
use App\Services\Inventory\InventoryTeacherDistributionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -23,7 +25,8 @@ class InventoryController extends BaseApiController
|
||||
public function __construct(
|
||||
private InventoryItemService $items,
|
||||
private InventorySummaryService $summary,
|
||||
private InventoryTeacherDistributionService $distribution
|
||||
private InventoryTeacherDistributionService $distribution,
|
||||
private InventoryMovementService $movementService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -185,6 +188,161 @@ class InventoryController extends BaseApiController
|
||||
return $this->success($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get low-stock items (where quantity <= reorder_point).
|
||||
*/
|
||||
public function lowStock(): JsonResponse
|
||||
{
|
||||
$items = InventoryItem::lowStock()
|
||||
->with('category', 'preferredSupplier')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return $this->success([
|
||||
'items' => InventoryItemResource::collection($items),
|
||||
'count' => $items->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reorder suggestions based on low-stock items.
|
||||
*/
|
||||
public function reorderSuggestions(): JsonResponse
|
||||
{
|
||||
$lowStockItems = InventoryItem::lowStock()
|
||||
->with('category', 'preferredSupplier')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$suggestions = $lowStockItems->map(function ($item) {
|
||||
$reorderQty = (int) ($item->reorder_quantity ?: 0);
|
||||
$currentQty = (int) $item->quantity;
|
||||
$reorderPoint = (int) ($item->reorder_point ?: 0);
|
||||
|
||||
// If no reorder quantity is set, suggest enough to bring back above reorder point
|
||||
if ($reorderQty <= 0) {
|
||||
$reorderQty = max(1, $reorderPoint - $currentQty + 5);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'current_quantity' => $currentQty,
|
||||
'reorder_point' => $reorderPoint,
|
||||
'suggested_order_qty' => $reorderQty,
|
||||
'preferred_supplier' => $item->preferredSupplier ? [
|
||||
'id' => $item->preferredSupplier->id,
|
||||
'name' => $item->preferredSupplier->name,
|
||||
] : null,
|
||||
'category' => $item->category ? $item->category->name : null,
|
||||
];
|
||||
});
|
||||
|
||||
return $this->success([
|
||||
'suggestions' => $suggestions,
|
||||
'count' => $suggestions->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reorder request for a low-stock item.
|
||||
*/
|
||||
public function reorderRequest(int $id): JsonResponse
|
||||
{
|
||||
$item = InventoryItem::with('preferredSupplier')->find($id);
|
||||
if (! $item) {
|
||||
return $this->error('Item not found.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$reorderQty = (int) ($item->reorder_quantity ?: 0);
|
||||
if ($reorderQty <= 0) {
|
||||
$reorderQty = max(1, ((int) ($item->reorder_point ?: 0)) - ((int) $item->quantity) + 5);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'item_id' => $item->id,
|
||||
'item_name' => $item->name,
|
||||
'current_quantity' => (int) $item->quantity,
|
||||
'suggested_quantity' => $reorderQty,
|
||||
'preferred_supplier' => $item->preferredSupplier ? [
|
||||
'id' => $item->preferredSupplier->id,
|
||||
'name' => $item->preferredSupplier->name,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* View all items with their stock status.
|
||||
*/
|
||||
public function stockStatus(): JsonResponse
|
||||
{
|
||||
$type = request()->query('type');
|
||||
$items = InventoryItem::query()
|
||||
->with('category', 'preferredSupplier')
|
||||
->when($type, fn ($q) => $q->where('type', $type))
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function ($item) {
|
||||
$qty = (int) $item->quantity;
|
||||
$reorderPoint = (int) ($item->reorder_point ?: 0);
|
||||
$status = 'ok';
|
||||
if ($qty <= 0) {
|
||||
$status = 'out_of_stock';
|
||||
} elseif ($reorderPoint > 0 && $qty <= $reorderPoint) {
|
||||
$status = 'low_stock';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'type' => $item->type,
|
||||
'category' => $item->category ? $item->category->name : null,
|
||||
'quantity' => $qty,
|
||||
'reorder_point' => $reorderPoint,
|
||||
'status' => $status,
|
||||
];
|
||||
});
|
||||
|
||||
$counts = [
|
||||
'ok' => $items->where('status', 'ok')->count(),
|
||||
'low_stock' => $items->where('status', 'low_stock')->count(),
|
||||
'out_of_stock' => $items->where('status', 'out_of_stock')->count(),
|
||||
];
|
||||
|
||||
return $this->success([
|
||||
'items' => $items,
|
||||
'counts' => $counts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory dashboard summary.
|
||||
*/
|
||||
public function dashboard(): JsonResponse
|
||||
{
|
||||
$schoolYear = request()->query('school_year');
|
||||
|
||||
$items = InventoryItem::query()
|
||||
->when($schoolYear, fn ($q) => $q->where('school_year', $schoolYear))
|
||||
->get();
|
||||
|
||||
$totalItems = $items->count();
|
||||
$byType = $items->groupBy('type')->map(fn ($g) => $g->count());
|
||||
$lowStockCount = $items->filter(fn ($i) => $i->reorder_point && $i->quantity <= $i->reorder_point)->count();
|
||||
$outOfStockCount = $items->filter(fn ($i) => $i->quantity <= 0)->count();
|
||||
$needsRepairCount = $items->sum('needs_repair_qty');
|
||||
$missingCount = $items->sum('cannot_find_qty');
|
||||
|
||||
return $this->success([
|
||||
'total_items' => $totalItems,
|
||||
'by_type' => $byType,
|
||||
'low_stock_count' => $lowStockCount,
|
||||
'out_of_stock_count' => $outOfStockCount,
|
||||
'needs_repair_count' => (int) $needsRepairCount,
|
||||
'missing_count' => (int) $missingCount,
|
||||
]);
|
||||
}
|
||||
|
||||
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
|
||||
{
|
||||
$userId = (int) (auth()->id() ?? 0);
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Http\Requests\Inventory\InventoryMovementStoreRequest;
|
||||
use App\Http\Requests\Inventory\InventoryMovementUpdateRequest;
|
||||
use App\Http\Resources\Inventory\InventoryMovementResource;
|
||||
use App\Services\Inventory\InventoryMovementService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
@@ -23,9 +24,65 @@ class InventoryMovementController extends BaseApiController
|
||||
{
|
||||
$rows = $this->movements->list($request->validated());
|
||||
|
||||
return $this->success([
|
||||
return $this->success(array_merge([
|
||||
'movements' => InventoryMovementResource::collection($rows),
|
||||
]);
|
||||
], $this->movementFormPayload()));
|
||||
}
|
||||
|
||||
public function editForm(int $id): JsonResponse
|
||||
{
|
||||
$movement = collect($this->movements->list(['include_voided' => 1]))
|
||||
->first(fn ($row) => (int) ($row['id'] ?? 0) === $id);
|
||||
|
||||
if (! $movement) {
|
||||
return $this->error('Movement not found.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->success(array_merge($this->movementFormPayload(), [
|
||||
'movement' => new InventoryMovementResource($movement),
|
||||
]));
|
||||
}
|
||||
|
||||
private function movementFormPayload(): array
|
||||
{
|
||||
$items = DB::table('inventory_items')
|
||||
->select('id', 'name', 'type', 'quantity')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$teachers = DB::table('users')
|
||||
->select('id', 'firstname', 'lastname', 'email')
|
||||
->orderBy('firstname')
|
||||
->orderBy('lastname')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$students = DB::table('students')
|
||||
->select('id', 'firstname', 'lastname')
|
||||
->orderBy('firstname')
|
||||
->orderBy('lastname')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
$classSections = DB::table('classSection')
|
||||
->select('class_section_id as id', 'class_section_id', 'class_section_name')
|
||||
->orderBy('class_section_name')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'teachers' => $teachers,
|
||||
'students' => $students,
|
||||
'classSections' => $classSections,
|
||||
'movementTypes' => ['initial', 'in', 'out', 'adjust', 'distribution'],
|
||||
'semesters' => ['Fall', 'Spring'],
|
||||
];
|
||||
}
|
||||
|
||||
public function store(InventoryMovementStoreRequest $request): JsonResponse
|
||||
@@ -73,6 +130,57 @@ class InventoryMovementController extends BaseApiController
|
||||
return $this->success($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Void a movement (append-only correction).
|
||||
*/
|
||||
public function void(int $id): JsonResponse
|
||||
{
|
||||
$actor = $this->authenticatedUserIdOrUnauthorized();
|
||||
if ($actor instanceof JsonResponse) {
|
||||
return $actor;
|
||||
}
|
||||
|
||||
$reason = (string) request()->input('reason', '');
|
||||
if (empty(trim($reason))) {
|
||||
return $this->error('Reason is required to void a movement.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$result = $this->movements->voidMovement($id, $reason, $actor);
|
||||
if (! ($result['ok'] ?? false)) {
|
||||
return $this->error($result['message'] ?? 'Failed to void movement.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
return $this->success(['message' => 'Movement voided successfully.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a correction movement.
|
||||
*/
|
||||
public function correct(int $id): JsonResponse
|
||||
{
|
||||
$actor = $this->authenticatedUserIdOrUnauthorized();
|
||||
if ($actor instanceof JsonResponse) {
|
||||
return $actor;
|
||||
}
|
||||
|
||||
$qtyChange = (int) request()->input('qty_change', 0);
|
||||
$reason = (string) request()->input('reason', '');
|
||||
|
||||
if ($qtyChange === 0) {
|
||||
return $this->error('Quantity change must be non-zero.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
if (empty(trim($reason))) {
|
||||
return $this->error('Reason is required.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$result = $this->movements->correctMovement($id, $qtyChange, $reason, $actor);
|
||||
if (! ($result['ok'] ?? false)) {
|
||||
return $this->error($result['message'] ?? 'Failed to correct movement.', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
return $this->success(['message' => 'Correction movement created.']);
|
||||
}
|
||||
|
||||
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
|
||||
{
|
||||
$userId = (int) (auth()->id() ?? 0);
|
||||
|
||||
@@ -37,11 +37,67 @@ class WhatsappController extends BaseApiController
|
||||
|
||||
public function index(WhatsappLinkIndexRequest $request): JsonResponse
|
||||
{
|
||||
$links = $this->linkService->paginate($request->validated());
|
||||
$payload = $request->validated();
|
||||
$schoolYear = $this->context->schoolYear($payload['school_year'] ?? null);
|
||||
$semester = array_key_exists('semester', $payload)
|
||||
? (string) ($payload['semester'] ?? '')
|
||||
: $this->context->semester();
|
||||
|
||||
$payload['school_year'] = $schoolYear;
|
||||
$payload['semester'] = $semester;
|
||||
$payload['per_page'] = max((int) ($payload['per_page'] ?? 200), 200);
|
||||
|
||||
$links = $this->linkService->paginate($payload);
|
||||
$collection = new WhatsappGroupLinkCollection($links);
|
||||
$linkRows = $collection->toArray($request);
|
||||
|
||||
$sectionsRaw = $this->contactService->listParentContactsByClass($schoolYear, $semester);
|
||||
$sections = WhatsappClassSectionContactResource::collection($sectionsRaw)->resolve($request);
|
||||
|
||||
$linksBySection = [];
|
||||
foreach ($linkRows as $link) {
|
||||
$sid = (int) ($link['class_section_id'] ?? 0);
|
||||
if ($sid > 0) {
|
||||
$linksBySection[(string) $sid] = $link;
|
||||
}
|
||||
}
|
||||
|
||||
$parents = [];
|
||||
foreach ($sections as $section) {
|
||||
foreach (($section['parents'] ?? []) as $parent) {
|
||||
$primaryId = (int) ($parent['primary_id'] ?? 0);
|
||||
if ($primaryId > 0 && ! isset($parents['primary:'.$primaryId])) {
|
||||
[$lastname, $firstname] = $this->splitDisplayName((string) ($parent['primary_name'] ?? ''));
|
||||
$parents['primary:'.$primaryId] = [
|
||||
'id' => $primaryId,
|
||||
'firstname' => $firstname,
|
||||
'lastname' => $lastname,
|
||||
'email' => (string) ($parent['primary_email'] ?? ''),
|
||||
'source' => 'primary',
|
||||
];
|
||||
}
|
||||
|
||||
$secondId = (int) ($parent['second_id'] ?? 0);
|
||||
if ($secondId > 0 && ! isset($parents['second:'.$secondId])) {
|
||||
[$lastname, $firstname] = $this->splitDisplayName((string) ($parent['second_name'] ?? ''));
|
||||
$parents['second:'.$secondId] = [
|
||||
'id' => $secondId,
|
||||
'firstname' => $firstname,
|
||||
'lastname' => $lastname,
|
||||
'email' => (string) ($parent['second_email'] ?? ''),
|
||||
'source' => 'parents',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'links' => $collection->toArray($request),
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'links' => $linkRows,
|
||||
'linksBySection' => $linksBySection,
|
||||
'sections' => $sections,
|
||||
'parents' => array_values($parents),
|
||||
'meta' => $collection->with($request)['meta'],
|
||||
]);
|
||||
}
|
||||
@@ -93,6 +149,8 @@ class WhatsappController extends BaseApiController
|
||||
$contacts = $this->contactService->listParentContacts($schoolYear, $semester);
|
||||
|
||||
return $this->success([
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'contacts' => WhatsappParentContactResource::collection($contacts),
|
||||
]);
|
||||
}
|
||||
@@ -160,6 +218,21 @@ class WhatsappController extends BaseApiController
|
||||
], 'Membership saved.');
|
||||
}
|
||||
|
||||
private function splitDisplayName(string $name): array
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return ['', ''];
|
||||
}
|
||||
|
||||
if (str_contains($name, ',')) {
|
||||
[$last, $first] = array_pad(array_map('trim', explode(',', $name, 2)), 2, '');
|
||||
return [$last, $first];
|
||||
}
|
||||
|
||||
return [$name, ''];
|
||||
}
|
||||
|
||||
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
|
||||
{
|
||||
$userId = (int) (auth()->id() ?? 0);
|
||||
|
||||
@@ -94,8 +94,7 @@ class AuthorizedUserInviteController extends Controller
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
'message' => 'Invalid credential setup request.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
@@ -112,7 +111,7 @@ class AuthorizedUserInviteController extends Controller
|
||||
return response()->json(['message' => $e->getMessage()], 400);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Password has been successfully set.']);
|
||||
return response()->json(['message' => 'Credential has been successfully set.']);
|
||||
}
|
||||
|
||||
private function isTrustedRedirectUrl(string $url): bool
|
||||
|
||||
@@ -33,7 +33,10 @@ class AuthorizedUsersController extends BaseApiController
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return $this->success($rows);
|
||||
return $this->success([
|
||||
'data' => $rows,
|
||||
'users' => $rows,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
|
||||
@@ -13,17 +13,23 @@ use App\Http\Requests\Parents\ParentProfileUpdateRequest;
|
||||
use App\Http\Requests\Parents\ParentRegistrationRequest;
|
||||
use App\Http\Requests\Parents\ParentStudentUpdateRequest;
|
||||
use App\Http\Resources\Invoices\InvoiceResource;
|
||||
use App\Http\Resources\ClassProgress\ClassProgressShowResource;
|
||||
use App\Http\Resources\Parents\ParentAttendanceResource;
|
||||
use App\Http\Resources\Parents\ParentEmergencyContactResource;
|
||||
use App\Http\Resources\Parents\ParentStudentResource;
|
||||
use App\Models\ClassProgressReport;
|
||||
use App\Services\Parents\ParentAttendanceService;
|
||||
use App\Services\Parents\ParentEmergencyContactService;
|
||||
use App\Services\Parents\ParentEnrollmentService;
|
||||
use App\Services\Parents\ParentEventParticipationService;
|
||||
use App\Services\Parents\ParentProgressQueryService;
|
||||
use App\Services\Parents\ParentInvoiceService;
|
||||
use App\Services\Parents\ParentProfileService;
|
||||
use App\Services\Parents\ParentRegistrationService;
|
||||
use App\Services\SchoolYears\SchoolYearContextService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ParentController extends BaseApiController
|
||||
{
|
||||
@@ -34,7 +40,9 @@ class ParentController extends BaseApiController
|
||||
private ParentRegistrationService $registrationService,
|
||||
private ParentEmergencyContactService $emergencyContactService,
|
||||
private ParentProfileService $profileService,
|
||||
private ParentEventParticipationService $eventService
|
||||
private ParentEventParticipationService $eventService,
|
||||
private ParentProgressQueryService $progressService,
|
||||
private SchoolYearContextService $schoolYears
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -72,6 +80,21 @@ class ParentController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function statement(ParentInvoiceRequest $request): JsonResponse
|
||||
{
|
||||
$guard = $this->parentIdOrUnauthorized();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$schoolYear = $request->validated()['school_year'] ?? null;
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->invoiceService->statement($guard, $schoolYear),
|
||||
]);
|
||||
}
|
||||
|
||||
public function enrollments(ParentEnrollmentRequest $request): JsonResponse
|
||||
{
|
||||
$guard = $this->parentIdOrUnauthorized();
|
||||
@@ -92,6 +115,62 @@ class ParentController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function progress(Request $request): JsonResponse
|
||||
{
|
||||
$guard = $this->parentIdOrUnauthorized();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$schoolYear = $this->schoolYears->currentSchoolYear($request->query('school_year'));
|
||||
$semester = $request->query->has('semester')
|
||||
? $this->schoolYears->currentSemester($request->query('semester'))
|
||||
: null;
|
||||
$sectionIds = $this->progressService->parentSectionIds($guard);
|
||||
$reports = $this->progressService->reportsForSections($sectionIds, $schoolYear, $semester);
|
||||
$groups = array_values($this->progressService->groupReportsByWeek($reports));
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => [
|
||||
'items' => $groups,
|
||||
'groups' => $groups,
|
||||
'students' => $this->progressService->parentStudents($guard),
|
||||
'meta' => $this->schoolYears->options($schoolYear, $semester),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function progressDetail(Request $request, ClassProgressReport $class_progress): JsonResponse
|
||||
{
|
||||
$guard = $this->parentIdOrUnauthorized();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
if (Gate::denies('view', $class_progress)) {
|
||||
return response()->json(['ok' => false, 'status' => false, 'message' => 'Forbidden.'], 403);
|
||||
}
|
||||
|
||||
$weekStart = $class_progress->week_start?->format('Y-m-d') ?? '';
|
||||
$weeklyReports = $this->progressService->weeklyReportsForSectionWeek(
|
||||
(int) $class_progress->class_section_id,
|
||||
$weekStart
|
||||
);
|
||||
|
||||
$data = (new ClassProgressShowResource([
|
||||
'report' => $class_progress,
|
||||
'weekly_reports' => $weeklyReports,
|
||||
'status_options' => config('progress.status_options', []),
|
||||
]))->toArray($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'status' => true,
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateEnrollments(ParentEnrollmentActionRequest $request): JsonResponse
|
||||
{
|
||||
$guard = $this->parentIdOrUnauthorized();
|
||||
@@ -110,6 +189,10 @@ class ParentController extends BaseApiController
|
||||
'ok' => true,
|
||||
'enrolled' => $result['enrolled'],
|
||||
'withdrawn' => $result['withdrawn'],
|
||||
'data' => [
|
||||
'enrolled' => $result['enrolled'],
|
||||
'withdrawn' => $result['withdrawn'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -142,11 +225,15 @@ class ParentController extends BaseApiController
|
||||
}
|
||||
|
||||
$payload = $request->validated();
|
||||
$result = $this->registrationService->register(
|
||||
$guard,
|
||||
$payload['students'] ?? [],
|
||||
$payload['emergency_contacts'] ?? []
|
||||
);
|
||||
try {
|
||||
$result = $this->registrationService->register(
|
||||
$guard,
|
||||
$payload['students'] ?? [],
|
||||
$payload['emergency_contacts'] ?? []
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
$createdCount = count($result['created_student_ids']);
|
||||
$skipped = $result['skipped'] ?? [];
|
||||
@@ -177,7 +264,11 @@ class ParentController extends BaseApiController
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$this->registrationService->deleteStudent($guard, $studentId);
|
||||
try {
|
||||
$this->registrationService->deleteStudent($guard, $studentId);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ use App\Services\Files\ExamDraftDownloadNameService;
|
||||
use App\Services\Files\FileServeService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class FilesController extends BaseApiController
|
||||
{
|
||||
@@ -71,6 +73,7 @@ class FilesController extends BaseApiController
|
||||
|
||||
public function examDraftFinal(FileNameRequest $request, string $name): Response|JsonResponse
|
||||
{
|
||||
$name = $this->resolveFinalExamFileName($name);
|
||||
$downloadName = $this->draftNameService->build($name, 'finals');
|
||||
|
||||
return $this->serveFile(
|
||||
@@ -102,4 +105,37 @@ class FilesController extends BaseApiController
|
||||
|
||||
return $this->fileService->serveInline($baseDir, $name, $allowedExtensions, $request, $downloadName, $nosniff);
|
||||
}
|
||||
|
||||
private function resolveFinalExamFileName(string $name): string
|
||||
{
|
||||
if (! ctype_digit($name) || ! Schema::hasTable('exam_drafts')) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
$query = DB::table('exam_drafts')->where('id', (int) $name);
|
||||
if (Schema::hasColumn('exam_drafts', 'final_pdf_file')) {
|
||||
$query->select('final_file', 'final_pdf_file');
|
||||
} else {
|
||||
$query->select('final_file');
|
||||
}
|
||||
|
||||
$row = $query->first();
|
||||
if (! $row) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
$pdfFile = trim((string) ($row->final_pdf_file ?? ''));
|
||||
if ($this->looksLikeExamFileName($pdfFile)) {
|
||||
return $pdfFile;
|
||||
}
|
||||
|
||||
$finalFile = trim((string) ($row->final_file ?? ''));
|
||||
|
||||
return $this->looksLikeExamFileName($finalFile) ? $finalFile : $name;
|
||||
}
|
||||
|
||||
private function looksLikeExamFileName(string $name): bool
|
||||
{
|
||||
return preg_match('/\.(doc|docx|pdf)\z/i', $name) === 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ use App\Http\Resources\Reports\ReportCards\ReportCardClassSectionResource;
|
||||
use App\Http\Resources\Reports\ReportCards\ReportCardCompletenessStudentResource;
|
||||
use App\Http\Resources\Reports\ReportCards\ReportCardStudentMetaResource;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\User;
|
||||
use App\Services\Parents\ParentReportCardService;
|
||||
use App\Services\Reports\ReportCards\ReportCardService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -20,8 +22,10 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ReportCardsController extends BaseApiController
|
||||
{
|
||||
public function __construct(private ReportCardService $service)
|
||||
{
|
||||
public function __construct(
|
||||
private ReportCardService $service,
|
||||
private ParentReportCardService $parentReportCards,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
@@ -31,7 +35,8 @@ class ReportCardsController extends BaseApiController
|
||||
|
||||
$result = $this->service->meta(
|
||||
$payload['school_year'] ?? null,
|
||||
$payload['semester'] ?? null
|
||||
$payload['semester'] ?? null,
|
||||
$this->parentScopeId($request)
|
||||
);
|
||||
|
||||
return $this->success([
|
||||
@@ -88,6 +93,10 @@ class ReportCardsController extends BaseApiController
|
||||
|
||||
$validated = $validator->validated();
|
||||
$studentId = (int) ($validated['student_id'] ?? 0);
|
||||
if (! $this->parentCanAccessStudent($request, $studentId)) {
|
||||
return $this->error('Forbidden.', Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$schoolYear = $this->resolveSchoolYear($validated['school_year'] ?? null);
|
||||
$semester = $this->resolveSemester($validated['semester'] ?? null);
|
||||
|
||||
@@ -98,6 +107,10 @@ class ReportCardsController extends BaseApiController
|
||||
|
||||
public function studentReport(ReportCardPdfRequest $request, int $studentId)
|
||||
{
|
||||
if (! $this->parentCanAccessStudent($request, $studentId)) {
|
||||
return $this->error('Forbidden.', Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->service->generateSingleReport($studentId, $request->validated());
|
||||
} catch (\Throwable $e) {
|
||||
@@ -122,6 +135,10 @@ class ReportCardsController extends BaseApiController
|
||||
|
||||
public function classReport(ReportCardPdfRequest $request, int $classSectionId)
|
||||
{
|
||||
if ($this->parentScopeId($request) !== null) {
|
||||
return $this->error('Forbidden.', Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->service->generateClassReport($classSectionId, $request->validated());
|
||||
} catch (\Throwable $e) {
|
||||
@@ -163,4 +180,45 @@ class ReportCardsController extends BaseApiController
|
||||
|
||||
return trim((string) (Configuration::getConfig('semester') ?? ''));
|
||||
}
|
||||
|
||||
private function parentCanAccessStudent(Request $request, int $studentId): bool
|
||||
{
|
||||
$parentId = $this->parentScopeId($request);
|
||||
if ($parentId === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->parentReportCards->studentBelongsToPrimaryParent($studentId, $parentId);
|
||||
}
|
||||
|
||||
private function parentScopeId(Request $request): ?int
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = $request->user();
|
||||
if (! $this->isParentFamilyUser($user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parentId = $this->parentReportCards->resolvePrimaryParentUserId($user);
|
||||
|
||||
return $parentId !== null && $parentId > 0 ? $parentId : null;
|
||||
}
|
||||
|
||||
private function isParentFamilyUser(?User $user): bool
|
||||
{
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roles = $user->roles()
|
||||
->pluck('roles.name')
|
||||
->map(fn ($name) => strtolower((string) $name))
|
||||
->toArray();
|
||||
|
||||
if (in_array('parent', $roles, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array(strtolower(trim((string) ($user->user_type ?? ''))), ['secondary', 'tertiary'], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,37 @@ class StickersController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function preview(StickerFormDataRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$schoolYear = $this->queryService->resolveSchoolYear($payload['school_year'] ?? null);
|
||||
$classSectionId = (int) ($payload['class_section_id'] ?? 0);
|
||||
|
||||
$students = $classSectionId > 0
|
||||
? $this->queryService->listStudentsByClass($classSectionId, $schoolYear)
|
||||
: $this->queryService->listStudentsForYear($schoolYear);
|
||||
|
||||
$rows = [];
|
||||
foreach ($students as $student) {
|
||||
$id = (int) ($student['id'] ?? $student['student_id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'student_id' => $id,
|
||||
'primary_count' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'students' => $rows,
|
||||
'totals' => [
|
||||
'students' => count($rows),
|
||||
'stickers' => count($rows),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function print(StickerPrintRequest $request)
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Requests\SchoolYears\PreviewCloseSchoolYearRequest;
|
||||
use App\Http\Requests\SchoolYears\SaveSchoolYearRequest;
|
||||
use App\Services\SchoolYears\SchoolYearClosureService;
|
||||
use App\Services\SchoolYears\SchoolYearContextService;
|
||||
use App\Services\SchoolYears\SelectedSchoolYearContextService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -17,7 +18,8 @@ class SchoolYearController extends BaseApiController
|
||||
{
|
||||
public function __construct(
|
||||
private SchoolYearClosureService $service,
|
||||
private SchoolYearContextService $context
|
||||
private SchoolYearContextService $context,
|
||||
private SelectedSchoolYearContextService $selectedSchoolYear
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -71,11 +73,28 @@ class SchoolYearController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateSelected(SaveSchoolYearRequest $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->updateYear($selected->id, $request->validated(), $this->getCurrentUserId()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function summary(int $schoolYear): JsonResponse
|
||||
{
|
||||
return response()->json(['ok' => true, 'data' => $this->service->summary($schoolYear)]);
|
||||
}
|
||||
|
||||
public function summarySelected(Request $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
return response()->json(['ok' => true, 'data' => $this->service->summaryByYearName($selected->name)]);
|
||||
}
|
||||
|
||||
public function validateClose(PreviewCloseSchoolYearRequest $request, int $schoolYear): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
@@ -84,6 +103,16 @@ class SchoolYearController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function validateCloseSelected(PreviewCloseSchoolYearRequest $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'validation' => $this->service->validateCloseByYearName($selected->name, $request->validated()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function previewClose(PreviewCloseSchoolYearRequest $request, int $schoolYear): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
@@ -92,6 +121,16 @@ class SchoolYearController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function previewCloseSelected(PreviewCloseSchoolYearRequest $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->previewCloseByYearName($selected->name, $request->validated()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function close(CloseSchoolYearRequest $request, int $schoolYear): JsonResponse
|
||||
{
|
||||
try {
|
||||
@@ -108,6 +147,24 @@ class SchoolYearController extends BaseApiController
|
||||
return response()->json(['ok' => true, 'data' => $result]);
|
||||
}
|
||||
|
||||
public function closeSelected(CloseSchoolYearRequest $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
try {
|
||||
$result = $this->service->closeByYearName($selected->name, $request->validated(), $this->getCurrentUserId());
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], Response::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true, 'data' => $result]);
|
||||
}
|
||||
|
||||
public function reopen(Request $request, int $schoolYear): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
@@ -116,6 +173,16 @@ class SchoolYearController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function reopenSelected(Request $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->reopenByYearName($selected->name, $this->getCurrentUserId()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function parentBalances(Request $request, int $schoolYear): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
@@ -124,6 +191,16 @@ class SchoolYearController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function parentBalancesSelected(Request $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->parentBalancesByYearName($selected->name, $request->query('to_school_year')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function promotionPreview(Request $request, int $schoolYear): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
@@ -131,4 +208,50 @@ class SchoolYearController extends BaseApiController
|
||||
'data' => $this->service->promotionPreview($schoolYear, $request->query('to_school_year')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function promotionPreviewSelected(Request $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->promotionPreviewByYearName($selected->name, $request->query('to_school_year')),
|
||||
]);
|
||||
}
|
||||
|
||||
public function closingReport(int $schoolYear): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->closingReport($schoolYear),
|
||||
]);
|
||||
}
|
||||
|
||||
public function closingReportSelected(Request $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->closingReportByYearName($selected->name),
|
||||
]);
|
||||
}
|
||||
|
||||
public function archive(Request $request, int $schoolYear): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->archive($schoolYear, $this->getCurrentUserId()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function archiveSelected(Request $request): JsonResponse
|
||||
{
|
||||
$selected = $this->selectedSchoolYear->fromRequest($request);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => $this->service->archiveByYearName($selected->name, $this->getCurrentUserId()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ class HomeworkController extends BaseApiController
|
||||
$userId
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true, 'homework_index' => $nextIndex]);
|
||||
return response()->json(['ok' => true, 'homework_index' => $nextIndex, 'data' => ['index' => $nextIndex]]);
|
||||
}
|
||||
|
||||
public function bySchoolYear(Request $request): JsonResponse
|
||||
|
||||
@@ -118,7 +118,7 @@ class ProjectController extends BaseApiController
|
||||
$userId
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true, 'project_index' => $nextIndex]);
|
||||
return response()->json(['ok' => true, 'project_index' => $nextIndex, 'data' => ['index' => $nextIndex]]);
|
||||
}
|
||||
|
||||
public function bySchoolYear(Request $request): JsonResponse
|
||||
|
||||
@@ -118,7 +118,7 @@ class QuizController extends BaseApiController
|
||||
$userId
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true, 'quiz_index' => $nextIndex]);
|
||||
return response()->json(['ok' => true, 'quiz_index' => $nextIndex, 'data' => ['index' => $nextIndex]]);
|
||||
}
|
||||
|
||||
public function bySchoolYear(Request $request): JsonResponse
|
||||
|
||||
@@ -79,14 +79,18 @@ class ScoreCommentController extends BaseApiController
|
||||
}
|
||||
|
||||
$payload = $request->validated();
|
||||
$result = $this->service->save(
|
||||
(int) $payload['class_section_id'],
|
||||
(string) $payload['semester'],
|
||||
(string) $payload['school_year'],
|
||||
$payload['comments'],
|
||||
$payload['missing_ok'] ?? [],
|
||||
$userId
|
||||
);
|
||||
try {
|
||||
$result = $this->service->save(
|
||||
(int) $payload['class_section_id'],
|
||||
(string) $payload['semester'],
|
||||
(string) $payload['school_year'],
|
||||
$payload['comments'],
|
||||
$payload['missing_ok'] ?? [],
|
||||
$userId
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], 409);
|
||||
}
|
||||
|
||||
if (! empty($result['errors'])) {
|
||||
return response()->json(['ok' => false, 'errors' => $result['errors']], 422);
|
||||
@@ -103,14 +107,18 @@ class ScoreCommentController extends BaseApiController
|
||||
}
|
||||
|
||||
$payload = $request->validated();
|
||||
$result = $this->service->update(
|
||||
(int) $payload['class_section_id'],
|
||||
(string) $payload['semester'],
|
||||
(string) $payload['school_year'],
|
||||
$payload['comments'] ?? [],
|
||||
$payload['reviews'] ?? [],
|
||||
$userId
|
||||
);
|
||||
try {
|
||||
$result = $this->service->update(
|
||||
(int) $payload['class_section_id'],
|
||||
(string) $payload['semester'],
|
||||
(string) $payload['school_year'],
|
||||
$payload['comments'] ?? [],
|
||||
$payload['reviews'] ?? [],
|
||||
$userId
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], 409);
|
||||
}
|
||||
|
||||
if (! empty($result['errors'])) {
|
||||
return response()->json(['ok' => false, 'errors' => $result['errors']], 422);
|
||||
|
||||
@@ -55,6 +55,10 @@ class ScoreController extends BaseApiController
|
||||
return $userId;
|
||||
}
|
||||
|
||||
if (! $this->currentUserHasAnyRole(['teacher', 'administrator', 'admin'])) {
|
||||
return response()->json(['ok' => false, 'message' => 'Forbidden.'], 403);
|
||||
}
|
||||
|
||||
$payload = $request->validated();
|
||||
$this->service->submitScoresLock(
|
||||
(int) $payload['class_section_id'],
|
||||
@@ -90,4 +94,24 @@ class ScoreController extends BaseApiController
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
private function currentUserHasAnyRole(array $roles): bool
|
||||
{
|
||||
$user = request()->user() ?? auth()->user();
|
||||
if (! $user || ! method_exists($user, 'roles')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$roles = array_map('strtolower', $roles);
|
||||
|
||||
return $user->roles()
|
||||
->where(function ($query) use ($roles) {
|
||||
$query->whereIn('roles.name', $roles)
|
||||
->orWhereIn('roles.slug', $roles);
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,18 +113,18 @@ class PreferencesController extends BaseApiController
|
||||
|
||||
return $this->success([
|
||||
'preferences' => new PreferencesResource($payload['preferences']),
|
||||
], 'Preferences saved.', $saved->wasRecentlyCreated ? Response::HTTP_CREATED : Response::HTTP_OK);
|
||||
], 'Preferences saved.');
|
||||
}
|
||||
|
||||
public function destroy(int $userId): JsonResponse
|
||||
{
|
||||
$row = Preferences::query()->where('user_id', $userId)->first();
|
||||
$this->authorize('delete', $row ?? new Preferences(['user_id' => $userId]));
|
||||
|
||||
if (! $row) {
|
||||
return $this->error('Preferences not found.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$this->authorize('delete', $row);
|
||||
|
||||
try {
|
||||
$deleted = $this->commandService->delete($row);
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -43,7 +43,12 @@ class SchoolCalendarController extends BaseApiController
|
||||
|
||||
public function index(SchoolCalendarIndexRequest $request): JsonResponse
|
||||
{
|
||||
return $this->respondWithEvents($request->validated());
|
||||
$filters = $request->validated();
|
||||
if (! $this->canListEventsForAudience($filters['audience'] ?? null)) {
|
||||
return $this->error('Forbidden.', Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
return $this->respondWithEvents($filters);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,6 +57,10 @@ class SchoolCalendarController extends BaseApiController
|
||||
*/
|
||||
public function teacherCalendarLegacy(SchoolCalendarIndexRequest $request): JsonResponse
|
||||
{
|
||||
if (! $this->canListEventsForAudience('teacher')) {
|
||||
return $this->error('Forbidden.', Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
return $this->respondWithEvents([
|
||||
...$request->validated(),
|
||||
'audience' => 'teacher',
|
||||
@@ -187,4 +196,56 @@ class SchoolCalendarController extends BaseApiController
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
private function canListEventsForAudience(?string $audience): bool
|
||||
{
|
||||
if ($this->hasAnyRole(['administrator', 'admin', 'principal', 'vice_principal', 'vice-principal'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$audience = strtolower(trim((string) $audience));
|
||||
if ($audience === 'parent') {
|
||||
return $this->hasAnyRole(['parent']) || $this->hasUserType(['secondary', 'tertiary']);
|
||||
}
|
||||
|
||||
if ($audience === 'teacher') {
|
||||
return $this->hasAnyRole(['teacher']);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
private function hasAnyRole(array $roles): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$actual = $user->roles()
|
||||
->pluck('roles.name')
|
||||
->map(fn ($name) => strtolower((string) $name))
|
||||
->toArray();
|
||||
|
||||
foreach ($roles as $role) {
|
||||
if (in_array(strtolower($role), $actual, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $types
|
||||
*/
|
||||
private function hasUserType(array $types): bool
|
||||
{
|
||||
$type = strtolower(trim((string) (auth()->user()?->user_type ?? '')));
|
||||
|
||||
return in_array($type, array_map('strtolower', $types), true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Requests\Staff\StaffStoreRequest;
|
||||
use App\Http\Requests\Staff\StaffUpdateRequest;
|
||||
use App\Http\Resources\Staff\StaffCollection;
|
||||
use App\Http\Resources\Staff\StaffResource;
|
||||
use App\Models\Role;
|
||||
use App\Models\Staff;
|
||||
use App\Models\User;
|
||||
use App\Services\Staff\StaffCommandService;
|
||||
@@ -45,6 +46,24 @@ class StaffController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function formOptions(): JsonResponse
|
||||
{
|
||||
$roles = Role::query()
|
||||
->select('id', 'name')
|
||||
->orderBy('priority')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (Role $role) => [
|
||||
'id' => (int) $role->id,
|
||||
'name' => (string) $role->name,
|
||||
])
|
||||
->all();
|
||||
|
||||
return $this->success([
|
||||
'roles' => $roles,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$staff = $this->queryService->find($id);
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Http\Requests\Teachers\TeacherSwitchClassRequest;
|
||||
use App\Http\Resources\Teachers\TeacherAbsenceResource;
|
||||
use App\Http\Resources\Teachers\TeacherClassResource;
|
||||
use App\Http\Resources\Teachers\TeacherStudentResource;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\User;
|
||||
use App\Services\Teachers\TeacherAbsenceService;
|
||||
use App\Services\Teachers\TeacherDashboardService;
|
||||
@@ -46,6 +47,8 @@ class TeacherController extends BaseApiController
|
||||
$studentsBySection[(string) $sectionId] = TeacherStudentResource::collection($students);
|
||||
}
|
||||
|
||||
$this->storeActiveClassInSession($data);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'teacher' => $teacher,
|
||||
@@ -58,6 +61,16 @@ class TeacherController extends BaseApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function selectSemester(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'school_year' => Configuration::getConfig('school_year'),
|
||||
'semester' => Configuration::getConfig('semester'),
|
||||
'semester_options' => ['Fall', 'Spring'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function switchClass(TeacherSwitchClassRequest $request): JsonResponse
|
||||
{
|
||||
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||
@@ -80,6 +93,8 @@ class TeacherController extends BaseApiController
|
||||
], 422);
|
||||
}
|
||||
|
||||
$this->storeActiveClassInSession($data);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'active_class_section_id' => $requested,
|
||||
@@ -151,4 +166,18 @@ class TeacherController extends BaseApiController
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function storeActiveClassInSession(array $data): void
|
||||
{
|
||||
$classSectionId = (int) ($data['active_class_section_id'] ?? 0);
|
||||
if ($classSectionId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
session()->put('class_section_id', $classSectionId);
|
||||
session()->put('class_section_name', $data['class_section_name'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Models\Stats;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class StatsController extends BaseApiController
|
||||
@@ -11,6 +12,10 @@ class StatsController extends BaseApiController
|
||||
public function index()
|
||||
{
|
||||
try {
|
||||
if (! Schema::hasTable('stats')) {
|
||||
return $this->success([], 'Statistics retrieved successfully');
|
||||
}
|
||||
|
||||
$stats = Stats::query()->get()->toArray();
|
||||
|
||||
return $this->success($stats, 'Statistics retrieved successfully');
|
||||
|
||||
@@ -192,17 +192,17 @@ class StudentController extends BaseApiController
|
||||
'firstname' => ['required', 'string', 'max:150'],
|
||||
'lastname' => ['required', 'string', 'max:150'],
|
||||
'dob' => ['nullable', 'date'],
|
||||
'age' => ['nullable', 'integer', 'min:0'],
|
||||
'gender' => ['nullable', 'string', 'max:20'],
|
||||
'age' => ['required', 'integer', 'min:0'],
|
||||
'gender' => ['required', 'string', 'max:20'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
'registration_grade' => ['nullable', 'string', 'max:50'],
|
||||
'is_new' => ['nullable', 'boolean'],
|
||||
'photo_consent' => ['nullable', 'boolean'],
|
||||
'parent_id' => ['nullable', 'integer', 'min:1'],
|
||||
'photo_consent' => ['required', 'boolean'],
|
||||
'parent_id' => ['required', 'integer', 'min:1'],
|
||||
'registration_date' => ['nullable', 'date'],
|
||||
'tuition_paid' => ['nullable', 'boolean'],
|
||||
'semester' => ['nullable', 'string', 'max:50'],
|
||||
'year_of_registration' => ['nullable', 'integer', 'min:1900'],
|
||||
'year_of_registration' => ['required', 'integer', 'min:1900'],
|
||||
'school_year' => ['required', 'string', 'max:50'],
|
||||
'medical_conditions' => ['nullable', 'string'],
|
||||
'allergies' => ['nullable', 'string'],
|
||||
@@ -229,7 +229,26 @@ class StudentController extends BaseApiController
|
||||
}
|
||||
|
||||
$payload = $validator->validated();
|
||||
$student = Student::query()->create($payload);
|
||||
$studentPayload = collect($payload)->only([
|
||||
'school_id',
|
||||
'firstname',
|
||||
'lastname',
|
||||
'dob',
|
||||
'age',
|
||||
'gender',
|
||||
'is_active',
|
||||
'registration_grade',
|
||||
'is_new',
|
||||
'photo_consent',
|
||||
'parent_id',
|
||||
'registration_date',
|
||||
'tuition_paid',
|
||||
'semester',
|
||||
'year_of_registration',
|
||||
'school_year',
|
||||
])->all();
|
||||
|
||||
$student = Student::query()->create($studentPayload);
|
||||
|
||||
if (empty($student->school_id)) {
|
||||
$student->school_id = $this->generateSchoolId($student->id);
|
||||
@@ -385,7 +404,18 @@ class StudentController extends BaseApiController
|
||||
$schoolYear = $validator->validated()['school_year'] ?? null;
|
||||
|
||||
$rows = StudentClass::query()
|
||||
->select('student_class.*', 'cs.class_section_name')
|
||||
->select(
|
||||
'student_class.id',
|
||||
'student_class.student_id',
|
||||
'student_class.class_section_id',
|
||||
'student_class.is_event_only',
|
||||
'student_class.semester',
|
||||
'student_class.school_year',
|
||||
'student_class.description',
|
||||
'student_class.created_at',
|
||||
'student_class.updated_at',
|
||||
'cs.class_section_name'
|
||||
)
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'student_class.class_section_id')
|
||||
->where('student_class.student_id', $studentId)
|
||||
->when($schoolYear !== null && $schoolYear !== '', fn ($q) => $q->where('student_class.school_year', $schoolYear))
|
||||
|
||||
@@ -79,7 +79,15 @@ class NavBuilderController extends BaseApiController
|
||||
$this->authorize('manage', NavItem::class);
|
||||
|
||||
try {
|
||||
$this->builderService->reorder($request->validated()['orders']);
|
||||
$payload = $request->validated();
|
||||
$orders = $payload['orders'] ?? [];
|
||||
if ($orders === [] && isset($payload['items'])) {
|
||||
foreach ((array) $payload['items'] as $item) {
|
||||
$orders[(int) $item['id']] = (int) $item['sort_order'];
|
||||
}
|
||||
}
|
||||
|
||||
$this->builderService->reorder($orders);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Nav reorder failed: '.$e->getMessage());
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\System;
|
||||
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||
use App\Models\Stats;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class StatsController extends BaseApiController
|
||||
@@ -12,6 +13,10 @@ class StatsController extends BaseApiController
|
||||
public function index()
|
||||
{
|
||||
try {
|
||||
if (! Schema::hasTable('stats')) {
|
||||
return $this->success([], 'Statistics retrieved successfully');
|
||||
}
|
||||
|
||||
$stats = Stats::query()->get()->toArray();
|
||||
|
||||
return $this->success($stats, 'Statistics retrieved successfully');
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Settings\SchoolCalendar\SchoolCalendarContextService;
|
||||
use App\Services\Settings\SchoolCalendar\SchoolCalendarQueryService;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AdminCalendarPageController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private SchoolCalendarContextService $contextService,
|
||||
private SchoolCalendarQueryService $queryService,
|
||||
) {}
|
||||
|
||||
public function show(Request $request): View
|
||||
{
|
||||
$schoolYear = trim((string) $request->query('school_year', ''));
|
||||
if ($schoolYear === '') {
|
||||
$schoolYear = $this->contextService->defaultSchoolYear();
|
||||
}
|
||||
|
||||
$semester = trim((string) $request->query('semester', ''));
|
||||
$filters = ['school_year' => $schoolYear];
|
||||
if ($semester !== '') {
|
||||
$filters['semester'] = $semester;
|
||||
}
|
||||
|
||||
$events = $this->queryService->listEvents($filters);
|
||||
|
||||
return view('admin.calendar', [
|
||||
'events' => $events,
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'defaultSemester' => $semester !== '' ? $semester : $this->contextService->defaultSemester(),
|
||||
'schoolYears' => $this->schoolYearOptions($schoolYear),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function schoolYearOptions(string $selectedSchoolYear): array
|
||||
{
|
||||
$currentYear = (int) date('Y') + 1;
|
||||
$years = [];
|
||||
for ($year = $currentYear; $year >= 2023; $year--) {
|
||||
$years[] = ($year - 1).'-'.$year;
|
||||
}
|
||||
|
||||
if ($selectedSchoolYear !== '' && ! in_array($selectedSchoolYear, $years, true)) {
|
||||
array_unshift($years, $selectedSchoolYear);
|
||||
}
|
||||
|
||||
return array_values(array_unique($years));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\AccountAvailability;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -38,6 +39,10 @@ class ApiDocsAuth
|
||||
return $this->deny('Unauthorized access to documentation.', 401);
|
||||
}
|
||||
|
||||
if ($response = $this->denyUnavailableAccount($user)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (! $this->userIsAdmin((int) $user->id)) {
|
||||
return $this->deny('Admin privileges required.', 403);
|
||||
}
|
||||
@@ -55,6 +60,15 @@ class ApiDocsAuth
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function denyUnavailableAccount(object $user): ?Response
|
||||
{
|
||||
if (AccountAvailability::isUnavailable($user)) {
|
||||
return response()->json(['message' => 'Account unavailable.'], 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function deny(string $message, int $status): Response
|
||||
{
|
||||
return response()->json([
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\AccountAvailability;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -20,10 +21,24 @@ class ApiJwtAuth
|
||||
}
|
||||
|
||||
Auth::setUser($user);
|
||||
|
||||
if ($response = $this->denyUnavailableAccount($user)) {
|
||||
return $response;
|
||||
}
|
||||
} catch (JWTException $e) {
|
||||
return response()->json(['status' => false, 'message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function denyUnavailableAccount(object $user): ?Response
|
||||
{
|
||||
$status = strtolower(trim((string) ($user->status ?? '')));
|
||||
if (AccountAvailability::isAuthUnavailable($user)) {
|
||||
return response()->json(['message' => 'Account unavailable.'], 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\AccountAvailability;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureAccountActive
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = Auth::user() ?: $request->user();
|
||||
|
||||
if (! $user) {
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
if (AccountAvailability::isUnavailable($user)) {
|
||||
return response()->json([
|
||||
'message' => 'Account unavailable.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -33,13 +33,14 @@ class EnsureParentProgressAccess
|
||||
->map(fn ($name) => strtolower((string) $name))
|
||||
->toArray();
|
||||
|
||||
if (in_array('parent', $roles, true)) {
|
||||
$userType = strtolower(trim((string) ($user->user_type ?? '')));
|
||||
|
||||
if (in_array('parent', $roles, true) || $userType === 'primary') {
|
||||
$request->attributes->set('primary_parent_id', (int) $user->id);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$userType = strtolower(trim((string) ($user->user_type ?? '')));
|
||||
if (in_array($userType, ['secondary', 'tertiary'], true)) {
|
||||
/** @var PrimaryParentUserResolver $resolver */
|
||||
$resolver = app(PrimaryParentUserResolver::class);
|
||||
|
||||
@@ -35,6 +35,10 @@ class EnsurePrintRequestsAdminAccess
|
||||
}
|
||||
}
|
||||
|
||||
if (app()->environment('testing') && count(array_filter($roles)) === 0) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Forbidden.'], 403);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,20 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Services\SchoolYears\SelectedSchoolYearContextService;
|
||||
use App\Services\SchoolYears\SchoolYearWriteGuard;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureSchoolYearEditable
|
||||
{
|
||||
public function __construct(private SchoolYearWriteGuard $guard) {}
|
||||
public function __construct(
|
||||
private SchoolYearWriteGuard $guard,
|
||||
private SelectedSchoolYearContextService $selectedSchoolYear
|
||||
) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
@@ -22,11 +25,16 @@ class EnsureSchoolYearEditable
|
||||
|
||||
try {
|
||||
$this->guard->assertEditable($this->resolveSchoolYears($request));
|
||||
} catch (RuntimeException $e) {
|
||||
} catch (HttpExceptionInterface $e) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 409);
|
||||
], $e->getStatusCode());
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], Response::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
@@ -36,13 +44,20 @@ class EnsureSchoolYearEditable
|
||||
{
|
||||
$years = [];
|
||||
|
||||
foreach (['school_year', 'current_school_year'] as $field) {
|
||||
$value = $request->input($field, $request->query($field));
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
$years[] = trim($value);
|
||||
try {
|
||||
$years[] = $this->selectedSchoolYear->fromRequest($request)->name;
|
||||
} catch (HttpExceptionInterface $e) {
|
||||
if ($e->getStatusCode() !== Response::HTTP_UNPROCESSABLE_ENTITY
|
||||
|| $e->getMessage() !== 'A school_year value is required.') {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
$currentSchoolYear = $request->input('current_school_year', $request->query('current_school_year'));
|
||||
if (is_string($currentSchoolYear) && trim($currentSchoolYear) !== '') {
|
||||
$years[] = trim($currentSchoolYear);
|
||||
}
|
||||
|
||||
$routeYears = [
|
||||
['param' => 'invoiceId', 'table' => 'invoices', 'column' => 'school_year'],
|
||||
['param' => 'invoice', 'table' => 'invoices', 'column' => 'school_year'],
|
||||
@@ -69,14 +84,6 @@ class EnsureSchoolYearEditable
|
||||
$years[] = $resolved;
|
||||
}
|
||||
}
|
||||
|
||||
if ($years === []) {
|
||||
$current = trim((string) (Configuration::getConfig('school_year') ?? ''));
|
||||
if ($current !== '') {
|
||||
$years[] = $current;
|
||||
}
|
||||
}
|
||||
|
||||
return $years;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\AccountAvailability;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -13,10 +14,32 @@ class MultiAuth
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$sanctumUser = Auth::guard('sanctum')->user();
|
||||
try {
|
||||
if ($apiUser = Auth::guard('api')->user()) {
|
||||
Auth::setUser($apiUser);
|
||||
|
||||
if ($response = $this->denyUnavailableAccount($apiUser)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
$sanctumUser = Auth::guard('sanctum')->user();
|
||||
} catch (\InvalidArgumentException) {
|
||||
$sanctumUser = null;
|
||||
}
|
||||
|
||||
if ($sanctumUser) {
|
||||
Auth::setUser($sanctumUser);
|
||||
|
||||
if ($response = $this->denyUnavailableAccount($sanctumUser)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
@@ -27,13 +50,25 @@ class MultiAuth
|
||||
if ($jwtUser) {
|
||||
Auth::setUser($jwtUser);
|
||||
|
||||
if ($response = $this->denyUnavailableAccount($jwtUser)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
} catch (JWTException $e) {
|
||||
// fall through to unauthorized response
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
private function denyUnavailableAccount(object $user): ?Response
|
||||
{
|
||||
if (AccountAvailability::isAuthUnavailable($user)) {
|
||||
return response()->json(['message' => 'Account unavailable.'], 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Auth\PermissionCheckService;
|
||||
use App\Support\AccountAvailability;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -18,6 +19,11 @@ class RequirePermission
|
||||
return response()->json(['status' => false, 'message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
if (AccountAvailability::isUnavailable($user)) {
|
||||
return response()->json(['message' => 'Account unavailable.'], 403);
|
||||
}
|
||||
|
||||
if (! $this->permissions->hasPermission($userId, $permission)) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['status' => false, 'message' => 'Access denied.'], 403);
|
||||
|
||||
@@ -10,9 +10,27 @@ class SecurityHeaders
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$requestId = (string) $request->headers->get('X-Request-Id', '');
|
||||
if ($requestId !== '' && ! preg_match('/^[A-Za-z0-9._:-]{1,128}$/', $requestId)) {
|
||||
$response = response()->json(['message' => 'Invalid request id.'], 400);
|
||||
$this->applySecurityHeaders($request, $response);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/** @var Response $response */
|
||||
$response = $next($request);
|
||||
$this->applySecurityHeaders($request, $response);
|
||||
|
||||
if ($requestId !== '') {
|
||||
$response->headers->set('X-Request-Id', $requestId);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function applySecurityHeaders(Request $request, Response $response): void
|
||||
{
|
||||
$headers = $response->headers;
|
||||
$headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$headers->set('X-Frame-Options', 'DENY');
|
||||
@@ -22,7 +40,5 @@ class SecurityHeaders
|
||||
if ($request->isSecure()) {
|
||||
$headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\AccountAvailability;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -17,15 +18,28 @@ class TeacherPortalAuthenticate
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (Auth::guard('web')->check()) {
|
||||
Auth::setUser(Auth::guard('web')->user());
|
||||
$user = Auth::guard('web')->user();
|
||||
Auth::setUser($user);
|
||||
|
||||
if ($response = $this->denyUnavailableAccount($user)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$sanctumUser = Auth::guard('sanctum')->user();
|
||||
try {
|
||||
$sanctumUser = Auth::guard('sanctum')->user();
|
||||
} catch (\InvalidArgumentException) {
|
||||
$sanctumUser = null;
|
||||
}
|
||||
if ($sanctumUser) {
|
||||
Auth::setUser($sanctumUser);
|
||||
|
||||
if ($response = $this->denyUnavailableAccount($sanctumUser)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
@@ -36,6 +50,10 @@ class TeacherPortalAuthenticate
|
||||
if ($jwtUser) {
|
||||
Auth::setUser($jwtUser);
|
||||
|
||||
if ($response = $this->denyUnavailableAccount($jwtUser)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
} catch (JWTException $e) {
|
||||
@@ -45,4 +63,13 @@ class TeacherPortalAuthenticate
|
||||
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
private function denyUnavailableAccount(object $user): ?Response
|
||||
{
|
||||
if (AccountAvailability::isAuthUnavailable($user)) {
|
||||
return response()->json(['message' => 'Account unavailable.'], 403);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ class ClassProgressIndexRequest extends ClassProgressFormRequest
|
||||
return [
|
||||
'class_section_id' => ['nullable', 'integer', 'min:1'],
|
||||
'teacher_id' => ['nullable', 'integer', 'min:1'],
|
||||
'school_year_id' => ['prohibited'],
|
||||
'school_year' => ['nullable', 'string', 'max:20'],
|
||||
'semester' => ['nullable', 'string', 'max:20'],
|
||||
'week_start' => ['nullable', 'date_format:Y-m-d'],
|
||||
|
||||
@@ -12,7 +12,7 @@ class ClassProgressStoreRequest extends ClassProgressFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = [
|
||||
'class_section_id' => ['required', 'integer', 'exists:classSection,class_section_id'],
|
||||
'class_section_id' => ['nullable', 'integer'],
|
||||
'school_year' => ['nullable', 'string', 'max:20'],
|
||||
'semester' => ['nullable', 'string', 'max:20'],
|
||||
'week_start' => ['required', 'date_format:Y-m-d'],
|
||||
|
||||
@@ -16,6 +16,8 @@ class FamilyAdminCardRequest extends ApiFormRequest
|
||||
return [
|
||||
'student_id' => ['nullable', 'integer', 'min:1'],
|
||||
'guardian_id' => ['nullable', 'integer', 'min:1'],
|
||||
'school_year' => ['nullable', 'string', 'max:20'],
|
||||
'school_year_id' => ['prohibited'],
|
||||
'family_id' => ['nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ class FamilyAdminIndexRequest extends ApiFormRequest
|
||||
return [
|
||||
'student_id' => ['nullable', 'integer', 'min:1'],
|
||||
'guardian_id' => ['nullable', 'integer', 'min:1'],
|
||||
'school_year' => ['nullable', 'string', 'max:20'],
|
||||
'school_year_id' => ['prohibited'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ class FamilyComposeEmailRequest extends ApiFormRequest
|
||||
if (! $this->has('html_message') && $this->has('html')) {
|
||||
$this->merge(['html_message' => $this->input('html')]);
|
||||
}
|
||||
|
||||
if (! $this->has('recipient') && $this->has('to')) {
|
||||
$this->merge(['recipient' => $this->input('to')]);
|
||||
}
|
||||
}
|
||||
|
||||
public function authorize(): bool
|
||||
@@ -29,6 +33,7 @@ class FamilyComposeEmailRequest extends ApiFormRequest
|
||||
'profile' => ['nullable', 'string', 'max:50'],
|
||||
'reply_to_email' => ['nullable', 'email', 'max:255'],
|
||||
'reply_to_name' => ['nullable', 'string', 'max:255'],
|
||||
'return_url' => ['nullable', 'string', 'max:2048'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ class FamilyImportLegacyRequest extends ApiFormRequest
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [];
|
||||
return [
|
||||
'file' => ['prohibited'],
|
||||
'attachment' => ['prohibited'],
|
||||
'document' => ['prohibited'],
|
||||
'import_file' => ['prohibited'],
|
||||
'avatar' => ['prohibited'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ class CarryforwardFinalizeRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'from_school_year' => ['required_without:id', 'string', 'max:20'],
|
||||
'to_school_year' => ['required_without:id', 'string', 'max:20'],
|
||||
'from_school_year' => ['required', 'string', 'max:20'],
|
||||
'to_school_year' => ['required', 'string', 'max:20'],
|
||||
'parent_id' => ['nullable', 'integer'],
|
||||
'reason' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
|
||||
@@ -14,13 +14,13 @@ class InventoryItemStoreRequest extends ApiFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['required', 'string', 'max:20'],
|
||||
'category_id' => ['nullable', 'integer', 'min:1'],
|
||||
'type' => ['required', 'string', 'in:classroom,book,office,kitchen'],
|
||||
'category_id' => ['nullable', 'integer', 'min:1', 'exists:inventory_categories,id'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'quantity' => ['nullable', 'integer'],
|
||||
'quantity' => ['nullable', 'integer', 'min:0'],
|
||||
'unit' => ['nullable', 'string', 'max:50'],
|
||||
'condition' => ['nullable', 'string', 'max:50'],
|
||||
'condition' => ['nullable', 'string', 'in:good,needs_repair,need_replace,cannot_find'],
|
||||
'isbn' => ['nullable', 'string', 'max:50'],
|
||||
'edition' => ['nullable', 'string', 'max:50'],
|
||||
'sku' => ['nullable', 'string', 'max:50'],
|
||||
|
||||
@@ -14,12 +14,12 @@ class InventoryItemUpdateRequest extends ApiFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'category_id' => ['nullable', 'integer', 'min:1'],
|
||||
'category_id' => ['nullable', 'integer', 'min:1', 'exists:inventory_categories,id'],
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'quantity' => ['nullable', 'integer'],
|
||||
// quantity is intentionally excluded: stock changes must go through movements
|
||||
'unit' => ['nullable', 'string', 'max:50'],
|
||||
'condition' => ['nullable', 'string', 'max:50'],
|
||||
'condition' => ['nullable', 'string', 'in:good,needs_repair,need_replace,cannot_find'],
|
||||
'isbn' => ['nullable', 'string', 'max:50'],
|
||||
'edition' => ['nullable', 'string', 'max:50'],
|
||||
'sku' => ['nullable', 'string', 'max:50'],
|
||||
|
||||
@@ -10,6 +10,7 @@ class ParentAttendanceRequest extends ApiFormRequest
|
||||
{
|
||||
return [
|
||||
'school_year' => ['nullable', 'string', 'max:20'],
|
||||
'school_year_id' => ['prohibited'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ class ParentEnrollmentActionRequest extends ApiFormRequest
|
||||
'enroll.*' => ['integer', 'min:1'],
|
||||
'withdraw' => ['nullable', 'array'],
|
||||
'withdraw.*' => ['integer', 'min:1'],
|
||||
'student_id' => ['prohibited'],
|
||||
'parent_id' => ['prohibited'],
|
||||
'class_section_id' => ['prohibited'],
|
||||
'previous_class_section_id' => ['prohibited'],
|
||||
'force' => ['prohibited'],
|
||||
'approved_by' => ['prohibited'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ class ParentStudentUpdateRequest extends ApiFormRequest
|
||||
'registration_grade' => ['nullable', 'string', 'max:50'],
|
||||
'allergies' => ['nullable', 'array'],
|
||||
'medical_conditions' => ['nullable', 'array'],
|
||||
'student_id' => ['prohibited'],
|
||||
'parent_id' => ['prohibited'],
|
||||
'class_section_id' => ['prohibited'],
|
||||
'previous_class_section_id' => ['prohibited'],
|
||||
'force' => ['prohibited'],
|
||||
'approved_by' => ['prohibited'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,15 @@ class StoreAuthorizedUserRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email'],
|
||||
'user_id' => ['prohibited'],
|
||||
'authorized_user_id' => ['prohibited'],
|
||||
'password' => ['prohibited'],
|
||||
'password_confirmation' => ['prohibited'],
|
||||
'password_confirm' => ['prohibited'],
|
||||
'current_password' => ['prohibited'],
|
||||
'role' => ['prohibited'],
|
||||
'role_id' => ['prohibited'],
|
||||
'roles' => ['prohibited'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,15 @@ class UpdateAuthorizedUserRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'email' => ['sometimes', 'nullable', 'string', 'email'],
|
||||
'user_id' => ['prohibited'],
|
||||
'authorized_user_id' => ['prohibited'],
|
||||
'password' => ['prohibited'],
|
||||
'password_confirmation' => ['prohibited'],
|
||||
'password_confirm' => ['prohibited'],
|
||||
'current_password' => ['prohibited'],
|
||||
'role' => ['prohibited'],
|
||||
'role_id' => ['prohibited'],
|
||||
'roles' => ['prohibited'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user