archetecture security fix
This commit is contained in:
+513
@@ -0,0 +1,513 @@
|
||||
## Docker Environments
|
||||
|
||||
Three Docker environments are available:
|
||||
|
||||
- `Dockerfile.dev` with `docker-compose.dev.yml`
|
||||
- `Dockerfile.test` with `docker-compose.test.yml`
|
||||
- `Dockerfile.production` with `docker-compose.production.yml`
|
||||
- `docker-compose.pgmanage.yml` for a standalone pgManage container
|
||||
- `docker-compose.portainer.production.yml` for a standalone production Portainer deployment behind Traefik
|
||||
- `docker-compose.registry.production.yml` for a standalone production Docker registry behind Traefik
|
||||
- `docker-compose.registry.local.yml` for a Docker registry running on a separate local server with port `5000` exposed directly
|
||||
|
||||
### Development
|
||||
|
||||
Use the full dev stack for local work with hot reload and bundled Postgres and Redis:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml --profile full up --build
|
||||
```
|
||||
|
||||
Services:
|
||||
|
||||
- marketplace: `http://localhost:3000`
|
||||
- dashboard: `http://localhost:3000/dashboard`
|
||||
- admin: `http://localhost:3000/admin`
|
||||
- public-site: `http://localhost:3003`
|
||||
- api: `http://localhost:4000`
|
||||
- pgAdmin: `http://localhost:5050`
|
||||
|
||||
Each dev app now runs in its own container and can be started independently with a profile tag:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml --profile api up --build
|
||||
docker compose -f docker-compose.dev.yml --profile marketplace up --build
|
||||
docker compose -f docker-compose.dev.yml --profile dashboard up --build
|
||||
docker compose -f docker-compose.dev.yml --profile admin up --build
|
||||
docker compose -f docker-compose.dev.yml --profile public-site up --build
|
||||
docker compose -f docker-compose.dev.yml --profile tools up --build
|
||||
docker compose -f docker-compose.dev.yml --profile api --profile marketplace --profile dashboard --profile admin up --build
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `api` starts `postgres`, `redis`, and `migrate` automatically through dependencies.
|
||||
- frontend profiles also start `api` and its dependencies automatically.
|
||||
- `tools` starts only `pgadmin` plus its required `postgres` dependency.
|
||||
|
||||
On startup, Docker now waits for Postgres to become healthy, runs a one-shot `migrate` service, and only then starts the selected app container. For development, that bootstrap runs `db:generate` every time, but `db:deploy` and `db:seed` only the first time for a persisted dev database, so your local data survives rebuilds and normal restarts.
|
||||
|
||||
Uploaded assets handled by the API, including vehicle photos and company branding images, are stored in the named Docker volume `api_uploads_dev` at `/var/lib/rentaldrivego/storage` inside the container. They stay available across normal container rebuilds and restarts.
|
||||
|
||||
Default dev platform administrator:
|
||||
|
||||
- email: `admin@rentaldrivego.com`
|
||||
- password: `changeme123`
|
||||
|
||||
If you intentionally want a fresh dev bootstrap:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml down -v
|
||||
```
|
||||
|
||||
If you want to keep the database and only apply new schema changes manually:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml run --rm migrate sh -c "npm run db:deploy"
|
||||
```
|
||||
|
||||
pgAdmin dev login:
|
||||
|
||||
- email: `admin@rentaldrivego.local`
|
||||
- email: `admin@rentaldrivego.dev`
|
||||
- password: `admin`
|
||||
|
||||
pgAdmin opens with the dev Postgres server pre-registered as `RentalDriveGo Dev DB`.
|
||||
|
||||
pgAdmin Postgres connection:
|
||||
|
||||
- host: `postgres`
|
||||
- port: `5432`
|
||||
- database: `rentaldrivego`
|
||||
- username: `postgres`
|
||||
- password: `password`
|
||||
|
||||
### Standalone pgManage
|
||||
|
||||
If you want a standalone Postgres management UI without starting the full development stack:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.pgmanage.yml up -d
|
||||
```
|
||||
|
||||
It publishes `http://localhost:8000` with a standard Docker port mapping and persists its data in the named Docker volume `pgmanage_data`.
|
||||
From inside the container, connect to the local Postgres service through `host.docker.internal:5432`.
|
||||
For local HTTP access, pgManage defaults `PGMANAGE_SECURE_COOKIES=False`. If you expose it behind HTTPS instead, set `PGMANAGE_SECURE_COOKIES=True`.
|
||||
If you are using the enterprise image, set `PGMANAGE_LICENSE_KEY` to remove the startup license error.
|
||||
|
||||
### Test
|
||||
|
||||
Use the test stack to run repeatable containerized verification:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
|
||||
```
|
||||
|
||||
The test container runs:
|
||||
|
||||
- `npm run db:deploy`
|
||||
- `npm run db:generate`
|
||||
- `npm run type-check`
|
||||
- `npm run build`
|
||||
- `npm run test:api:integration`
|
||||
|
||||
### Production
|
||||
|
||||
The production stack runs behind **Traefik** (reverse proxy + automatic HTTPS via Let's Encrypt). All services communicate over a private Docker network (`internal`). Traefik reaches public-facing services via a separate `traefik-proxy` network.
|
||||
|
||||
#### 1. Point DNS to your server
|
||||
|
||||
Add an A record for every subdomain to your server's public IP before deploying so Let's Encrypt can issue certificates:
|
||||
|
||||
| Subdomain | Service |
|
||||
|---|---|
|
||||
| `rentaldrivego.ma` | marketplace and public site |
|
||||
| `api.rentaldrivego.ma` | API |
|
||||
| `pgmanage.rentaldrivego.ma` | pgManage (DB admin) |
|
||||
| `portainer.rentaldrivego.ma` | Portainer |
|
||||
| `registry.rentaldrivego.ma` | Docker registry |
|
||||
|
||||
#### 2. Install Docker and clone the repo
|
||||
|
||||
```bash
|
||||
# Install Docker (if not already installed)
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
|
||||
git clone <repo-url> rentaldrivego
|
||||
cd rentaldrivego
|
||||
```
|
||||
|
||||
#### 3. Create the shared Traefik network
|
||||
|
||||
Only needs to be done once per server. If it already exists this is a no-op.
|
||||
|
||||
```bash
|
||||
docker network create traefik-proxy
|
||||
```
|
||||
|
||||
#### 4. Configure environment variables
|
||||
|
||||
```bash
|
||||
cp .env.docker.production.example .env.docker.production
|
||||
```
|
||||
|
||||
Open `.env.docker.production` and fill in every value. The minimum required secrets are:
|
||||
|
||||
| Variable | What to set |
|
||||
|---|---|
|
||||
| `POSTGRES_PASSWORD` | Strong random password |
|
||||
| `JWT_SECRET` | Long random string (e.g. `openssl rand -hex 64`) |
|
||||
| `ACME_EMAIL` | Your email for Let's Encrypt notifications |
|
||||
| `RESEND_API_KEY` | Resend API key (or configure SMTP vars instead) |
|
||||
| `PGMANAGE_DOMAIN` | Hostname for pgManage, e.g. `pgmanage.rentaldrivego.ma` |
|
||||
|
||||
Production now derives `DATABASE_URL` inside the app container from `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD` when `DATABASE_URL_FROM_POSTGRES=true`. That avoids Prisma auth failures when the database password contains reserved URL characters such as `@`, `:`, or `/`.
|
||||
|
||||
The example file uses `rentaldrivego.ma` for the marketplace and public site. The dashboard and admin panel are routed under that same host at `/dashboard` and `/admin`.
|
||||
Set `PORTAINER_DOMAIN=portainer.rentaldrivego.ma` in `.env.docker.production` to expose Portainer through Traefik.
|
||||
Set `PGMANAGE_DOMAIN=pgmanage.rentaldrivego.ma` to expose pgManage through Traefik, and set `PGMANAGE_LICENSE_KEY` if you are running the enterprise image.
|
||||
Set `REGISTRY_DOMAIN=registry.rentaldrivego.ma` to expose the Docker registry through Traefik.
|
||||
Set `REGISTRY_UPSTREAM_URL=http://<local-registry-server-ip>:5000` on the VPS when the registry itself runs on a different server and Traefik should reverse-proxy to it.
|
||||
|
||||
If you want to use a self-hosted registry on the same VPS, also set these values in `.env.docker.production`:
|
||||
|
||||
```text
|
||||
REGISTRY_HOST=registry.rentaldrivego.ma
|
||||
REGISTRY_USER=<registry-user>
|
||||
REGISTRY_PASSWORD=<strong-registry-password>
|
||||
REGISTRY_IMAGE=registry.rentaldrivego.ma/rentaldrivego/car_management_system
|
||||
```
|
||||
|
||||
The registry bootstrap script uses `REGISTRY_USER` and `REGISTRY_PASSWORD` to generate the htpasswd file mounted into the registry container, so the same credentials can be used by GitLab CI for `docker login`.
|
||||
|
||||
#### 4a. Configure the registry for CI deploys
|
||||
|
||||
If you want GitLab CI to build once and deploy by pulling a prebuilt image on the VPS, configure one of these two modes in GitLab `Settings > CI/CD > Variables`:
|
||||
|
||||
- Built-in GitLab Container Registry:
|
||||
Enable the project registry and use GitLab's default `CI_REGISTRY`, `CI_REGISTRY_USER`, `CI_REGISTRY_PASSWORD`, and `CI_REGISTRY_IMAGE` variables.
|
||||
- Explicit registry variables:
|
||||
Set `REGISTRY_HOST`, `REGISTRY_USER`, `REGISTRY_PASSWORD`, and `REGISTRY_IMAGE` yourself. This works for GitLab Registry, Docker Hub, GHCR, or another OCI registry.
|
||||
|
||||
If `REGISTRY_*` variables are set, the CI pipeline now uses them as a complete override and requires all four values. If they are unset, the pipeline falls back to GitLab's `CI_REGISTRY_*` variables.
|
||||
|
||||
For production image builds, also define these GitLab CI/CD variables with their real public URLs:
|
||||
|
||||
```text
|
||||
NEXT_PUBLIC_API_URL=https://api.rentaldrivego.ma/api/v1
|
||||
NEXT_PUBLIC_MARKETPLACE_URL=https://rentaldrivego.ma
|
||||
NEXT_PUBLIC_DASHBOARD_URL=https://rentaldrivego.ma/dashboard
|
||||
NEXT_PUBLIC_ADMIN_URL=https://rentaldrivego.ma/admin
|
||||
```
|
||||
|
||||
The Docker build now fails if any of those values are missing or still point at `example.com`, because Next.js inlines them into the production frontend bundles at build time.
|
||||
|
||||
Example explicit values for a local registry exposed through Traefik:
|
||||
|
||||
```text
|
||||
REGISTRY_HOST=registry.rentaldrivego.ma
|
||||
REGISTRY_IMAGE=registry.rentaldrivego.ma/rentaldrivego/car_management_system
|
||||
REGISTRY_USER=<registry-user>
|
||||
REGISTRY_PASSWORD=<registry-password>
|
||||
```
|
||||
|
||||
For the deploy job, add these GitLab CI variables as well:
|
||||
|
||||
```text
|
||||
VPS_IP=<server-ip-or-hostname>
|
||||
VPS_USER=<ssh-user>
|
||||
SSH_PRIVATE_KEY=<deployment-private-key>
|
||||
```
|
||||
|
||||
`SSH_PRIVATE_KEY` must be an unencrypted private key that OpenSSH can read in a non-interactive job. The pipeline accepts any of these formats:
|
||||
|
||||
- GitLab `File` variable containing the private key
|
||||
- Standard multiline key pasted directly into the variable value
|
||||
- Single-line key with literal `\n` newline escapes
|
||||
- Base64-encoded private key stored as a single line
|
||||
|
||||
If your key is passphrase-protected, generate a dedicated deploy key without a passphrase for CI instead of reusing an interactive workstation key.
|
||||
|
||||
The production compose file reads `APP_IMAGE` and `APP_VERSION` for pull-based deploys. The GitLab deploy job injects those values automatically and now syncs the deployment assets to the VPS before running the server-side deploy script. Production no longer depends on `git pull` during release.
|
||||
|
||||
The CI pipeline publishes and deploys from the GitLab default branch (`$CI_DEFAULT_BRANCH`). If you change your release branch, update the repository default branch in GitLab instead of hard-coding branch names in `.gitlab-ci.yml`.
|
||||
|
||||
If you use `docker:dind` on a self-hosted GitLab runner, the runner's Docker executor must be started with `privileged = true`. Without that, `docker:dind` often logs AppArmor or `/sys/kernel/security` mount errors and can become unreliable even when the job container still starts.
|
||||
|
||||
#### 5. Bootstrap the server once
|
||||
|
||||
Traefik must be running before the app stack so it can wire up routes at startup. This bootstrap path is for the first server setup or for deliberate source-based rebuilds. Normal production releases should go through GitLab CI.
|
||||
|
||||
```bash
|
||||
bash scripts/docker-prod-up-traefik.sh
|
||||
```
|
||||
|
||||
If you intentionally want to build from source on the server:
|
||||
|
||||
```bash
|
||||
npm run docker:prod:up
|
||||
```
|
||||
|
||||
Or use the helper scripts if you want to start one production container at a time:
|
||||
|
||||
```bash
|
||||
npm run docker:prod:start:traefik
|
||||
npm run docker:prod:start:postgres
|
||||
npm run docker:prod:start:redis
|
||||
npm run docker:prod:start:api
|
||||
npm run docker:prod:start:marketplace
|
||||
npm run docker:prod:start:dashboard
|
||||
npm run docker:prod:start:admin
|
||||
npm run docker:prod:start:frontends
|
||||
npm run docker:prod:start:pgmanage
|
||||
npm run docker:prod:start:portainer
|
||||
npm run docker:prod:start:registry
|
||||
npm run docker:prod:start:all
|
||||
npm run docker:prod:start:api && npm run docker:prod:start:frontends
|
||||
```
|
||||
|
||||
Docker will:
|
||||
1. Build the monorepo image on the server
|
||||
2. Start all app services (`api`, `marketplace`, `dashboard`, `admin`, `pgmanage`)
|
||||
|
||||
Traefik automatically picks up the containers and provisions TLS certificates. Services are live at their `https://` URLs within ~30 seconds.
|
||||
|
||||
#### 6. Standard release flow
|
||||
|
||||
Normal production releases should use GitLab CI:
|
||||
|
||||
1. CI builds and pushes a single versioned image.
|
||||
2. CI copies the current deployment assets (`docker-compose.production.yml`, Traefik config, deploy scripts, pgManage override) to the VPS.
|
||||
3. CI runs `bash scripts/docker-prod-deploy.sh` on the VPS with `APP_IMAGE` and `APP_VERSION` pinned to the commit being released.
|
||||
4. The deploy script pulls the release image, starts Postgres and Redis, runs `npm run db:deploy` as a one-shot migration job, then starts the API and frontends and waits for their health checks.
|
||||
|
||||
This avoids the old drift where the VPS checkout changed independently through `git pull`, or where API startup silently mixed migrations with application boot.
|
||||
|
||||
#### 6a. Deploy Portainer
|
||||
|
||||
Portainer is deployed separately from the main app stack and reuses the shared `traefik-proxy` network managed by Traefik.
|
||||
|
||||
```bash
|
||||
npm run docker:prod:start:portainer
|
||||
```
|
||||
|
||||
Equivalent raw Docker Compose command:
|
||||
|
||||
```bash
|
||||
docker compose -p rentaldrivego-portainer-prod --env-file .env.docker.production -f docker-compose.portainer.production.yml up -d
|
||||
```
|
||||
|
||||
#### 6b. Deploy the registry on a separate local server
|
||||
|
||||
For the split setup:
|
||||
|
||||
1. Run the registry on your local server with port `5000` exposed directly.
|
||||
2. Set `REGISTRY_UPSTREAM_URL` on the VPS to `http://<local-registry-server-ip>:5000`.
|
||||
3. Restart Traefik on the VPS so it renders the dynamic registry upstream file and serves TLS for `REGISTRY_DOMAIN`.
|
||||
|
||||
On the local registry server:
|
||||
|
||||
```bash
|
||||
npm run docker:registry:local:start
|
||||
```
|
||||
|
||||
Equivalent raw Docker Compose command:
|
||||
|
||||
```bash
|
||||
docker compose -p rentaldrivego-registry-local --env-file .env.docker.production -f docker-compose.registry.local.yml up -d
|
||||
```
|
||||
|
||||
On first start, the script generates `docker/registry/auth/htpasswd` from `REGISTRY_USER` and `REGISTRY_PASSWORD` in `.env.docker.production`.
|
||||
|
||||
From the VPS, verify the upstream directly before involving Traefik:
|
||||
|
||||
```bash
|
||||
curl -I http://<local-registry-server-ip>:5000/v2/
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```text
|
||||
HTTP/1.1 401 Unauthorized
|
||||
```
|
||||
|
||||
Then on the VPS restart Traefik:
|
||||
|
||||
```bash
|
||||
bash scripts/docker-prod-up-traefik.sh
|
||||
```
|
||||
|
||||
After that, verify the public route and login flow:
|
||||
|
||||
```bash
|
||||
docker login registry.rentaldrivego.ma
|
||||
docker pull registry:2
|
||||
docker tag registry:2 registry.rentaldrivego.ma/smoke-test:latest
|
||||
docker push registry.rentaldrivego.ma/smoke-test:latest
|
||||
```
|
||||
|
||||
Uploaded assets handled by the API, including vehicle photos and company branding images, are persisted in the named Docker volume `api_uploads`, mounted inside the API container at `/var/lib/rentaldrivego/storage`. Rebuilding or redeploying the API no longer clears those files as long as that volume is kept.
|
||||
|
||||
The production helper scripts and the GitLab deploy job now explicitly create the Docker volume `${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_api_uploads` before starting or redeploying the API stack, so a fresh server bootstrap does not accidentally start the API without its upload storage volume.
|
||||
|
||||
#### Updating after a code change
|
||||
|
||||
Push to the GitLab default branch and let the pipeline deploy the pinned image. That is the supported production release path.
|
||||
|
||||
If CI is unavailable and you need to redeploy the image already published to the registry from the server:
|
||||
|
||||
```bash
|
||||
APP_IMAGE=registry.example.com/rentaldrivego/car_management_system \
|
||||
APP_VERSION=<gitlab-commit-sha> \
|
||||
REGISTRY_HOST=registry.example.com \
|
||||
REGISTRY_USER=<registry-user> \
|
||||
REGISTRY_PASSWORD=<registry-password> \
|
||||
bash scripts/docker-prod-deploy.sh
|
||||
```
|
||||
|
||||
Only use `npm run docker:prod:up` when you intentionally want a source build on the server.
|
||||
|
||||
#### Apply database migrations without downtime
|
||||
|
||||
```bash
|
||||
docker compose -p rentaldrivego-prod --env-file .env.docker.production -f docker-compose.production.yml run --rm migrate
|
||||
```
|
||||
|
||||
#### Create the first production admin
|
||||
|
||||
The repo includes a seed that creates the first `SUPER_ADMIN` if that email does not already exist.
|
||||
|
||||
```bash
|
||||
docker compose -p rentaldrivego-prod --env-file .env.docker.production -f docker-compose.production.yml run --rm \
|
||||
-e ADMIN_SEED_EMAIL=rentaldrivego@gmail.com \
|
||||
-e ADMIN_SEED_PASSWORD='Qwerty00!@#$%' \
|
||||
-e ADMIN_SEED_FIRST_NAME=Super \
|
||||
-e ADMIN_SEED_LAST_NAME=Admin \
|
||||
api npm run db:seed:admin
|
||||
```
|
||||
|
||||
Then sign in at `https://rentaldrivego.ma/admin/login` and create any additional admin users from the admin panel.
|
||||
|
||||
#### View logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
npm run docker:prod:logs
|
||||
|
||||
# Single service
|
||||
npm run docker:prod:logs:api
|
||||
```
|
||||
|
||||
#### Backup production data
|
||||
|
||||
Create a timestamped backup directory under `./backups`:
|
||||
|
||||
```bash
|
||||
npm run docker:prod:backup
|
||||
```
|
||||
|
||||
Or choose a different parent directory:
|
||||
|
||||
```bash
|
||||
bash scripts/docker-prod-backup.sh /srv/rentaldrivego-backups
|
||||
```
|
||||
|
||||
Each backup contains:
|
||||
|
||||
- `postgres.dump` — logical PostgreSQL backup in custom format
|
||||
- `api-uploads.tar.gz` — uploaded files from `/var/lib/rentaldrivego/storage`
|
||||
- `traefik-letsencrypt.tar.gz` — Traefik ACME certificate state, when available
|
||||
- `volumes/*.tar.gz` — raw Docker volume archives for the default production volumes
|
||||
- `manifest.txt` — basic metadata
|
||||
|
||||
By default, the backup also archives these named production volumes when they exist:
|
||||
|
||||
- `${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_api_uploads`
|
||||
- `${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_pgmanage_prod_data`
|
||||
- `${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_postgres_prod_data`
|
||||
- `${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}_redis_prod_data`
|
||||
|
||||
To include extra named volumes from the same server, pass them as additional arguments:
|
||||
|
||||
```bash
|
||||
bash scripts/docker-prod-backup.sh /srv/rentaldrivego-backups \
|
||||
gitlab-l3gq_gitlab-config \
|
||||
gitlab-l3gq_gitlab-data \
|
||||
gitlab-l3gq_gitlab-logs \
|
||||
pgmanage_data \
|
||||
traefik-gcjk_portainer_data \
|
||||
traefik-gcjk_traefik-letsencrypt
|
||||
```
|
||||
|
||||
You can also provide extra volume names through `DOCKER_EXTRA_BACKUP_VOLUMES`.
|
||||
|
||||
#### Restore production data
|
||||
|
||||
Restore is destructive: it overwrites the production database, uploaded files, and, when present in the backup, Traefik ACME state.
|
||||
|
||||
```bash
|
||||
bash scripts/docker-prod-restore.sh ./backups/rentaldrivego-prod-YYYYMMDDTHHMMSSZ --yes
|
||||
```
|
||||
|
||||
The restore script:
|
||||
|
||||
1. Stops public app services
|
||||
2. Restores the PostgreSQL dump
|
||||
3. Replaces uploaded files
|
||||
4. Restores Traefik ACME state if the archive exists
|
||||
5. Restores raw volume archives from `volumes/` except the ones already covered by the database and upload restore steps
|
||||
6. Starts Traefik and the production stack again
|
||||
|
||||
If the backup contains raw archives for volumes used by other stacks such as GitLab or Portainer, stop those containers before running restore. The script will refuse to overwrite a volume that is currently attached to a running container.
|
||||
|
||||
Before running restore on a live server, take a fresh backup first.
|
||||
|
||||
#### Stop the stack
|
||||
|
||||
```bash
|
||||
# Stop containers but keep volumes (data is preserved)
|
||||
npm run docker:prod:down
|
||||
|
||||
# Stop and delete all data (destructive — irreversible)
|
||||
docker compose -p rentaldrivego-prod --env-file .env.docker.production -f docker-compose.production.yml down -v
|
||||
```
|
||||
|
||||
#### pgManage (DB admin UI)
|
||||
|
||||
pgManage is available at `https://pgmanage.rentaldrivego.ma`. To connect to the production database, add a connection inside pgManage with:
|
||||
|
||||
- **Host:** `localhost`
|
||||
- **Port:** `5432`
|
||||
- **Database:** `rentaldrivego`
|
||||
- **Username:** `postgres`
|
||||
- **Password:** value of `POSTGRES_PASSWORD` from `.env.docker.production`
|
||||
|
||||
#### Portainer
|
||||
|
||||
Portainer is available at `https://portainer.rentaldrivego.ma`.
|
||||
|
||||
Use this command to deploy or update it:
|
||||
|
||||
```bash
|
||||
npm run docker:prod:start:portainer
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- The production image builds the whole monorepo once, then each service overrides its runtime command.
|
||||
- The dev compose file bind-mounts the repo and keeps `node_modules` in a named volume.
|
||||
- `API_INTERNAL_URL` is used for server-side container-to-container calls, while `NEXT_PUBLIC_API_URL` is used by the browser.
|
||||
- The Dockerfiles activate the repo's pinned `npm@10.5.0` with `corepack` before install so container builds do not depend on the npm version bundled with the base image.
|
||||
- The dev compose stack stores Postgres data in `postgres_dev_data` and the bootstrap marker in `postgres_bootstrap_state`, so `up --build` does not reseed an existing local database.
|
||||
- If you need database schema updates inside Docker, run:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml run --rm migrate
|
||||
```
|
||||
|
||||
If a cached base image still fails during `npm ci`, refresh it and rebuild without cache:
|
||||
|
||||
```bash
|
||||
docker pull node:20-bookworm
|
||||
docker compose -f docker-compose.dev.yml build --no-cache dashboard
|
||||
```
|
||||
@@ -0,0 +1,46 @@
|
||||
# Finance Plan Implementation Notes
|
||||
|
||||
This implementation keeps the existing financial endpoints and PayPal-related code intact, then adds the missing additive finance lifecycle pieces from the plan.
|
||||
|
||||
## Added
|
||||
|
||||
- Finance lifecycle migration: follow-up notes, balance carryforwards, installment plans/installments, payment-installment allocations, notification logs/preferences, receipt sequences, and carryforward payment allocations.
|
||||
- Parent payment follow-up reporting with CSV export and write-only metadata actions for notes, contacted status, promise-to-pay, and resolution.
|
||||
- Prior-year balance carryforward preview, draft creation, approval, posting to a new-year invoice, waiver, adjustment, report, and CSV export.
|
||||
- Installment plan creation, activation, cancellation, overdue/due reporting, and payment allocation.
|
||||
- Finance notification logging endpoints for receipts, statements, overdue reminders, installment reminders, and notification log search.
|
||||
- Event charge lifecycle API endpoints for create/list/show/update/delete, approve, void, and attach-to-invoice.
|
||||
- Finance config flags for legacy ledger mode, carryforward behavior, payment allocation policy, and tuition calculator version.
|
||||
|
||||
## Safety posture
|
||||
|
||||
- Existing finance routes were not removed.
|
||||
- Existing PayPal models/controllers/routes were not removed.
|
||||
- Existing invoice/payment math remains the source for read models.
|
||||
- Carryforward posting creates a separate new-year invoice instead of mutating prior-year invoices.
|
||||
- Follow-up notes do not modify invoice balances.
|
||||
- Installment allocation writes to additive allocation tables and installment balances only.
|
||||
- Notifications currently log database events instead of sending real external email/SMS, because inboxes are not test benches, despite humanity's tireless effort to prove otherwise.
|
||||
|
||||
## Key files changed or added
|
||||
|
||||
- `database/migrations/2026_06_04_230000_add_finance_lifecycle_tables.php`
|
||||
- `config/finance.php`
|
||||
- `app/Services/Finance/ParentPaymentFollowUpService.php`
|
||||
- `app/Services/Finance/PriorYearBalanceCarryforwardService.php`
|
||||
- `app/Services/Finance/InstallmentPlanService.php`
|
||||
- `app/Services/Finance/FinanceNotificationLogService.php`
|
||||
- `app/Http/Controllers/Api/Finance/BalanceCarryforwardController.php`
|
||||
- `app/Http/Controllers/Api/Finance/InstallmentPlanController.php`
|
||||
- `app/Http/Controllers/Api/Finance/FinanceNotificationController.php`
|
||||
- `app/Http/Controllers/Api/Finance/EventChargeController.php`
|
||||
- `app/Http/Controllers/Api/Finance/FinancialController.php`
|
||||
- `routes/api.php`
|
||||
- New finance request classes under `app/Http/Requests/Finance/`
|
||||
- New finance lifecycle models under `app/Models/`
|
||||
|
||||
## Not fully implemented by design
|
||||
|
||||
- Centralized ledger replacement is still behind the future phase. That is intentional. Replacing accounting math without dry-runs is how systems become folklore.
|
||||
- External mail/SMS sending is not activated. The implementation logs notification intent first.
|
||||
- Parent-facing UI pages are not included because this package is the Laravel API.
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
# 🚗 RentalDriveGo— Multi-Tenant Car Rental SaaS + Marketplace
|
||||
|
||||
## Platform Model
|
||||
|
||||
**Rental Companies (B2B)** — pay RentalDriveGoa subscription (AmanPay or PayPal), get:
|
||||
- A fully private dashboard (zero data overlap with any other company)
|
||||
- Fleet management with vehicle photo upload (photos auto-appear on global marketplace)
|
||||
- Promotional offers management
|
||||
- A white-label site at `company.RentalDriveGo.com` where renters book and pay
|
||||
|
||||
**Renters (B2C)** — free account, can:
|
||||
- Browse ALL companies' vehicles and offers on `RentalDriveGo.com/explore`
|
||||
- Click a vehicle → **redirected to the company's site** to book and pay
|
||||
- Receive full notifications (Email + SMS + WhatsApp + Push + In-App)
|
||||
- Track all bookings across companies in one account
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Payment Providers — Stripe Has Been Removed
|
||||
|
||||
| Provider | Purpose |
|
||||
|----------|---------|
|
||||
| **AmanPay** (amanpay.net) | Primary. National/international cards, cash (3000+ points), e-wallet. Moroccan PCI-DSS Level-1 PSP. |
|
||||
| **PayPal** | Secondary. Global coverage. |
|
||||
|
||||
**Two payment contexts:**
|
||||
1. Company pays RentalDriveGosubscription → RentalDriveGo's own AmanPay/PayPal account
|
||||
2. Renter pays company for rental → Company's own AmanPay merchant + PayPal account (direct, no intermediary)
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Marketplace is Discovery Only — No Booking on RentalDriveGo.com/explore
|
||||
|
||||
The global marketplace shows all vehicles with photos from all companies. When a renter clicks "Book this vehicle", they are **redirected to the company's branded site** (`{slug}.RentalDriveGo.com/book?vehicleId=X&from=Y&to=Z&ref=marketplace`). The booking form is pre-filled. All payment happens on the company site using the company's own payment accounts.
|
||||
|
||||
---
|
||||
|
||||
## 📸 Vehicle Photos → Marketplace Visibility
|
||||
|
||||
```
|
||||
Company uploads photos in /dashboard/fleet
|
||||
→ Stored in Cloudinary
|
||||
→ Vehicle.photos[] holds the URLs
|
||||
→ First photo = cover image on marketplace cards
|
||||
→ All photos shown in marketplace vehicle gallery
|
||||
→ isPublished=true makes vehicle appear on marketplace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗 Five-Layer Architecture
|
||||
|
||||
```
|
||||
Layer 1A: RentalDriveGo.com — B2B marketing site
|
||||
Layer 1B: RentalDriveGo.com/explore — B2C marketplace (discovery + redirect only)
|
||||
Layer 2: app.RentalDriveGo.com — Company dashboard (private, subscription-gated)
|
||||
Layer 3: {slug}.RentalDriveGo.com — Company branded site (booking + payment here)
|
||||
Layer 4: api.RentalDriveGo.com — REST API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
rental-car-site/
|
||||
├── README.md
|
||||
├── docs/
|
||||
│ ├── ARCHITECTURE.md ← Payment providers, marketplace redirect model, photo flow
|
||||
│ ├── DESIGN_SYSTEM.md
|
||||
│ ├── PAGES.md ← All pages. Marketplace = discovery only. Booking = company site.
|
||||
│ ├── FEATURES.md
|
||||
│ └── DEPLOYMENT.md
|
||||
└── skills/
|
||||
├── rental-car-website/
|
||||
├── rental-car-components/
|
||||
├── rental-car-backend/
|
||||
│ └── references/
|
||||
│ ├── schema.md ← AmanPay/PayPal fields, no Stripe
|
||||
│ ├── api-routes.md ← All routes
|
||||
│ ├── payment-service.md ← AmanPay + PayPal implementation ← KEY FILE
|
||||
│ ├── subscription-service.md ← Manual renewal, plan prices in MAD/USD/EUR
|
||||
│ ├── subdomain-service.md ← Marketplace redirect + company site
|
||||
│ └── notification-service.md ← All 5 channels
|
||||
└── rental-car-i18n/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Tech Stack
|
||||
|
||||
| Concern | Technology |
|
||||
|---------|-----------|
|
||||
| Subscription payment | AmanPay (primary) + PayPal (secondary) |
|
||||
| Rental payment | Company's own AmanPay merchant + PayPal |
|
||||
| PDF generation | `@react-pdf/renderer` — server-side, on-demand, never stored |
|
||||
| Damage diagrams | SVG top-down car map (22 zones, React interactive + PDF static render) |
|
||||
| Insurance | Per-company configurable policies with per-day/flat/% charge types |
|
||||
| Pricing rules | Age/experience-based surcharges & discounts (configurable per company) |
|
||||
| Email | Resend |
|
||||
| SMS + WhatsApp | Twilio |
|
||||
| Push | Firebase FCM |
|
||||
| Real-time in-app | Socket.io + Redis |
|
||||
| Images | Cloudinary |
|
||||
| Company auth | Clerk |
|
||||
| Renter auth | JWT (custom) |
|
||||
| API | Node.js + Express + Prisma + PostgreSQL |
|
||||
| Frontend | Next.js 14 + React 18 + Vite |
|
||||
| Deployment | Vercel + Railway |
|
||||
@@ -0,0 +1,160 @@
|
||||
# Security Hardening Application Report
|
||||
|
||||
Generated: 2026-06-09
|
||||
Project: RentalDriveGo / Car Management System
|
||||
Input archive: `/mnt/data/car_management_system_plan_applied(1).zip`
|
||||
Plan applied: `/mnt/data/SECURITY_HARDENING_IMPLEMENTATION_PLAN(1).md`
|
||||
|
||||
## Executive result
|
||||
|
||||
The uploaded project was inspected and the security hardening plan was applied as far as safely possible inside the source archive. The archive already contained a broad previous hardening pass. I did not blindly trust that state, because that is how software becomes an expensive apology letter. I re-audited the implementation against the plan and applied additional corrections where the code still contradicted the target security model.
|
||||
|
||||
The final output includes a patched project archive, this report, a changed-file list, and an incremental diff for the additional changes made during this pass.
|
||||
|
||||
## What was already present in the uploaded project
|
||||
|
||||
The project already contained many of the plan-aligned building blocks:
|
||||
|
||||
- Centralized JWT helpers for actor tokens.
|
||||
- HttpOnly session cookie helpers for admin, employee, and renter sessions.
|
||||
- Company authorization policy helpers.
|
||||
- Admin 2FA and fresh-2FA enforcement middleware.
|
||||
- Public booking access-token helpers.
|
||||
- Webhook idempotency helpers.
|
||||
- Hashed `CompanyApiKey` model and migration.
|
||||
- `ReservationPublicAccess` model and migration.
|
||||
- `WebhookEvent` model and migration.
|
||||
- Upload validation helpers and public/private storage separation.
|
||||
- Request ID and sanitized error response middleware.
|
||||
- Production Docker/Traefik hardening, including Redis authentication and `x-middleware-subrequest` blocking.
|
||||
- Static security scan script and CI security gates.
|
||||
|
||||
That base work was useful, but it had a few security and test-consistency gaps.
|
||||
|
||||
## Additional changes applied in this pass
|
||||
|
||||
### 1. Removed legacy plaintext company API key storage
|
||||
|
||||
The plan requires company API keys to be hash-only. The code had introduced `CompanyApiKey`, but the legacy `Company.apiKey` field still existed in the Prisma schema and middleware still allowed a fallback lookup against that plaintext field when `ALLOW_LEGACY_COMPANY_API_KEYS=true`.
|
||||
|
||||
Changes applied:
|
||||
|
||||
- Removed `Company.apiKey` from `packages/database/prisma/schema.prisma`.
|
||||
- Removed `apiKey` from the local `Company` TypeScript interface in `packages/database/src/index.ts` and `packages/database/src/index.d.ts`.
|
||||
- Removed the legacy plaintext fallback path from `apps/api/src/middleware/requireApiKey.ts`.
|
||||
- Added migration `20260609233000_drop_legacy_company_api_key` to drop the old column and unique index.
|
||||
- Rewrote `requireApiKey` tests around prefix lookup, hash comparison, revocation, and `lastUsedAt` updates.
|
||||
|
||||
Security effect: raw company API keys are no longer accepted through the legacy company column and are no longer represented in the current Prisma schema.
|
||||
|
||||
### 2. Centralized Socket.io token verification
|
||||
|
||||
The main API process still verified Socket.io auth tokens directly with `jwt.verify(token, JWT_SECRET)` and did not enforce issuer, audience, actor type, or allowed algorithm. This contradicted the session-authentication phase of the plan.
|
||||
|
||||
Changes applied:
|
||||
|
||||
- Added `verifyAnyActorToken()` to `apps/api/src/security/tokens.ts`.
|
||||
- Updated `apps/api/src/index.ts` to use centralized actor-token verification for Socket.io authentication.
|
||||
- Removed the direct `jsonwebtoken` import from the main API entrypoint.
|
||||
|
||||
Security effect: Socket.io no longer accepts tokens that bypass the centralized actor-token constraints.
|
||||
|
||||
### 3. Hardened employee password-reset JWT verification
|
||||
|
||||
Employee password-reset tokens were signed and verified directly with the JWT secret and no issuer, audience, or algorithm constraints.
|
||||
|
||||
Changes applied:
|
||||
|
||||
- Added explicit `HS256` signing for employee password-reset tokens.
|
||||
- Added issuer `rentaldrivego-api`.
|
||||
- Added audience `employee_password_reset`.
|
||||
- Added matching verification constraints.
|
||||
|
||||
Security effect: reset tokens now reject wrong issuer, wrong audience, or wrong algorithm instead of relying on a bare shared-secret verification.
|
||||
|
||||
### 4. Repaired test fixtures and middleware tests
|
||||
|
||||
Some tests still expected pre-hardening behavior. That is a bad smell: tests defending old weaknesses are basically tiny lobbyists for future incidents.
|
||||
|
||||
Changes applied:
|
||||
|
||||
- Updated API-key middleware tests to validate hashed-key behavior instead of plaintext `Company.apiKey` lookup.
|
||||
- Updated auth middleware tests to match the centralized token verifier behavior.
|
||||
- Updated integration test helper token generation to use `signActorToken()` so generated test tokens include issuer and audience.
|
||||
|
||||
Security effect: future test runs are less likely to push developers back toward weaker auth behavior.
|
||||
|
||||
## Verification performed in this sandbox
|
||||
|
||||
| Check | Result | Notes |
|
||||
|---|---:|---|
|
||||
| Static security scan | PASS | `npm run security:static` completed successfully. |
|
||||
| Critical production dependency audit | PASS | `npm audit --package-lock-only --omit=dev --audit-level=critical` exited successfully. |
|
||||
| JSON syntax check | PASS | Root and app `package.json` files and lockfile parsed successfully. |
|
||||
| Shell syntax check | PASS | Shell scripts and production entrypoint parsed with `bash -n`. |
|
||||
| YAML parse check | PASS | `docker-compose.production.yml` and `.gitlab-ci.yml` parsed successfully. |
|
||||
| Node script syntax | PASS | `scripts/security-static-check.mjs` passed `node --check`. |
|
||||
| Full dependency install | NOT COMPLETED | `npm ci --ignore-scripts --prefer-offline` could not complete in this sandbox. |
|
||||
| Full type-check/test/build | NOT RUN | Requires dependencies to be installed. Run in CI or a normal development environment. |
|
||||
| Docker compose config/render | NOT RUN | Docker is unavailable in this sandbox. |
|
||||
| Prisma generate/migrate | NOT RUN | Requires dependency installation and a normal Prisma/DB environment. |
|
||||
|
||||
## Dependency audit note
|
||||
|
||||
The critical audit gate passes. The audit still reports moderate findings involving `postcss` through Next.js and `uuid` through Firebase/cron-related dependency chains. The lockfile’s suggested fixes require forced or breaking upgrades, so I did not casually smash the dependency graph with a hammer and call the mess “security.” Those should be handled in a controlled dependency-upgrade ticket with full frontend and notification regression testing.
|
||||
|
||||
## Files changed by this pass
|
||||
|
||||
- `apps/api/src/index.ts`
|
||||
- `apps/api/src/middleware/requireApiKey.ts`
|
||||
- `apps/api/src/middleware/requireApiKey.test.ts`
|
||||
- `apps/api/src/middleware/requireCompanyAuth.test.ts`
|
||||
- `apps/api/src/middleware/requireRenterAuth.test.ts`
|
||||
- `apps/api/src/modules/auth/auth.employee.service.ts`
|
||||
- `apps/api/src/security/tokens.ts`
|
||||
- `apps/api/src/tests/helpers/fixtures.ts`
|
||||
- `packages/database/prisma/schema.prisma`
|
||||
- `packages/database/prisma/migrations/20260609233000_drop_legacy_company_api_key/migration.sql`
|
||||
- `packages/database/src/index.ts`
|
||||
- `packages/database/src/index.d.ts`
|
||||
|
||||
See also:
|
||||
|
||||
- `security_hardening_incremental.diff`
|
||||
- `security_hardening_changed_files.txt`
|
||||
|
||||
## Phase status against the hardening plan
|
||||
|
||||
| Phase | Status | Evidence / caveat |
|
||||
|---|---:|---|
|
||||
| Phase 0: Emergency stabilization | Partial / needs operator action | Static scan passes, placeholders are present, but real production secret rotation cannot be performed inside the archive. |
|
||||
| Phase 1: Sessions and authentication | Substantially applied | HttpOnly session helpers and centralized actor JWT verification exist; Socket.io and reset-token gaps were corrected in this pass. |
|
||||
| Phase 2: Authorization and tenant isolation | Substantially applied | Company policy middleware exists; tenant-safe patterns are present in many modules. Full proof requires test suite and code review across all repository methods. |
|
||||
| Phase 3: Public booking privacy | Applied at source level | Public access token model/helper and safe public booking flow are present. Must be verified with integration tests. |
|
||||
| Phase 4: Admin hardening | Applied at source level | Mandatory 2FA and fresh-2FA middleware are present. Enrollment and recovery-code workflows still need production validation. |
|
||||
| Phase 5: API key hardening | Strengthened in this pass | Legacy plaintext company API key storage/fallback removed; hash-only `CompanyApiKey` path remains. |
|
||||
| Phase 6: Payments and webhooks | Applied at source level | Raw-body webhook handling and idempotency helpers are present. Must be verified against provider test events. |
|
||||
| Phase 7: Upload and storage hardening | Applied at source level | Magic-byte validation and public/private storage split are present. Must be verified with upload abuse tests. |
|
||||
| Phase 8: Rate limiting, errors, browser security | Applied at source level | Request IDs, sanitized errors, rate limit middleware, and security headers are present. Must be verified in deployed environment. |
|
||||
| Phase 9: Deployment hardening | Applied at config level | Redis auth, non-public DB tooling, private networks, and Traefik header blocking are present. Must be verified on the real host. |
|
||||
| Phase 10: Observability, auditability, jobs | Partial | Logging/audit hooks exist, but queue migration and operational observability need dedicated validation. |
|
||||
| CI/CD security gates | Applied at config level | Security scan and critical audit gates exist; full CI must run outside this sandbox. |
|
||||
|
||||
## Required follow-up before launch
|
||||
|
||||
1. Rotate all real production secrets and invalidate old sessions/API keys where appropriate.
|
||||
2. Run `npm ci` in CI or a normal development environment.
|
||||
3. Run `npm run db:generate` and apply the new migration after backup.
|
||||
4. Run full type-check, unit tests, integration tests, e2e tests, and builds.
|
||||
5. Run payment-provider webhook test events using real sandbox provider signatures.
|
||||
6. Verify public/private storage behavior with real uploaded files.
|
||||
7. Verify Redis and PostgreSQL are not externally reachable from the production host.
|
||||
8. Run container image build and Trivy scan.
|
||||
9. Confirm admin 2FA enrollment and fresh-2FA gates before enabling privileged admin actions in production.
|
||||
10. Document any deferrals with owner, risk acceptance, compensating control, deadline, and ticket number.
|
||||
|
||||
## Launch recommendation
|
||||
|
||||
Do not launch publicly yet based only on the patched archive. The source now better matches the plan, and the critical static/audit checks pass, but the hard launch gate still depends on full CI, Prisma migration validation, deployment verification, and production secret rotation.
|
||||
|
||||
The patched archive is suitable for the next CI/staging pass. Treat it as implementation-ready source, not production clearance. Production clearance comes from reproducible evidence, not vibes in a ZIP file.
|
||||
@@ -0,0 +1,255 @@
|
||||
# Security Hardening Leftover Application Report
|
||||
|
||||
Project: RentalDriveGo / Car Management System
|
||||
Input archive: `car_management_system_hardened_applied.zip`
|
||||
Output archive: `car_management_system_leftover_applied.zip`
|
||||
Date: 2026-06-09
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This pass applied the remaining source-level hardening gaps that were still practical to implement directly in the repository after the first hardening pass. The focus was on eliminating browser-readable authentication assumptions, enforcing app-layer blocking for the `x-middleware-subrequest` bypass class, improving actor-aware rate limiting, adding an admin 2FA recovery-code workflow, and bringing documentation/static checks into line with the hardened authentication model.
|
||||
|
||||
This does not replace production operator work such as real secret rotation, live infrastructure verification, container scanning, applying migrations to a real database, or running the complete CI/test pipeline. Those items remain launch-gate evidence requirements.
|
||||
|
||||
## Applied Changes
|
||||
|
||||
### 1. Removed remaining browser-side employee token assumptions
|
||||
|
||||
Changed files:
|
||||
|
||||
- `apps/dashboard/src/lib/api.ts`
|
||||
- `apps/dashboard/src/components/layout/TopBar.tsx`
|
||||
- `apps/dashboard/src/components/layout/Sidebar.tsx`
|
||||
- `apps/dashboard/src/app/(dashboard)/team/page.tsx`
|
||||
- `apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx`
|
||||
|
||||
What changed:
|
||||
|
||||
- Removed dashboard use of employee auth tokens from `localStorage`.
|
||||
- Dashboard API calls now use `credentials: 'include'` and depend on HttpOnly cookies.
|
||||
- Dashboard Socket.io connection now uses cookie credentials instead of script-provided auth tokens.
|
||||
- Team page no longer decodes employee identity from a localStorage JWT. It resolves the current actor through `/auth/employee/me`.
|
||||
- Admin 2FA sign-in form now accepts either a six-digit TOTP code or a recovery code value.
|
||||
|
||||
Security effect:
|
||||
|
||||
- Reduces script-readable authentication exposure.
|
||||
- Aligns the dashboard with the intended HttpOnly session-cookie model.
|
||||
- Prevents UI code from treating a readable JWT as the authority for employee identity.
|
||||
|
||||
### 2. Added Socket.io HttpOnly-cookie session support
|
||||
|
||||
Changed file:
|
||||
|
||||
- `apps/api/src/index.ts`
|
||||
|
||||
What changed:
|
||||
|
||||
- Added Socket.io session-token extraction from HttpOnly cookies.
|
||||
- Preserved explicit token verification for trusted non-browser/server contexts, while browser clients can now authenticate through cookies.
|
||||
- Reused centralized actor-token verification.
|
||||
|
||||
Security effect:
|
||||
|
||||
- Real-time dashboard connections no longer require JavaScript-readable employee tokens.
|
||||
- Socket authentication now follows the same actor-token validation path used elsewhere.
|
||||
|
||||
### 3. Added app-layer `x-middleware-subrequest` blocking
|
||||
|
||||
Changed files:
|
||||
|
||||
- `apps/api/src/app.ts`
|
||||
- `apps/dashboard/src/middleware.ts`
|
||||
- `apps/marketplace/src/middleware.ts`
|
||||
- `apps/admin/src/middleware.ts`
|
||||
- `apps/dashboard/src/middleware.test.ts`
|
||||
- `apps/marketplace/src/middleware.test.ts`
|
||||
|
||||
What changed:
|
||||
|
||||
- API now rejects requests containing `x-middleware-subrequest` before route handling.
|
||||
- Dashboard, marketplace, and admin Next middleware now reject the same header at the app layer.
|
||||
- Added/updated middleware tests for the rejection path.
|
||||
|
||||
Security effect:
|
||||
|
||||
- Adds defense in depth beyond reverse-proxy filtering.
|
||||
- Prevents the project from depending on a single infrastructure control for this bypass class.
|
||||
|
||||
### 4. Hardened admin/browser fetch behavior
|
||||
|
||||
Changed files include:
|
||||
|
||||
- `apps/admin/src/lib/api.ts`
|
||||
- `apps/admin/src/app/dashboard/admin-users/page.tsx`
|
||||
- `apps/admin/src/app/dashboard/renters/page.tsx`
|
||||
- `apps/admin/src/app/dashboard/companies/[id]/page.tsx`
|
||||
- `apps/admin/src/app/dashboard/containers/page.tsx`
|
||||
- `apps/admin/src/app/dashboard/pricing/page.tsx`
|
||||
- `apps/admin/src/app/forgot-password/page.tsx`
|
||||
- `apps/admin/src/app/reset-password/page.tsx`
|
||||
|
||||
What changed:
|
||||
|
||||
- Admin API wrapper uses `credentials: 'include'`.
|
||||
- Manual admin fetch calls now include credentials where they directly call the admin API.
|
||||
- Removed dead placeholder `getToken()` helpers that returned empty strings and created meaningless `Authorization: Bearer ` headers.
|
||||
|
||||
Security effect:
|
||||
|
||||
- Admin browser requests now consistently rely on the HttpOnly admin session cookie.
|
||||
- Removes misleading bearer-token scaffolding from the admin UI.
|
||||
|
||||
### 5. Improved actor-aware rate limiting
|
||||
|
||||
Changed files:
|
||||
|
||||
- `apps/api/src/middleware/rateLimiter.ts`
|
||||
- `apps/api/src/app.ts`
|
||||
|
||||
What changed:
|
||||
|
||||
- API rate-limit keys now prefer a verified actor identity from session cookies or valid Bearer tokens.
|
||||
- Actor-aware rate limiting falls back safely to existing request actor fields or anonymous IP keys.
|
||||
- Admin authentication routes now receive the stricter authentication limiter before the broader admin limiter.
|
||||
|
||||
Security effect:
|
||||
|
||||
- Authenticated traffic is limited by actor identity instead of only coarse IP data.
|
||||
- Login and admin-auth abuse get stricter protection.
|
||||
- Multi-container correctness still depends on Redis availability/configuration in production, which must be verified during deployment.
|
||||
|
||||
### 6. Added admin 2FA recovery-code backend workflow
|
||||
|
||||
Changed files:
|
||||
|
||||
- `packages/database/prisma/schema.prisma`
|
||||
- `packages/database/prisma/migrations/20260610001500_add_admin_recovery_codes/migration.sql`
|
||||
- `apps/api/src/modules/admin/admin.repo.ts`
|
||||
- `apps/api/src/modules/admin/admin.service.ts`
|
||||
- `apps/api/src/modules/admin/admin.schemas.ts`
|
||||
- `apps/api/src/modules/admin/admin.routes.ts`
|
||||
|
||||
What changed:
|
||||
|
||||
- Added `AdminRecoveryCode` model.
|
||||
- Recovery codes are stored as bcrypt hashes, not plaintext.
|
||||
- TOTP enrollment now issues one-time recovery codes.
|
||||
- Recovery-code regeneration is protected by authenticated admin access and fresh 2FA.
|
||||
- Login can consume a valid unused recovery code when TOTP is enabled.
|
||||
- Recovery-code issuance and use are audited.
|
||||
|
||||
Security effect:
|
||||
|
||||
- Adds a recovery path for mandatory admin 2FA without storing backup codes in plaintext.
|
||||
- Preserves one-time-use semantics.
|
||||
- Adds auditability for recovery-code lifecycle events.
|
||||
|
||||
Limitation:
|
||||
|
||||
- The backend returns recovery codes after enrollment/regeneration. A production-ready admin UI still needs to display them once with clear save instructions and must not persist them client-side.
|
||||
|
||||
### 7. Strengthened static scanning for auth-token regressions
|
||||
|
||||
Changed file:
|
||||
|
||||
- `scripts/security-static-check.mjs`
|
||||
|
||||
What changed:
|
||||
|
||||
- Static scan now catches common regressions involving auth/session token names in `localStorage` and `document.cookie`.
|
||||
- Scan specifically targets auth token/session names such as employee/admin/renter tokens and sessions.
|
||||
|
||||
Security effect:
|
||||
|
||||
- Makes it harder for future code changes to quietly reintroduce script-readable authentication tokens.
|
||||
|
||||
### 8. Updated documentation to match the hardened model
|
||||
|
||||
Changed files:
|
||||
|
||||
- `apps/dashboard/README.md`
|
||||
- `memory/project_auth_architecture.md`
|
||||
- `docs/project-design/COOKIE_POLICY.md`
|
||||
- `apps/api/src/swagger/openapi.ts`
|
||||
|
||||
What changed:
|
||||
|
||||
- Replaced stale localStorage/JWT handoff claims with HttpOnly session-cookie wording.
|
||||
- Clarified that browser clients use HttpOnly sessions and that Bearer tokens are only for documented trusted server/mobile contexts.
|
||||
- Updated cookie policy references from legacy `employee_token` wording to `employee_session`.
|
||||
|
||||
Security effect:
|
||||
|
||||
- Reduces the odds that a future developer follows stale documentation and reintroduces the old pattern.
|
||||
|
||||
## Validation Performed
|
||||
|
||||
The following checks were run successfully in the available environment:
|
||||
|
||||
```bash
|
||||
npm run security:static
|
||||
node --check scripts/security-static-check.mjs
|
||||
# JSON parse validation for package.json and package-lock.json files
|
||||
# YAML parse validation for docker-compose.production.yml and .gitlab-ci.yml
|
||||
bash -n docker/entrypoint.production.sh scripts/docker-prod-*.sh scripts/docker-registry-local-up.sh scripts/setup-clerk-keys.sh
|
||||
# TypeScript/TSX syntax transpile check over 507 source files
|
||||
npm audit --package-lock-only --omit=dev --audit-level=critical
|
||||
```
|
||||
|
||||
Results:
|
||||
|
||||
- Security static check: passed.
|
||||
- Static-check script syntax: passed.
|
||||
- Package JSON validation: passed.
|
||||
- YAML validation: passed.
|
||||
- Shell syntax validation: passed.
|
||||
- TypeScript/TSX syntax transpile validation: passed.
|
||||
- Critical production dependency audit: passed.
|
||||
|
||||
Audit note:
|
||||
|
||||
- `npm audit --audit-level=critical` exited successfully.
|
||||
- Moderate advisories remain for transitive `postcss` and `uuid` paths. The available automatic fixes require breaking/force dependency changes, so they were not applied blindly in this source pass.
|
||||
|
||||
## Not Fully Verified in This Environment
|
||||
|
||||
The following items require a real development/CI/deployment environment:
|
||||
|
||||
- `npm ci` from a clean checkout.
|
||||
- Full workspace typecheck.
|
||||
- Full unit, integration, security, and e2e test suites.
|
||||
- Prisma client generation.
|
||||
- Applying the new database migration to a real database.
|
||||
- Database backup/restore verification.
|
||||
- Docker image build and runtime validation.
|
||||
- Container scanning with Trivy or equivalent.
|
||||
- Live reverse-proxy validation for `x-middleware-subrequest` blocking.
|
||||
- Redis-backed distributed rate-limit validation across multiple API containers.
|
||||
- Provider webhook sandbox tests.
|
||||
- Live admin 2FA recovery-code UX verification.
|
||||
|
||||
## Remaining Launch-Gate Work
|
||||
|
||||
These are still not things source edits can prove by themselves:
|
||||
|
||||
1. Rotate all real production secrets.
|
||||
2. Confirm no real secrets exist in repository history or image layers.
|
||||
3. Apply and verify the new `admin_recovery_codes` migration.
|
||||
4. Run the full CI gate: lint, typecheck, unit tests, integration tests, security tests, build, dependency audit, secret scan, and container scan.
|
||||
5. Confirm Redis and PostgreSQL are private in production.
|
||||
6. Confirm DB management tools are not publicly reachable.
|
||||
7. Confirm production containers run non-root with reduced capabilities and resource limits.
|
||||
8. Confirm webhook signature/idempotency behavior against real provider sandbox payloads.
|
||||
9. Confirm private files are inaccessible through static routes in the deployed environment.
|
||||
10. Confirm the admin UI displays recovery codes once and instructs admins to save them securely.
|
||||
|
||||
## Changed Files
|
||||
|
||||
See `security_hardening_leftover_changed_files.txt` for the complete file list and `security_hardening_leftover.diff` for the unified diff.
|
||||
|
||||
## Final Assessment
|
||||
|
||||
This pass closes a meaningful set of leftover source-level gaps from the security-hardening plan. The project is closer to the intended model: API-enforced security, HttpOnly browser sessions, stronger admin 2FA recovery, app-layer bypass blocking, and less stale documentation.
|
||||
|
||||
However, this still should not be treated as production-ready until the remaining launch-gate evidence is collected from CI and the live deployment environment. Security that has not been tested in the actual runtime is mostly optimism with a lanyard.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Strong Grading Implementation Notes
|
||||
|
||||
## Safety rule
|
||||
|
||||
This patch is additive and backward-compatible. Existing historical semester scores remain displayed from stored `semester_scores` values and are labeled as legacy by metadata. The new strong-grading machinery is present, but strong-mode finalization only activates when `strong_grading_enabled` is enabled through configuration.
|
||||
|
||||
## What changed
|
||||
|
||||
### Legacy display protection
|
||||
|
||||
`semester_scores` now supports:
|
||||
|
||||
- `calculation_mode`
|
||||
- `calculation_policy_version`
|
||||
- `snapshot_id`
|
||||
|
||||
Existing rows are backfilled as:
|
||||
|
||||
- `calculation_mode = legacy`
|
||||
- `calculation_policy_version = legacy_v1`
|
||||
|
||||
No historical score is recalculated by this migration.
|
||||
|
||||
### Score safety columns
|
||||
|
||||
The score tables now support:
|
||||
|
||||
- `max_points`
|
||||
- `status`
|
||||
- `excused_reason`
|
||||
- `locked_at`
|
||||
- `locked_by`
|
||||
|
||||
Existing numeric score rows are marked `scored`. Existing blank rows are marked `pending`, not `missing`, so old data is not punished by the new policy.
|
||||
|
||||
### Validation
|
||||
|
||||
A central `ScoreValueValidator` rejects impossible scores before storage. The default max remains `100` to preserve old behavior until item-level max points are configured.
|
||||
|
||||
### Attendance grace
|
||||
|
||||
Attendance now names the one-absence grace policy explicitly instead of hiding it in `(total_days + 1 - absences) / total_days`. The result is intentionally equivalent.
|
||||
|
||||
### Strong-mode lock validation
|
||||
|
||||
`GradingLockService` now checks the policy mode before locking. In legacy mode, behavior remains compatible. In strong mode, pending scores and invalid score ranges block locking.
|
||||
|
||||
### Snapshot infrastructure
|
||||
|
||||
`semester_score_snapshots` and `SemesterScoreSnapshotService` were added. Strong-mode finalized scores can store calculation inputs and outputs for auditability.
|
||||
|
||||
### Display resolver
|
||||
|
||||
`GradeCalculationDisplayResolver` resolves whether a row should be shown as `Legacy Calculation` or `Strong Calculation`.
|
||||
|
||||
## Configuration gates
|
||||
|
||||
Strong mode is off by default.
|
||||
|
||||
Suggested configuration values:
|
||||
|
||||
```text
|
||||
strong_grading_enabled = false
|
||||
strong_grading_class_sections = *
|
||||
```
|
||||
|
||||
When enabled:
|
||||
|
||||
```text
|
||||
strong_grading_enabled = true
|
||||
strong_grading_class_sections = 12,15,20
|
||||
```
|
||||
|
||||
or all sections:
|
||||
|
||||
```text
|
||||
strong_grading_class_sections = *
|
||||
```
|
||||
|
||||
## What is intentionally not done yet
|
||||
|
||||
This patch does not globally replace the legacy PTAP formula. That would change grade math. The legacy formula remains the default until strong grading is intentionally activated for a class section.
|
||||
|
||||
This patch does not silently recalculate historical records. That would be reckless, so naturally we avoided it.
|
||||
|
||||
## Verification
|
||||
|
||||
PHP syntax checks passed for the modified and new PHP files.
|
||||
|
||||
PHPUnit could not run in this sandbox because the PHP runtime is missing required extensions: `dom`, `mbstring`, `xml`, and `xmlwriter`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,126 +0,0 @@
|
||||
# `AdministratorPromotionController` plan
|
||||
|
||||
## Scope
|
||||
- **Controller**: `app/Http/Controllers/Api/Administrator/AdministratorPromotionController.php`
|
||||
- **Purpose**: admin/registrar API to manage promotion records and parent enrollment lifecycle.
|
||||
- **Primary dependencies**:
|
||||
- Query/detail: `PromotionQueryService`
|
||||
- Eligibility evaluation: `PromotionEligibilityService`
|
||||
- Status transitions: `PromotionStatusService`
|
||||
- Parent enrollment workflow: `PromotionEnrollmentService`
|
||||
- Reminders: `PromotionReminderService`
|
||||
- Auditing: `PromotionAuditService`
|
||||
- Level mapping: `LevelProgressionService`
|
||||
- **Models**:
|
||||
- `StudentPromotionRecord`
|
||||
- `PromotionAuditLog`
|
||||
- `PromotionReminderLog`
|
||||
|
||||
## Routes
|
||||
All routes are under:
|
||||
- Base prefix: `/api/v1/administrator/promotions`
|
||||
- Middleware: `auth:api`, `admin.access`
|
||||
|
||||
Endpoints:
|
||||
- Listing & detail
|
||||
- `GET /api/v1/administrator/promotions/` → `index`
|
||||
- `GET /api/v1/administrator/promotions/summary` → `summary`
|
||||
- `GET /api/v1/administrator/promotions/{promotionId}` → `show`
|
||||
- Eligibility & status
|
||||
- `POST /api/v1/administrator/promotions/evaluate` → `evaluate`
|
||||
- `PATCH /api/v1/administrator/promotions/{promotionId}/status` → `updateStatus`
|
||||
- Deadlines
|
||||
- `POST /api/v1/administrator/promotions/deadlines` → `setDeadline` (bulk/single via body)
|
||||
- `POST /api/v1/administrator/promotions/{promotionId}/deadline` → `setDeadline` (single)
|
||||
- `POST /api/v1/administrator/promotions/deadlines/expire` → `expireDeadlines`
|
||||
- Enrollment steps (admin override)
|
||||
- `PATCH /api/v1/administrator/promotions/{promotionId}/enrollment-steps` → `adminCompleteEnrollment`
|
||||
- Reminders & audit
|
||||
- `POST /api/v1/administrator/promotions/{promotionId}/reminders` → `sendReminder`
|
||||
- `GET /api/v1/administrator/promotions/{promotionId}/reminders` → `reminders`
|
||||
- `POST /api/v1/administrator/promotions/reminders/dispatch` → `dispatchScheduledReminders`
|
||||
- `GET /api/v1/administrator/promotions/{promotionId}/audit` → `audit`
|
||||
- Level progressions (mapping current level → promoted level)
|
||||
- `GET /api/v1/administrator/promotions/levels/` → `levelProgressions`
|
||||
- `POST /api/v1/administrator/promotions/levels/` → `upsertLevelProgression`
|
||||
- `POST /api/v1/administrator/promotions/levels/seed` → `seedLevelProgressions`
|
||||
|
||||
## Authentication & authorization
|
||||
- Admin access enforced by route middleware.
|
||||
- `getCurrentUserId()` used for attribution in audit logs and updates.
|
||||
|
||||
## Requests/validation
|
||||
Validated via `FormRequest` classes:
|
||||
- `index` / `summary`: `AdminListPromotionsRequest` (`filters()`)
|
||||
- `evaluate`: `EvaluateEligibilityRequest` (supports scope: `student`, `class_section`, `school_year`)
|
||||
- `updateStatus`: `UpdatePromotionStatusRequest` (supports `force` + `notes`)
|
||||
- `setDeadline`: `SetEnrollmentDeadlineRequest` (supports `apply_to` modes and optional `promotion_ids`)
|
||||
- `sendReminder`: `SendReminderRequest` (type, subject/message, channels)
|
||||
- `adminCompleteEnrollment`: `ParentEnrollmentStepRequest` (`steps()`)
|
||||
- `upsertLevelProgression`: `UpsertLevelProgressionRequest`
|
||||
|
||||
## Response shapes
|
||||
This controller uses legacy `{ ok: ... }` shapes (not Base API envelope).
|
||||
|
||||
- `index`:
|
||||
- `{ ok:true, data:[StudentPromotionResource...], pagination:{page, per_page, total, total_pages}, counts_by_status }`
|
||||
- `show`:
|
||||
- `{ ok:true, data: StudentPromotionResource }`
|
||||
- `summary`:
|
||||
- `{ ok:true, counts_by_status, total }`
|
||||
- `evaluate`:
|
||||
- `{ ok:true, result:{ scope, ... } }`
|
||||
- `updateStatus`:
|
||||
- `{ ok:true, data: StudentPromotionResource }`
|
||||
- Errors:
|
||||
- 422 for invalid args
|
||||
- 409 for transition conflicts
|
||||
- `setDeadline`:
|
||||
- `{ ok:true, updated, promotion_ids:[...] }`
|
||||
- `sendReminder`:
|
||||
- `{ ok:true, reminder: PromotionReminderResource }`
|
||||
- `dispatchScheduledReminders` / `expireDeadlines`:
|
||||
- `{ ok:true, result }`
|
||||
- `reminders`:
|
||||
- `{ ok:true, reminders:[PromotionReminderResource...] }`
|
||||
- `audit`:
|
||||
- `{ ok:true, entries:[PromotionAuditLogResource...] }`
|
||||
- `adminCompleteEnrollment`:
|
||||
- `{ ok:true, data: StudentPromotionResource }` (409 on conflict)
|
||||
- `levelProgressions`:
|
||||
- `{ ok:true, levels:[LevelProgressionResource...] }`
|
||||
- `upsertLevelProgression`:
|
||||
- `{ ok:true, level: LevelProgressionResource }`
|
||||
- `seedLevelProgressions`:
|
||||
- `{ ok:true, created }`
|
||||
|
||||
## Side effects
|
||||
- Deadline updates are wrapped in `DB::transaction` and log audit entries via `PromotionAuditService`.
|
||||
- Reminder sending records `PromotionReminderLog` and may send via multiple channels.
|
||||
- Status transitions update `StudentPromotionRecord` and write audit entries via services.
|
||||
|
||||
## Error handling expectations
|
||||
- `findOrFail()` returns 404 `{ ok:false, message:"Promotion record not found." }`.
|
||||
- `evaluate()` catches unexpected errors and returns 500 with generic message.
|
||||
- `sendReminder()` catches unexpected errors and returns 500 with generic message.
|
||||
|
||||
## Test plan (feature)
|
||||
- AuthZ:
|
||||
- Non-admin users denied by middleware.
|
||||
- List & filters:
|
||||
- Pagination and `counts_by_status` match expected totals.
|
||||
- Evaluate:
|
||||
- Each scope returns expected payload keys; unexpected errors return 500.
|
||||
- Status transitions:
|
||||
- Valid transitions succeed; invalid args return 422; invalid transitions return 409.
|
||||
- Deadlines:
|
||||
- Single record update; bulk update by year; bulk update by list.
|
||||
- Audit log created for each updated record.
|
||||
- Reminders:
|
||||
- Manual reminder creates log and returns resource.
|
||||
- Dispatch scheduled reminders returns summary result.
|
||||
- Enrollment steps:
|
||||
- Admin patch updates steps and returns updated record; conflicts return 409.
|
||||
- Levels:
|
||||
- List returns mappings; upsert creates/updates; seed inserts defaults.
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# `AuthController` plan
|
||||
|
||||
## Scope
|
||||
- **Controller**: `app/Http/Controllers/Api/Auth/AuthController.php`
|
||||
- **Purpose**: JWT login, token refresh, current-user lookup, logout.
|
||||
- **Primary dependencies**:
|
||||
- `App\Services\Auth\ApiLoginSecurityService` (rate limiting / IP blocking, attempt tracking, response builder)
|
||||
- `PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth` (token issuance/refresh/invalidation)
|
||||
- `Illuminate\Support\Facades\Auth` (current user)
|
||||
|
||||
## Routes
|
||||
- **Public (no `/v1`)**
|
||||
- `POST /api/login` → `login`
|
||||
- **Versioned (`/v1`)**
|
||||
- `POST /api/v1/login` → `login`
|
||||
- **Auth prefix (`/v1/auth`)**
|
||||
- `POST /api/v1/auth/login` → `login`
|
||||
- `POST /api/v1/auth/refresh` → `refresh` (middleware: `jwt.auth`)
|
||||
- `POST /api/v1/auth/logout` → `logout` (middleware: `auth:api`)
|
||||
- `GET /api/v1/auth/me` → `me` (middleware: `auth:api`)
|
||||
|
||||
## Authentication & authorization
|
||||
- **`login`**: public; checks credentials, then issues JWT.
|
||||
- **`refresh`**: requires a valid JWT via `jwt.auth`.
|
||||
- **`me`/`logout`**: require `auth:api` (JWT/guarded user).
|
||||
|
||||
## Request/validation expectations
|
||||
- **`login`**:
|
||||
- Accepts JSON or form payload. Extracts `email`, `password`.
|
||||
- Returns `400` if missing.
|
||||
- Normalizes email to lowercase; looks up `User` by case-insensitive email.
|
||||
- **`refresh`**:
|
||||
- Expects bearer token in request (handled by `JWTAuth::parseToken()`).
|
||||
- **`logout`**:
|
||||
- Attempts JWT invalidation when bearer token looks like a JWT (3 dot-separated segments).
|
||||
- Also deletes `currentAccessToken()` when supported (supports Sanctum-style tokens if present).
|
||||
|
||||
## Response shapes
|
||||
- **`login` success**: `ApiLoginSecurityService::buildLoginResponse($user, $jwtToken)` (treat as canonical shape).
|
||||
- **`login` failures**:
|
||||
- `400`: `{ status:false, message:"Email and password are required." }`
|
||||
- `401`: `{ status:false, message:"Invalid email or password." }`
|
||||
- `403`: `{ status:false, message:"Account suspended. Please reset your password." }`
|
||||
- `429`: `{ status:false, message:"Too many failed attempts..." }`
|
||||
- **`refresh` success** (Base API shape):
|
||||
- `{ status:true, message:"Token refreshed.", data:{ access_token, token_type:"bearer", expires_in } }`
|
||||
- **`me` success** (Base API shape):
|
||||
- `{ status:true, message:"OK", data:{ id, firstname, lastname, email, class_section_id, class_section_name } }`
|
||||
- **`logout` success** (Base API shape):
|
||||
- `{ status:true, message:"Logged out.", data:null }`
|
||||
|
||||
## Side effects & security notes
|
||||
- Logs IP attempt / failed attempt / successful login via `ApiLoginSecurityService`.
|
||||
- Suspended users are rejected **after** verifying credentials (avoids email enumeration by response shape).
|
||||
|
||||
## Error handling expectations
|
||||
- `refresh`: any exception becomes `401` via `respondError('Token refresh failed.', 401)`.
|
||||
- `logout`: JWT invalidation errors are intentionally ignored.
|
||||
|
||||
## Test plan (feature)
|
||||
- **Login**
|
||||
- Missing email/password → `400`.
|
||||
- Unknown email → `401` and IP attempt logged.
|
||||
- Wrong password → `401` and failed attempt handling invoked.
|
||||
- Suspended user with correct password → `403`.
|
||||
- Successful login returns expected response shape and includes token.
|
||||
- IP blocked → `429`.
|
||||
- **Refresh**
|
||||
- Valid token refresh returns new token + expires_in.
|
||||
- Invalid/missing token returns `401`.
|
||||
- **Me/Logout**
|
||||
- Requires auth; returns `401` unauthenticated.
|
||||
- Logout returns success even if token invalidation fails.
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# `AuthorizedUserInviteController` plan
|
||||
|
||||
## Scope
|
||||
- **Controller**: `app/Http/Controllers/Api/Parents/AuthorizedUserInviteController.php`
|
||||
- **Purpose**: public invite confirmation + password setup for “authorized users” invited by a parent.
|
||||
- **Primary dependency**: `App\Services\Parents\AuthorizedUsersManagementService`
|
||||
|
||||
## Routes
|
||||
- `GET /api/confirm_authorized_user?token=...` → `confirm`
|
||||
- `GET /api/set_authorized_user_password/{authorizedUserId}?token=...` → `setPasswordForm`
|
||||
- `POST /api/set_authorized_user_password/{authorizedUserId}` → `savePassword`
|
||||
- Path constraint: `{authorizedUserId}` is numeric
|
||||
|
||||
## Authentication & authorization
|
||||
- These endpoints are **public** and rely on **invite tokens** for authorization.
|
||||
- Redirects are protected via an allowlist check (`isTrustedRedirectUrl()`).
|
||||
|
||||
## Endpoint behavior
|
||||
### `confirm`
|
||||
- Input:
|
||||
- Query param `token` (string)
|
||||
- Calls `AuthorizedUsersManagementService::confirmEmailClick($token)` and expects `redirect_url`.
|
||||
- Output:
|
||||
- If request expects JSON: `{ message:"Confirmed.", redirect_url }`
|
||||
- Otherwise redirects the browser to `redirect_url` via `redirect()->away(...)`
|
||||
- Errors:
|
||||
- Invalid token → `400` (JSON or minimal HTML page)
|
||||
- Untrusted redirect URL → `400` JSON `{ message:"Invalid redirect URL." }`
|
||||
|
||||
### `setPasswordForm`
|
||||
- Input:
|
||||
- Route param `authorizedUserId`
|
||||
- Query param `token`
|
||||
- Calls `AuthorizedUsersManagementService::findActiveSetupRow($authorizedUserId, $token)`.
|
||||
- Output:
|
||||
- JSON mode: `{ message:"OK", user_id, token }`
|
||||
- HTML mode: instructional HTML (no actual password form UI)
|
||||
- Errors:
|
||||
- Invalid/expired link → `400` (JSON or plain text)
|
||||
|
||||
### `savePassword`
|
||||
- Input (JSON or form):
|
||||
- `password` (required; min 8; must include upper/lower/number/special)
|
||||
- `password_confirmation` or `password_confirm` (one required; must match `password`)
|
||||
- `user_id` (required int) — expected to match the authorized user’s owning parent id
|
||||
- `token` (required string)
|
||||
- Calls `AuthorizedUsersManagementService::setAuthorizedUserPassword(...)`.
|
||||
- Output:
|
||||
- Success: `{ message:"Password has been successfully set." }`
|
||||
- Errors:
|
||||
- Validation → `422` with `{ message, errors }`
|
||||
- Invalid token / mismatch / expired setup row → `400` `{ message }`
|
||||
|
||||
## Security notes
|
||||
- **Redirect allowlist**: only hosts matching `app.url`, `app.frontend_url`, or `services.frontend.url`.
|
||||
- **Password policy** is enforced at the API level via regex rules.
|
||||
|
||||
## Test plan (feature)
|
||||
- `confirm`
|
||||
- Invalid token → 400 JSON/HTML depending on `Accept`.
|
||||
- Valid token but redirect host not allowlisted → 400.
|
||||
- Valid token → JSON includes redirect_url; non-JSON issues redirect.
|
||||
- `setPasswordForm`
|
||||
- Invalid token/id → 400.
|
||||
- Valid token/id → returns JSON payload or HTML string.
|
||||
- `savePassword`
|
||||
- Weak password / missing confirm → 422.
|
||||
- Token mismatch/expired → 400.
|
||||
- Success → 200 and password is set (can authenticate as that authorized user).
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
# `AuthorizedUsersController` plan
|
||||
|
||||
## Scope
|
||||
- **Controller**: `app/Http/Controllers/Api/Parents/AuthorizedUsersController.php`
|
||||
- **Purpose**: parent CRUD for secondary “authorized users” attached to the parent account.
|
||||
- **Primary dependency**: `App\Services\Parents\AuthorizedUsersManagementService`
|
||||
- **Model**: `App\Models\AuthorizedUser`
|
||||
|
||||
## Routes
|
||||
All routes are under `v1` + `parents` group:
|
||||
- Base prefix: `GET/POST/PATCH/DELETE /api/v1/parents/...`
|
||||
- Group middleware: `parent.access`
|
||||
|
||||
Endpoints:
|
||||
- `GET /api/v1/parents/authorized-users` → `index`
|
||||
- `GET /api/v1/parents/authorized-users/{id}` → `show`
|
||||
- `POST /api/v1/parents/authorized-users` → `store`
|
||||
- `PATCH /api/v1/parents/authorized-users/{id}` → `update`
|
||||
- `DELETE /api/v1/parents/authorized-users/{id}` → `destroy`
|
||||
|
||||
## Authentication & authorization
|
||||
- Requires authenticated parent context (effectively enforced by `parent.access` + `auth()` usage).
|
||||
- Ownership check:
|
||||
- All record reads/writes are constrained to `AuthorizedUser.user_id = parentId`.
|
||||
|
||||
## Requests/validation
|
||||
- `store`: `StoreAuthorizedUserRequest`
|
||||
- Reads validated `email` and lowercases/trims before invite.
|
||||
- `update`: `UpdateAuthorizedUserRequest`
|
||||
- Supports email change; re-invites when email changed.
|
||||
|
||||
## Endpoint behavior
|
||||
### `index`
|
||||
- Returns all authorized users for the parent ordered by id.
|
||||
- Response shape uses Base API envelope:
|
||||
- `{ status:true, message:"Success", data:[...] }`
|
||||
|
||||
### `show`
|
||||
- Loads an authorized user row for this parent or returns 404.
|
||||
- Response shape: Base API envelope with the model row.
|
||||
|
||||
### `store`
|
||||
- Invites by email via `AuthorizedUsersManagementService::inviteByEmail($parentId, $email)`.
|
||||
- If invite already existed / not newly created:
|
||||
- Returns `200` success with message explaining email sent conditionally.
|
||||
- If created:
|
||||
- Returns `201` with created id.
|
||||
|
||||
### `update`
|
||||
- If email provided:
|
||||
- Calls `changeEmailAndReinvite($row, $email)`
|
||||
- On invalid argument: returns `422` with error message
|
||||
- If email not provided:
|
||||
- Returns success message (no-op update)
|
||||
|
||||
### `destroy`
|
||||
- Deletes the owned record and returns success message.
|
||||
|
||||
## Error handling
|
||||
- Unauthenticated → `401` Base API error `"Authentication required."`
|
||||
- Not found → `404` Base API error `"Authorized user not found."`
|
||||
- Invalid email change → `422` Base API error with exception message
|
||||
|
||||
## Test plan (feature)
|
||||
- Auth required:
|
||||
- Unauthenticated requests → 401 for all endpoints.
|
||||
- Ownership:
|
||||
- Parent cannot read/update/delete someone else’s authorized user (404).
|
||||
- Store:
|
||||
- New email creates record → 201 + id.
|
||||
- Existing/duplicate invite path → 200 with “If the email belongs…” message.
|
||||
- Update:
|
||||
- Email change triggers reinvite; invalid email triggers 422.
|
||||
- Delete:
|
||||
- Owned record deleted; subsequent fetch returns 404.
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
# `BaseApiController` plan
|
||||
|
||||
## Scope
|
||||
- **Controller**: `app/Http/Controllers/Api/Core/BaseApiController.php`
|
||||
- **Purpose**: shared API conveniences:
|
||||
- Standard success/error JSON envelope helpers
|
||||
- Request payload normalization (JSON body vs query/form)
|
||||
- Validation helper compatible with legacy-style rules
|
||||
- Lightweight pagination helper for arrays/query builders
|
||||
- Current-user resolution helpers (id + role list)
|
||||
|
||||
## Key responsibilities
|
||||
### Response helpers
|
||||
- `success($data, $message, $code)` → `{ status:true, message, data }`
|
||||
- `error($message, $code, $errors?)` → `{ status:false, message, errors? }`
|
||||
- Convenience wrappers:
|
||||
- `respondSuccess`, `respondCreated`, `respondDeleted`, `respondError`, `respondValidationError`
|
||||
|
||||
### Request normalization
|
||||
- Stores the underlying Laravel request as `laravelRequest`.
|
||||
- Wraps it in `request adapter` as `$this->request` for compatibility patterns.
|
||||
- `payloadData()`:
|
||||
- Uses JSON-decoded body when present and valid
|
||||
- Falls back to merged query + form data
|
||||
|
||||
### Validation
|
||||
- `validateRequest($data, $rules)`:
|
||||
- Normalizes legacy-like rule strings into Laravel rules via `normalizeRules()`.
|
||||
- Saves validator to `$this->validator`.
|
||||
- Returns `[]` on success, or `errors()->toArray()` on failure.
|
||||
- `validate($rules)` validates against `payloadData()` and returns boolean.
|
||||
|
||||
### Pagination
|
||||
- `paginate($source, $page, $perPage)` supports:
|
||||
- Eloquent builder / query builder
|
||||
- Plain arrays
|
||||
- Returns `{ data: [...], pagination:{ current_page, per_page, total, total_pages } }`
|
||||
|
||||
### Current user helpers
|
||||
- `getCurrentUserId()` resolves authenticated id from multiple sources.
|
||||
- `getCurrentUser()`:
|
||||
- Loads `User` row
|
||||
- Computes display name
|
||||
- Loads roles via `user_roles`/`roles` join (best-effort; logs error and returns `roles: []` if it fails)
|
||||
|
||||
## Usage guidance (project conventions)
|
||||
- **Controllers extending `BaseApiController`** should prefer:
|
||||
- `respondSuccess`/`respondError` for consistent envelopes where possible.
|
||||
- Returning explicit `{ ok: true }` shapes only when required for legacy client compatibility.
|
||||
- **Validation**:
|
||||
- Prefer dedicated `FormRequest` classes for new endpoints.
|
||||
- Use `validateRequest/normalizeRules` only for legacy/bridge use cases.
|
||||
|
||||
## Test plan (unit)
|
||||
- `normalizeRules()` conversions:
|
||||
- `max_length[n]` → `max:n`, `min_length[n]` → `min:n`, `valid_email` → `email`, `permit_empty` → `nullable`, `is_unique[t.c]` → `unique:t,c`, etc.
|
||||
- `payloadData()`:
|
||||
- JSON body takes precedence when valid
|
||||
- Falls back to query+form when no JSON
|
||||
- `paginate()`:
|
||||
- Eloquent builder: returns expected total + slices
|
||||
- Array source: returns expected slice
|
||||
- `getCurrentUser()`:
|
||||
- No auth → `null`
|
||||
- Role query failure → returns user with empty roles and logs error
|
||||
@@ -1,75 +0,0 @@
|
||||
# `ChargeController` plan
|
||||
|
||||
## Scope
|
||||
- **Controller**: `app/Http/Controllers/Api/Finance/ChargeController.php`
|
||||
- **Purpose**: list charges for a parent and manage extra/event charges (create/cancel/apply/mark paid).
|
||||
- **Primary dependency**: `App\Services\Billing\ChargeService`
|
||||
- **Resources**:
|
||||
- `ChargeListResource` (for list responses)
|
||||
- `ChargeActionResource` (for mutation responses)
|
||||
|
||||
## Routes
|
||||
All routes are under:
|
||||
- Base prefix: `/api/v1/finance/fees/charges`
|
||||
|
||||
Endpoints:
|
||||
- `GET /api/v1/finance/fees/charges/` → `index`
|
||||
- `POST /api/v1/finance/fees/charges/extra` → `storeExtra`
|
||||
- `POST /api/v1/finance/fees/charges/event` → `storeEvent`
|
||||
- `POST /api/v1/finance/fees/charges/extra/{id}/cancel` → `cancelExtra`
|
||||
- `POST /api/v1/finance/fees/charges/extra/{id}/apply` → `applyExtra`
|
||||
- `POST /api/v1/finance/fees/charges/event/{id}/cancel` → `cancelEvent`
|
||||
- `POST /api/v1/finance/fees/charges/event/{id}/mark-paid` → `markEventPaid`
|
||||
|
||||
## Authentication & authorization
|
||||
- `storeExtra` / `storeEvent` require an authenticated user id (checked by `authenticatedUserIdOrUnauthorized()`).
|
||||
- Other endpoints currently do not call the helper; authorization is expected to be enforced by surrounding route middleware and/or service-layer checks.
|
||||
|
||||
## Requests/validation
|
||||
- `index`: `ChargeListRequest` (`parent_id` required; optional `school_year`)
|
||||
- `storeExtra`: `StoreExtraChargeRequest` (+ adds `created_by` from authenticated user)
|
||||
- `storeEvent`: `StoreEventChargeRequest` (+ adds `created_by`)
|
||||
- `applyExtra`: `ApplyExtraChargeRequest` (`invoice_id`)
|
||||
- `markEventPaid`: `MarkEventChargePaidRequest` (`paid`, optional `payment_id`)
|
||||
- `cancelEvent`: uses raw `Request` with query boolean `issue_credit` (default true)
|
||||
|
||||
## Response shapes
|
||||
Legacy `{ ok: ... }` shape:
|
||||
- `index`:
|
||||
- `{ ok:true, charges: ChargeListResource }`
|
||||
- Mutations:
|
||||
- `{ ok:(bool), charge: ChargeActionResource }`
|
||||
- HTTP status:
|
||||
- Create success: `201`
|
||||
- Duplicate charge success-case: `409` (when `error === "duplicate_charge"`)
|
||||
- Validation/business failure: `422`
|
||||
- Cancel/apply/mark-paid: `200` when ok, else `422`
|
||||
|
||||
## Side effects
|
||||
- `storeExtra` / `storeEvent` attach `created_by` for auditing.
|
||||
- `cancelEvent` optionally issues credits (controlled via `issue_credit` query flag).
|
||||
- `markEventPaid` can link to a `payment_id` when supplied.
|
||||
|
||||
## Error handling expectations
|
||||
- `storeExtra` / `storeEvent` catch unexpected exceptions:
|
||||
- return `500` `{ ok:false, message:"Unable to create ... charge." }`
|
||||
- Unauthorized (store endpoints) returns:
|
||||
- `401` `{ ok:false, message:"Unauthorized." }`
|
||||
|
||||
## Test plan (feature)
|
||||
- Listing:
|
||||
- Returns `ChargeListResource` payload for a parent with charges.
|
||||
- Create extra/event:
|
||||
- Unauthenticated → 401.
|
||||
- Valid payload → 201 and ok true.
|
||||
- Duplicate charge → 409.
|
||||
- Service exception → 500.
|
||||
- Apply extra:
|
||||
- Valid invoice id applies charge → 200.
|
||||
- Invalid invoice/charge → 422.
|
||||
- Cancel:
|
||||
- Extra cancel → 200/422 depending on service result.
|
||||
- Event cancel with `issue_credit=false` behaves accordingly.
|
||||
- Mark paid:
|
||||
- Toggle paid true/false; supports payment_id link.
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# `FeeCalculationController` plan
|
||||
|
||||
## Scope
|
||||
- **Controller**: `app/Http/Controllers/Api/Finance/FeeCalculationController.php`
|
||||
- **Purpose**: finance calculation endpoints (refunds, tuition totals/breakdowns, family balances).
|
||||
- **Primary dependencies**:
|
||||
- `FeeRefundCalculatorService` (refund calculations)
|
||||
- `FeeStudentFeeService` (tuition total)
|
||||
- `TuitionCalculationService` (tuition breakdown)
|
||||
- `BalanceCalculationService` (family account/balance aggregation)
|
||||
- **Resources**:
|
||||
- `FeeRefundResource`
|
||||
- `FeeTuitionTotalResource`
|
||||
- `FeeTuitionBreakdownResource`
|
||||
- `FeeFamilyBalanceResource`
|
||||
|
||||
## Routes
|
||||
All routes are under:
|
||||
- Base prefix: `/api/v1/finance`
|
||||
- (Route file shows this under a `finance` group; actual auth middleware depends on the surrounding `v1` group setup.)
|
||||
|
||||
Endpoints:
|
||||
- `POST /api/v1/finance/fees/refund` → `refund`
|
||||
- `POST /api/v1/finance/fees/tuition-total` → `tuitionTotal`
|
||||
- `POST /api/v1/finance/fees/tuition-breakdown` → `tuitionBreakdown`
|
||||
- `POST /api/v1/finance/fees/family-balance` → `familyBalance`
|
||||
|
||||
## Requests/validation
|
||||
- `refund`: `FeeRefundRequest`
|
||||
- expects `parent_id` and `students` array
|
||||
- `tuitionTotal`: `FeeTuitionTotalRequest`
|
||||
- expects `students` array
|
||||
- `tuitionBreakdown`: `FeeTuitionBreakdownRequest`
|
||||
- expects `students` array
|
||||
- `familyBalance`: `FeeFamilyBalanceRequest`
|
||||
- expects `parent_id`, `students` array, optional `school_year`
|
||||
|
||||
## Response shapes
|
||||
Legacy `{ ok: ... }` shape:
|
||||
- `refund`:
|
||||
- `{ ok:true, refund: FeeRefundResource }`
|
||||
- `tuitionTotal`:
|
||||
- `{ ok:true, tuition: FeeTuitionTotalResource({ total_tuition }) }`
|
||||
- `tuitionBreakdown`:
|
||||
- `{ ok:true, tuition: FeeTuitionBreakdownResource }`
|
||||
- `familyBalance`:
|
||||
- `{ ok:true, account: FeeFamilyBalanceResource }`
|
||||
|
||||
## Error handling expectations
|
||||
- Each endpoint wraps calculation in `try/catch`:
|
||||
- On exception: logs error and returns `500` with `{ ok:false, message:"Unable to ..." }`
|
||||
- Validation errors are handled by the FormRequest layer (422).
|
||||
|
||||
## Test plan (feature/unit mix)
|
||||
- Validation:
|
||||
- Missing required fields → 422.
|
||||
- Refund:
|
||||
- Valid input returns `ok:true` and resource fields.
|
||||
- Service exception returns 500 and message.
|
||||
- Tuition total/breakdown:
|
||||
- Calculates expected totals for a known dataset.
|
||||
- Service exception returns 500.
|
||||
- Family balance:
|
||||
- Produces stable account totals for known payments/charges.
|
||||
- Service exception returns 500.
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# `ParentController` plan
|
||||
|
||||
## Scope
|
||||
- **Controller**: `app/Http/Controllers/Api/Parents/ParentController.php`
|
||||
- **Purpose**: parent portal endpoints for attendance, invoices, enrollments, registration, emergency contacts, profile, and event participation.
|
||||
- **Primary dependencies**:
|
||||
- `App\Services\Parents\ParentAttendanceService`
|
||||
- `App\Services\Parents\ParentInvoiceService`
|
||||
- `App\Services\Parents\ParentEnrollmentService`
|
||||
- `App\Services\Parents\ParentRegistrationService`
|
||||
- `App\Services\Parents\ParentEmergencyContactService`
|
||||
- `App\Services\Parents\ParentProfileService`
|
||||
- `App\Services\Parents\ParentEventParticipationService`
|
||||
|
||||
## Routes
|
||||
All routes are under `v1` + `parents` group:
|
||||
- Base prefix: `GET/POST/PATCH/DELETE /api/v1/parents/...`
|
||||
- Group middleware: `parent.access`
|
||||
|
||||
Endpoints:
|
||||
- `GET /api/v1/parents/attendance` → `attendance`
|
||||
- `GET /api/v1/parents/invoices` → `invoices`
|
||||
- `GET /api/v1/parents/enrollments` → `enrollments`
|
||||
- `POST /api/v1/parents/enrollments` → `updateEnrollments`
|
||||
- `GET /api/v1/parents/registration` → `registration`
|
||||
- `POST /api/v1/parents/registration` → `storeRegistration`
|
||||
- `PATCH /api/v1/parents/students/{studentId}` → `updateStudent`
|
||||
- `DELETE /api/v1/parents/students/{studentId}` → `deleteStudent`
|
||||
- `GET /api/v1/parents/emergency-contacts` → `emergencyContacts`
|
||||
- `POST /api/v1/parents/emergency-contacts` → `storeEmergencyContact`
|
||||
- `PATCH /api/v1/parents/emergency-contacts/{contactId}` → `updateEmergencyContact`
|
||||
- `GET /api/v1/parents/profile` → `profile`
|
||||
- `PATCH /api/v1/parents/profile` → `updateProfile`
|
||||
- `GET /api/v1/parents/events` → `events`
|
||||
- `POST /api/v1/parents/events/participation` → `updateParticipation`
|
||||
|
||||
## Authentication & authorization
|
||||
- Uses `parent.access` middleware which resolves “secondary”/“authorized” users to a primary parent id and stores it in the request attribute `primary_parent_id`.
|
||||
- Controller helper `parentIdOrUnauthorized()`:
|
||||
- Prefer `request()->attributes->get('primary_parent_id')`
|
||||
- Fallback to `auth()->id()`
|
||||
- On failure returns JSON `{ ok:false, message:"Unauthorized." }` with `401`
|
||||
|
||||
## Requests/validation
|
||||
Validated via `FormRequest` classes per endpoint:
|
||||
- `attendance`: `ParentAttendanceRequest` (optional `school_year`)
|
||||
- `invoices`: `ParentInvoiceRequest` (optional `school_year`)
|
||||
- `enrollments`: `ParentEnrollmentRequest` (optional `school_year`)
|
||||
- `updateEnrollments`: `ParentEnrollmentActionRequest` (`enroll[]`, `withdraw[]`)
|
||||
- `storeRegistration`: `ParentRegistrationRequest` (`students[]`, `emergency_contacts[]`)
|
||||
- `updateStudent`: `ParentStudentUpdateRequest`
|
||||
- `store/update emergency contact`: `ParentEmergencyContactRequest`
|
||||
- `updateProfile`: `ParentProfileUpdateRequest`
|
||||
- `updateParticipation`: `ParentEventParticipationRequest` (`participation[]`)
|
||||
|
||||
## Response shapes (legacy `{ ok: ... }`)
|
||||
This controller largely returns a legacy parent-portal shape rather than the Base API envelope.
|
||||
|
||||
- `attendance`:
|
||||
- `{ ok:true, attendance:[...], schoolYears:[...], selectedYear }`
|
||||
- `attendance` uses `ParentAttendanceResource::collection(...)`
|
||||
- `invoices`:
|
||||
- `{ ok:true, invoices:[...] }` (`InvoiceResource::collection(...)`)
|
||||
- `enrollments`:
|
||||
- `{ ok:true, students:[...], schoolYears:[...], selectedYear, withdrawalDeadline, lastDayOfRegistration, schoolStartDate }`
|
||||
- `updateEnrollments`:
|
||||
- `{ ok:true, enrolled:[...], withdrawn:[...] }`
|
||||
- `registration`:
|
||||
- `{ ok:true, parent, existingKids:[...], emergencies:[...], maxChilds, maxEmergency, lastDayOfRegistration, registrationAgeDeadline }`
|
||||
- `storeRegistration`:
|
||||
- `{ ok:true, created_student_ids:[...], skipped:[...] }` with status `201` if created_count>0 else `200`
|
||||
- `updateStudent` / `deleteStudent` / `updateParticipation`:
|
||||
- `{ ok:true }`
|
||||
- `emergencyContacts`:
|
||||
- `{ ok:true, contacts:[...] }`
|
||||
- `store/update emergency contact`:
|
||||
- `{ ok:true, contact:{...} }` (store returns `201`)
|
||||
- `profile` / `updateProfile`:
|
||||
- `{ ok:true, profile:{...} }`
|
||||
- `events`:
|
||||
- `{ ok:true, activeEvents, charges, yourStudents }`
|
||||
|
||||
## Error handling expectations
|
||||
- Unauthorized: `{ ok:false, message:"Unauthorized." }` with `401` (from `parentIdOrUnauthorized()`).
|
||||
- Service-layer exceptions are generally allowed to bubble unless handled in the service; controller does minimal try/catch.
|
||||
|
||||
## Test plan (feature)
|
||||
- Auth/parent.access:
|
||||
- Without auth → 401 `{ ok:false }`.
|
||||
- With secondary/authorized user → resolved to primary parent id and returns correct data.
|
||||
- Each endpoint:
|
||||
- Valid request returns `ok:true` and expected keys.
|
||||
- Validation errors are returned by FormRequest (422).
|
||||
- Registration:
|
||||
- Returns 201 when at least one student is created, 200 when all entries skipped.
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# `ParentPromotionController` plan
|
||||
|
||||
## Scope
|
||||
- **Controller**: `app/Http/Controllers/Api/Parents/ParentPromotionController.php`
|
||||
- **Purpose**: parent portal endpoints for promotion + enrollment workflow.
|
||||
- **Primary dependencies**:
|
||||
- `App\Services\Promotions\PromotionEnrollmentService`
|
||||
- `App\Services\Promotions\PromotionQueryService`
|
||||
- **Models**:
|
||||
- `App\Models\StudentPromotionRecord`
|
||||
- `App\Models\Student` (ownership verification fallback)
|
||||
|
||||
## Routes
|
||||
All routes are under `v1` + `parents` group:
|
||||
- Base prefix: `... /api/v1/parents/promotions`
|
||||
- Group middleware: `parent.access`
|
||||
|
||||
Endpoints:
|
||||
- `GET /api/v1/parents/promotions/` → `overview`
|
||||
- `GET /api/v1/parents/promotions/{promotionId}` → `show`
|
||||
- `POST /api/v1/parents/promotions/{promotionId}/start` → `start`
|
||||
- `PATCH /api/v1/parents/promotions/{promotionId}/steps` → `updateSteps`
|
||||
- `POST /api/v1/parents/promotions/{promotionId}/submit` → `submit`
|
||||
|
||||
## Authentication & authorization
|
||||
- Parent context resolved via `parentIdOrUnauthorized()`:
|
||||
- prefers request attribute `primary_parent_id` (set by `parent.access`)
|
||||
- falls back to `auth()->id()`
|
||||
- Ownership is enforced in `loadOwnedRecord()`:
|
||||
- Primary check: `StudentPromotionRecord.parent_id == parentId`
|
||||
- Fallback: loads `Student.parent_id` for the record’s `student_id`
|
||||
- If not owned: returns `403` `{ ok:false, message:"You are not authorised..." }`
|
||||
|
||||
## Requests/validation
|
||||
- `overview`: `ParentPromotionOverviewRequest` (optional `next_school_year`)
|
||||
- `updateSteps`: `ParentEnrollmentStepRequest` (`steps()` accessor provides structured step payload)
|
||||
|
||||
## Response shapes
|
||||
- `overview`:
|
||||
- `{ ok:true, records, next_school_year, message }`
|
||||
- Note: `records` are returned as provided by `PromotionEnrollmentService::overviewForParent(...)` (already shaped for the client)
|
||||
- `show` / `start` / `updateSteps` / `submit`:
|
||||
- `{ ok:true, data: StudentPromotionResource(...) }`
|
||||
|
||||
## State transitions (high-level)
|
||||
- `start` → `PromotionEnrollmentService::startEnrollment(...)`
|
||||
- `updateSteps` → `PromotionEnrollmentService::updateSteps(...)`
|
||||
- `submit` → `PromotionEnrollmentService::submitEnrollment(...)`
|
||||
- Conflicts (invalid transition / deadline / status) surface as `409` with `{ ok:false, message }`.
|
||||
|
||||
## Error handling expectations
|
||||
- Unauthorized → `401` `{ ok:false, message:"Unauthorized." }`
|
||||
- Record not found → `404` `{ ok:false, message:"Promotion record not found." }`
|
||||
- Not owned → `403` `{ ok:false, message:"You are not authorised..." }`
|
||||
- Invalid transition → `409` `{ ok:false, message }`
|
||||
|
||||
## Test plan (feature)
|
||||
- Ownership:
|
||||
- Parent cannot access another parent’s record (403).
|
||||
- Record with mismatched `parent_id` but student belongs to parent passes ownership check.
|
||||
- Happy path:
|
||||
- `overview` returns list for parent.
|
||||
- `start` moves record to started state and returns updated resource.
|
||||
- `updateSteps` persists steps and returns updated resource.
|
||||
- `submit` transitions to submitted state.
|
||||
- Conflict cases:
|
||||
- Submitting without required steps → 409.
|
||||
- Past deadline → 409 (as enforced by service).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
# infra
|
||||
docker compose -f docker-compose.dev.yml up -d postgres
|
||||
docker compose -f docker-compose.dev.yml up -d redis
|
||||
|
||||
# app services
|
||||
docker compose -f docker-compose.dev.yml --profile api up --build
|
||||
docker compose -f docker-compose.dev.yml --profile marketplace up --build
|
||||
docker compose -f docker-compose.dev.yml --profile dashboard up --build
|
||||
docker compose -f docker-compose.dev.yml --profile admin up --build
|
||||
|
||||
# tools
|
||||
docker compose -f docker-compose.dev.yml --profile tools up --build
|
||||
|
||||
# full stack
|
||||
docker compose -f docker-compose.dev.yml --profile full up --build
|
||||
@@ -1,925 +0,0 @@
|
||||
# Strong Enrollment, Registration, Promotion, and Section Placement Plan
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This plan defines a stronger, safer lifecycle for student registration, enrollment, promotion, and next-class section placement.
|
||||
|
||||
The goal is to separate concepts that are currently too easy to mix together:
|
||||
|
||||
- Student identity
|
||||
- New-student registration
|
||||
- Returning-student enrollment
|
||||
- Academic pass/fail decision
|
||||
- Promotion eligibility
|
||||
- Class/section placement
|
||||
- Historical display and auditability
|
||||
|
||||
A strong system must not treat all of these as one status field wearing too many hats. That is how school software becomes a haunted spreadsheet.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Principle
|
||||
|
||||
The system must distinguish between:
|
||||
|
||||
| Concept | Meaning | Source of Truth |
|
||||
|---|---|---|
|
||||
| Student identity | The person/student record | `students` |
|
||||
| New-student registration | A new family applies for admission | registration/application tables |
|
||||
| New-student approval | Admin approves the new student | admin registration/enrollment flow |
|
||||
| Returning-student enrollment | Parent confirms child is continuing next year | returning enrollment record |
|
||||
| Academic decision | Student passed, failed, retained, or needs review | `student_decisions` |
|
||||
| Promotion eligibility | Student may move to next class | derived from `student_decisions` |
|
||||
| Section placement | Student is assigned to a class section | section placement batch |
|
||||
| Historical display | Old records remain visible as originally stored | legacy records/tables |
|
||||
|
||||
Promotion must not directly mean enrollment. Enrollment must not directly mean class placement. Class placement must not decide pass/fail. Each process gets its own job, because apparently databases behave better when we stop asking one column to run a school.
|
||||
|
||||
---
|
||||
|
||||
## 3. Backward Compatibility Requirement
|
||||
|
||||
The new system must be additive, not destructive.
|
||||
|
||||
Existing registration, enrollment, assignment, and promotion data must remain displayable. Old rows must not be silently reinterpreted or recalculated under the new policy.
|
||||
|
||||
### Required rules
|
||||
|
||||
```text
|
||||
Old records remain visible.
|
||||
Old student/class assignments remain readable.
|
||||
Historical enrollment state is not silently changed.
|
||||
Historical promotion results are not silently recalculated.
|
||||
New lifecycle rules apply only to future/new workflow records or explicitly migrated records.
|
||||
```
|
||||
|
||||
### Recommended legacy metadata
|
||||
|
||||
Where needed, add metadata such as:
|
||||
|
||||
```text
|
||||
lifecycle_mode = legacy | strong
|
||||
lifecycle_policy_version = legacy_v1 | strong_v1
|
||||
```
|
||||
|
||||
Existing rows should default to:
|
||||
|
||||
```text
|
||||
lifecycle_mode = legacy
|
||||
lifecycle_policy_version = legacy_v1
|
||||
```
|
||||
|
||||
This allows the UI to show old data clearly without pretending old rows followed the new workflow.
|
||||
|
||||
---
|
||||
|
||||
## 4. Student Lifecycle Types
|
||||
|
||||
The system must support two main student flows.
|
||||
|
||||
## 4.1 New Student Flow
|
||||
|
||||
A new student is not already part of the school’s prior-year enrollment.
|
||||
|
||||
New-student flow:
|
||||
|
||||
```text
|
||||
Parent submits new-student registration
|
||||
→ Admin reviews application
|
||||
→ Admin approves or rejects
|
||||
→ Approved student enters placement pool
|
||||
→ Student is assigned to class section
|
||||
→ Enrollment is completed
|
||||
```
|
||||
|
||||
Admin approval is required for new students.
|
||||
|
||||
### New-student states
|
||||
|
||||
```text
|
||||
application_draft
|
||||
application_submitted
|
||||
admin_review_pending
|
||||
approved
|
||||
rejected
|
||||
waitlisted
|
||||
placement_pending
|
||||
placed_in_class
|
||||
enrollment_completed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4.2 Returning Student Flow
|
||||
|
||||
A returning student already exists and was enrolled in the previous school year/class.
|
||||
|
||||
Returning-student flow:
|
||||
|
||||
```text
|
||||
Old class/year finalized
|
||||
→ `student_decisions` records pass/fail/retain/review decision
|
||||
→ If passed/promoted, student becomes eligible to continue
|
||||
→ Parent completes returning-student enrollment
|
||||
→ System validates eligibility
|
||||
→ Student enters placement pool
|
||||
→ Student is assigned to class section
|
||||
→ Enrollment is completed
|
||||
```
|
||||
|
||||
Admin approval is not required for normal returning students.
|
||||
|
||||
Admin review is required only for exceptions.
|
||||
|
||||
### Returning-student states
|
||||
|
||||
```text
|
||||
academic_not_reviewed
|
||||
eligible_to_continue
|
||||
retained
|
||||
parent_enrollment_pending
|
||||
parent_enrollment_completed
|
||||
placement_pending
|
||||
placed_in_class
|
||||
enrollment_completed
|
||||
exception_review_required
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Pass/Fail Source of Truth
|
||||
|
||||
Promotion eligibility must come from `student_decisions`.
|
||||
|
||||
The placement system must not recalculate pass/fail. Its job is placement, not academic judgment. Otherwise it becomes judge, registrar, scheduler, and disaster generator.
|
||||
|
||||
### Required rule
|
||||
|
||||
```text
|
||||
Promotion placement must read academic eligibility from `student_decisions`.
|
||||
A student is eligible for next-class placement only when `student_decisions` marks the student as passed/promoted for the completed school year/class.
|
||||
Failed, retained, pending, or missing-decision students must not enter the automatic next-class placement pool.
|
||||
```
|
||||
|
||||
### Eligibility from `student_decisions`
|
||||
|
||||
| Decision | Placement behavior |
|
||||
|---|---|
|
||||
| passed/promoted | Eligible for next-class placement after parent enrollment |
|
||||
| failed/retained | Excluded from automatic next-class placement |
|
||||
| pending | Exception report |
|
||||
| missing | Exception report |
|
||||
| manual review | Exception review |
|
||||
| withdrawn/inactive | Excluded unless admin override |
|
||||
|
||||
### Required exception buckets
|
||||
|
||||
The system should report:
|
||||
|
||||
```text
|
||||
Passed but parent enrollment missing
|
||||
Passed and parent enrollment completed
|
||||
Failed/retained
|
||||
Missing student decision
|
||||
Pending student decision
|
||||
Decision conflicts with enrollment request
|
||||
Withdrawn/inactive student
|
||||
Duplicate target-year enrollment
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Parent Enrollment Requirement for Returning Students
|
||||
|
||||
A returning student must not be placed into the next year/class until the parent completes returning-student enrollment.
|
||||
|
||||
### Required rule
|
||||
|
||||
```text
|
||||
For returning students, promotion eligibility plus parent enrollment completion is sufficient to continue enrollment into the next school year, subject to system validation and class placement.
|
||||
Admin approval is only required for exceptions.
|
||||
```
|
||||
|
||||
### Returning enrollment should capture
|
||||
|
||||
```text
|
||||
student_id
|
||||
parent_id
|
||||
target_school_year
|
||||
requested_grade_level_id
|
||||
returning_student = true
|
||||
required forms completed
|
||||
emergency contact confirmation
|
||||
medical/contact update confirmation
|
||||
agreement/consent confirmations
|
||||
submission timestamp
|
||||
status
|
||||
```
|
||||
|
||||
### Returning enrollment statuses
|
||||
|
||||
```text
|
||||
not_started
|
||||
in_progress
|
||||
submitted
|
||||
completed
|
||||
expired
|
||||
exception_review_required
|
||||
```
|
||||
|
||||
Normal returning-student continuation should use:
|
||||
|
||||
```text
|
||||
parent_enrollment_completed
|
||||
```
|
||||
|
||||
not:
|
||||
|
||||
```text
|
||||
admin_approved
|
||||
```
|
||||
|
||||
Admin approval is for new students and exceptions, not normal returning students.
|
||||
|
||||
---
|
||||
|
||||
## 7. When Admin Review Is Required for Returning Students
|
||||
|
||||
Returning students should flow automatically only when clean.
|
||||
|
||||
Admin review is required when:
|
||||
|
||||
```text
|
||||
student failed but parent tries to enroll in next grade
|
||||
student is retained and needs repeat placement
|
||||
student has no linked parent/guardian
|
||||
required forms are incomplete after deadline
|
||||
requested class/grade conflicts with eligibility
|
||||
capacity override is needed
|
||||
manual promotion override is requested
|
||||
duplicate enrollment exists
|
||||
student was withdrawn or inactive
|
||||
student_decisions is missing or pending
|
||||
```
|
||||
|
||||
This keeps normal cases fast and sends only abnormal cases to humans, who are expensive, inconsistent, and sometimes looking for the wrong spreadsheet.
|
||||
|
||||
---
|
||||
|
||||
## 8. Placement Pool Construction
|
||||
|
||||
The final placement pool is built from two sources:
|
||||
|
||||
```text
|
||||
eligible returning students
|
||||
+ approved new students
|
||||
```
|
||||
|
||||
### 8.1 Returning student inclusion rule
|
||||
|
||||
A returning student enters the placement pool only if:
|
||||
|
||||
```text
|
||||
`student_decisions` marks student as passed/promoted
|
||||
parent completed returning-student enrollment
|
||||
student is not already placed in the target year
|
||||
target grade/class matches promotion path
|
||||
student is active/not withdrawn
|
||||
```
|
||||
|
||||
### 8.2 New student inclusion rule
|
||||
|
||||
A new student enters the placement pool only if:
|
||||
|
||||
```text
|
||||
new-student registration/enrollment is admin-approved
|
||||
student is not already placed in the target year
|
||||
target grade/class is valid
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Configurable Section Capacity
|
||||
|
||||
The section capacity must not be hardcoded.
|
||||
|
||||
The maximum number of students per generated section must be read from the `configuration` table as a key/value setting.
|
||||
|
||||
### Required configuration key
|
||||
|
||||
Recommended key:
|
||||
|
||||
```text
|
||||
promotion.section_capacity = 20
|
||||
```
|
||||
|
||||
### Configuration table example
|
||||
|
||||
| key | value | type | description |
|
||||
|---|---:|---|---|
|
||||
| `promotion.section_capacity` | `20` | integer | Maximum number of students per generated promoted section |
|
||||
|
||||
### Validation
|
||||
|
||||
```text
|
||||
promotion.section_capacity must be an integer
|
||||
promotion.section_capacity must be greater than 0
|
||||
recommended minimum: 5
|
||||
recommended maximum: 40 unless admin override
|
||||
```
|
||||
|
||||
### Fallback rule
|
||||
|
||||
If the key is missing:
|
||||
|
||||
```text
|
||||
section_capacity = 20
|
||||
```
|
||||
|
||||
The fallback protects the system, but missing config should still be logged as a warning. Silent defaults are convenient right up until nobody knows why sections were created that way.
|
||||
|
||||
### Snapshot rule
|
||||
|
||||
The system must store the capacity used at generation time:
|
||||
|
||||
```text
|
||||
section_capacity_used
|
||||
```
|
||||
|
||||
Optional but recommended:
|
||||
|
||||
```text
|
||||
configuration_snapshot_json
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"promotion.section_capacity": 20,
|
||||
"placement.algorithm": "score_band_balanced_v1"
|
||||
}
|
||||
```
|
||||
|
||||
This prevents old placement batches from changing meaning if the capacity is later changed to 22, 25, or whatever fresh number policy invents.
|
||||
|
||||
---
|
||||
|
||||
## 10. Section Count Rule
|
||||
|
||||
Let:
|
||||
|
||||
```text
|
||||
section_capacity = configuration['promotion.section_capacity'] ?? 20
|
||||
total_students = count(placement_pool)
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```text
|
||||
If total_students <= section_capacity:
|
||||
create one section
|
||||
|
||||
If total_students > section_capacity:
|
||||
section_count = ceil(total_students / section_capacity)
|
||||
```
|
||||
|
||||
Examples when `promotion.section_capacity = 20`:
|
||||
|
||||
| Total students | Section count |
|
||||
|---:|---:|
|
||||
| 1-20 | 1 |
|
||||
| 21-40 | 2 |
|
||||
| 41-60 | 3 |
|
||||
| 61-80 | 4 |
|
||||
|
||||
---
|
||||
|
||||
## 11. Score Bands for Placement
|
||||
|
||||
Students must be grouped into score bands for balanced section placement.
|
||||
|
||||
### Bands
|
||||
|
||||
| Band | Score range |
|
||||
|---|---:|
|
||||
| A | 90-100 |
|
||||
| B | 80-89 |
|
||||
| C | 70-79 |
|
||||
| D | 60-69 |
|
||||
|
||||
Students below 60 are not eligible for automatic next-class promotion unless an authorized override exists.
|
||||
|
||||
### Score source by student type
|
||||
|
||||
| Student type | Eligibility source | Band source |
|
||||
|---|---|---|
|
||||
| Returning student | `student_decisions` pass/promote | final promotion score |
|
||||
| Failed/retained student | `student_decisions` fail/retain | excluded from next-class automatic placement |
|
||||
| New student with placement assessment | approved new-student enrollment | assessment score band |
|
||||
| New student without placement assessment | approved new-student enrollment | D band by default |
|
||||
|
||||
---
|
||||
|
||||
## 12. New Student Default Band Rule
|
||||
|
||||
New students do not always have prior school scores. If no approved placement/assessment score exists, they must be assigned to the D band for balancing purposes.
|
||||
|
||||
### Required rule
|
||||
|
||||
```text
|
||||
New students approved for enrollment are included in the target class placement pool.
|
||||
If a new student does not have an approved placement or assessment score, the system assigns the student to the D score band, 60-69, for section-balancing purposes only.
|
||||
This default band assignment must not be stored as an academic grade.
|
||||
It must be stored as a placement-band classification with the reason `new_student_default_band`.
|
||||
```
|
||||
|
||||
Do not invent a fake numeric score like 65. That would contaminate reports later, because somebody will export it and believe it is real. Humans keep doing that. We design around it.
|
||||
|
||||
### Recommended fields
|
||||
|
||||
```text
|
||||
placement_band = D
|
||||
placement_score = null
|
||||
placement_band_reason = new_student_default_band
|
||||
```
|
||||
|
||||
If a new student has an approved placement score:
|
||||
|
||||
```text
|
||||
placement_band = derived from placement score
|
||||
placement_score = approved assessment score
|
||||
placement_band_reason = new_student_assessment
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Balanced Section Placement Rule
|
||||
|
||||
When there are multiple sections, the system must balance:
|
||||
|
||||
1. Total number of students per section.
|
||||
2. Score-band mix across sections.
|
||||
|
||||
The system should not simply divide each score band and accidentally create sections with different total sizes.
|
||||
|
||||
### Required rule
|
||||
|
||||
```text
|
||||
When distributing score bands across multiple sections, the system must prioritize equal total section size while keeping each score band as evenly distributed as possible.
|
||||
Remainders from score bands should be assigned using a balancing algorithm that gives the next student to the section with the lowest current total, not simply to the next section in fixed order.
|
||||
```
|
||||
|
||||
### Target section sizes
|
||||
|
||||
Given:
|
||||
|
||||
```text
|
||||
total_students
|
||||
section_count
|
||||
```
|
||||
|
||||
Calculate:
|
||||
|
||||
```text
|
||||
base_size = floor(total_students / section_count)
|
||||
remainder = total_students % section_count
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```text
|
||||
first `remainder` sections may have base_size + 1 students
|
||||
remaining sections have base_size students
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
32 students
|
||||
2 sections
|
||||
target size = 16 and 16
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
43 students
|
||||
3 sections
|
||||
target size = 15, 14, 14
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Balanced Placement Algorithm
|
||||
|
||||
Use a deterministic algorithm.
|
||||
|
||||
### Algorithm: score_band_balanced_v1
|
||||
|
||||
```text
|
||||
1. Load section_capacity from configuration.
|
||||
2. Validate section_capacity.
|
||||
3. Build placement pool:
|
||||
- returning students passed/promoted in `student_decisions`
|
||||
- returning parent enrollment completed
|
||||
- approved new students
|
||||
4. Exclude:
|
||||
- failed/retained students
|
||||
- missing/pending decision students
|
||||
- parent enrollment missing for returning students
|
||||
- duplicate target-year placements
|
||||
5. Count placement pool.
|
||||
6. If total_students <= section_capacity:
|
||||
create one section and assign all students.
|
||||
7. If total_students > section_capacity:
|
||||
section_count = ceil(total_students / section_capacity).
|
||||
8. Calculate target section sizes.
|
||||
9. Assign each student a placement band:
|
||||
returning students: final promotion score band
|
||||
new students with assessment: assessment score band
|
||||
new students without assessment: D band
|
||||
10. Group students by band:
|
||||
A: 90-100
|
||||
B: 80-89
|
||||
C: 70-79
|
||||
D: 60-69
|
||||
11. Sort bands from highest to lowest: A, B, C, D.
|
||||
12. Within each band, sort students deterministically:
|
||||
final/placement score descending when available
|
||||
student name ascending or student_id ascending as tie-breaker
|
||||
13. Assign students one by one to the eligible section with:
|
||||
lowest current total count
|
||||
then lowest count for that student's score band
|
||||
then deterministic section order
|
||||
14. Do not exceed target section size unless all target sizes are full or admin override exists.
|
||||
15. Validate every student is assigned exactly once.
|
||||
16. Save placement snapshot.
|
||||
17. Show admin preview.
|
||||
18. Finalize only after preview confirmation.
|
||||
```
|
||||
|
||||
### Tie-break order
|
||||
|
||||
The section assignment tie-break order must be:
|
||||
|
||||
```text
|
||||
lowest total section count
|
||||
then lowest count in the student's score band
|
||||
then deterministic section order
|
||||
```
|
||||
|
||||
This order matters.
|
||||
|
||||
If the system prioritizes band equality before total section size, totals can drift. If it prioritizes total size first and band count second, it produces more balanced sections.
|
||||
|
||||
---
|
||||
|
||||
## 15. Example Balanced Distribution
|
||||
|
||||
For 32 eligible students and capacity 20:
|
||||
|
||||
```text
|
||||
section_count = ceil(32 / 20) = 2
|
||||
target size = 16 and 16
|
||||
```
|
||||
|
||||
A valid balanced result:
|
||||
|
||||
| Band | Section A | Section B |
|
||||
|---|---:|---:|
|
||||
| 90-100 | 4 | 4 |
|
||||
| 80-89 | 5 | 5 |
|
||||
| 70-79 | 4 | 5 |
|
||||
| 60-69 | 3 | 2 |
|
||||
| **Total** | **16** | **16** |
|
||||
|
||||
This is the desired behavior: both sections have equal total size, and the band mix is as balanced as possible.
|
||||
|
||||
---
|
||||
|
||||
## 16. Placement Preview Before Finalization
|
||||
|
||||
The system must not immediately write final class assignments.
|
||||
|
||||
It should first create a draft placement batch.
|
||||
|
||||
### Preview should show
|
||||
|
||||
```text
|
||||
section count
|
||||
capacity used
|
||||
target section sizes
|
||||
student count per section
|
||||
score-band distribution per section
|
||||
new students defaulted to D band
|
||||
students excluded from placement
|
||||
students needing exception review
|
||||
capacity warnings
|
||||
duplicate placement warnings
|
||||
```
|
||||
|
||||
Admin should then finalize the preview.
|
||||
|
||||
Only after finalization should the system write final class/section assignments.
|
||||
|
||||
This avoids the classic software maneuver where a draft becomes reality because somebody clicked the wrong button and the database had no spine.
|
||||
|
||||
---
|
||||
|
||||
## 17. Placement Snapshot and Audit Tables
|
||||
|
||||
Create a placement batch snapshot so the school can explain how students were assigned.
|
||||
|
||||
### Recommended table: `section_placement_batches`
|
||||
|
||||
```text
|
||||
id
|
||||
from_school_year
|
||||
to_school_year
|
||||
from_grade_level_id
|
||||
to_grade_level_id
|
||||
section_capacity_used
|
||||
total_students
|
||||
section_count
|
||||
algorithm
|
||||
configuration_snapshot_json
|
||||
created_by
|
||||
created_at
|
||||
finalized_by
|
||||
finalized_at
|
||||
status
|
||||
```
|
||||
|
||||
Recommended statuses:
|
||||
|
||||
```text
|
||||
draft
|
||||
finalized
|
||||
cancelled
|
||||
superseded
|
||||
```
|
||||
|
||||
### Recommended table: `section_placement_batch_students`
|
||||
|
||||
```text
|
||||
id
|
||||
batch_id
|
||||
student_id
|
||||
student_type
|
||||
source_decision_id nullable
|
||||
source_enrollment_id nullable
|
||||
final_score nullable
|
||||
placement_score nullable
|
||||
score_band
|
||||
placement_band_reason
|
||||
assigned_section_id
|
||||
assignment_order
|
||||
was_override
|
||||
override_reason
|
||||
created_at
|
||||
```
|
||||
|
||||
### Placement band reasons
|
||||
|
||||
```text
|
||||
returning_student_final_score
|
||||
new_student_assessment
|
||||
new_student_default_band
|
||||
admin_override
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 18. Validation Before Placement Generation
|
||||
|
||||
Before generating placement, validate:
|
||||
|
||||
```text
|
||||
target school year exists
|
||||
target school year is open for enrollment/placement
|
||||
target grade/class exists
|
||||
section_capacity config exists or fallback is applied
|
||||
source class/year is finalized where applicable
|
||||
`student_decisions` exists for returning students
|
||||
returning students have passed/promoted decisions
|
||||
returning students completed parent enrollment
|
||||
new students are admin-approved
|
||||
students are not already placed in target year
|
||||
target grade/class matches the promotion path
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 19. Validation Before Placement Finalization
|
||||
|
||||
Before finalizing placement, validate:
|
||||
|
||||
```text
|
||||
all eligible students are assigned exactly once
|
||||
no ineligible student is assigned
|
||||
no student is already assigned to another target-year section
|
||||
all sections belong to the target year and grade/class
|
||||
no section exceeds target size unless override is recorded
|
||||
score-band distribution report was generated
|
||||
section_capacity_used is stored
|
||||
placement snapshot is saved
|
||||
admin finalized the preview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 20. Edge Cases
|
||||
|
||||
| Case | Rule |
|
||||
|---|---|
|
||||
| Score exactly 90 | Band A |
|
||||
| Score exactly 80 | Band B |
|
||||
| Score exactly 70 | Band C |
|
||||
| Score exactly 60 | Band D |
|
||||
| Score below 60 | Not automatic promotion unless override |
|
||||
| Missing final score for returning student | Exception report |
|
||||
| Missing `student_decisions` | Exception report |
|
||||
| Pending `student_decisions` | Exception report |
|
||||
| Parent did not complete returning enrollment | Do not place |
|
||||
| New student without assessment | D band for balancing only |
|
||||
| New student with assessment score | Band from assessment |
|
||||
| Duplicate target-year placement | Block and report |
|
||||
| Capacity exceeded | Admin override required |
|
||||
| Uneven score bands | Balance total section size first, band mix second |
|
||||
| Config changes after placement | Old placement uses stored `section_capacity_used` |
|
||||
|
||||
---
|
||||
|
||||
## 21. Required Reports
|
||||
|
||||
The admin should have reports for:
|
||||
|
||||
```text
|
||||
Passed but parent enrollment missing
|
||||
Ready for placement
|
||||
New students approved and ready for placement
|
||||
Missing/pending student decisions
|
||||
Failed/retained students
|
||||
Exception review required
|
||||
Draft placement batches
|
||||
Finalized placement batches
|
||||
Capacity and section distribution
|
||||
New students defaulted to D band
|
||||
Manual overrides
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 22. Service Design
|
||||
|
||||
Recommended services:
|
||||
|
||||
```text
|
||||
StudentDecisionEligibilityService
|
||||
ReturningEnrollmentStatusService
|
||||
NewStudentAdmissionStatusService
|
||||
PlacementPoolBuilder
|
||||
PromotionSectionCapacityService
|
||||
ScoreBandClassifier
|
||||
BalancedSectionPlacementService
|
||||
SectionPlacementPreviewService
|
||||
SectionPlacementFinalizationService
|
||||
SectionPlacementAuditService
|
||||
```
|
||||
|
||||
### Responsibility split
|
||||
|
||||
| Service | Responsibility |
|
||||
|---|---|
|
||||
| `StudentDecisionEligibilityService` | Reads `student_decisions`; determines pass/fail eligibility |
|
||||
| `ReturningEnrollmentStatusService` | Checks parent returning enrollment completion |
|
||||
| `NewStudentAdmissionStatusService` | Checks admin-approved new students |
|
||||
| `PlacementPoolBuilder` | Builds final placement pool |
|
||||
| `PromotionSectionCapacityService` | Reads and validates `promotion.section_capacity` |
|
||||
| `ScoreBandClassifier` | Assigns A/B/C/D bands |
|
||||
| `BalancedSectionPlacementService` | Generates balanced section assignments |
|
||||
| `SectionPlacementPreviewService` | Creates draft placement preview |
|
||||
| `SectionPlacementFinalizationService` | Writes final class assignments |
|
||||
| `SectionPlacementAuditService` | Stores snapshots and reports |
|
||||
|
||||
Controllers should orchestrate requests, not contain placement policy. Controllers are traffic cops, not the Ministry of Education.
|
||||
|
||||
---
|
||||
|
||||
## 23. Implementation Priority
|
||||
|
||||
### Phase 1: Audit and preserve current data
|
||||
|
||||
```text
|
||||
Document current tables and flows.
|
||||
Add lifecycle/display metadata where needed.
|
||||
Do not silently migrate old rows.
|
||||
Keep old records displayable.
|
||||
```
|
||||
|
||||
### Phase 2: Strengthen pass/fail eligibility
|
||||
|
||||
```text
|
||||
Use `student_decisions` as source of truth.
|
||||
Add reports for missing/pending decisions.
|
||||
Block automatic placement without passed/promoted decision.
|
||||
```
|
||||
|
||||
### Phase 3: Strengthen returning enrollment
|
||||
|
||||
```text
|
||||
Require parent completed returning enrollment.
|
||||
Separate returning enrollment from new-student registration.
|
||||
Admin approval only for new students and exceptions.
|
||||
```
|
||||
|
||||
### Phase 4: Add configurable section capacity
|
||||
|
||||
```text
|
||||
Add/use `promotion.section_capacity` in configuration table.
|
||||
Validate it.
|
||||
Default to 20 only if missing.
|
||||
Store capacity used in placement snapshot.
|
||||
```
|
||||
|
||||
### Phase 5: Build placement pool
|
||||
|
||||
```text
|
||||
Merge eligible returning students and approved new students.
|
||||
Default new students without assessment to D band.
|
||||
Exclude ineligible and incomplete records.
|
||||
Generate exception report.
|
||||
```
|
||||
|
||||
### Phase 6: Generate balanced placement preview
|
||||
|
||||
```text
|
||||
Create sections based on capacity.
|
||||
Group students by score band.
|
||||
Balance section totals first, band mix second.
|
||||
Generate draft preview.
|
||||
```
|
||||
|
||||
### Phase 7: Finalize placement
|
||||
|
||||
```text
|
||||
Validate draft.
|
||||
Admin finalizes.
|
||||
Write class section assignments.
|
||||
Store placement snapshot.
|
||||
```
|
||||
|
||||
### Phase 8: Add audit and reports
|
||||
|
||||
```text
|
||||
Placement batch history.
|
||||
Distribution reports.
|
||||
Override logs.
|
||||
New-student D-band report.
|
||||
Parent enrollment missing report.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 24. Final Policy Summary
|
||||
|
||||
The strong system must follow these rules:
|
||||
|
||||
```text
|
||||
1. New students require admin approval.
|
||||
2. Returning students do not require admin approval unless there is an exception.
|
||||
3. Returning students must pass according to `student_decisions`.
|
||||
4. Returning students must have parent enrollment completed.
|
||||
5. Promotion placement must not recalculate pass/fail.
|
||||
6. Section capacity comes from the `configuration` table using `promotion.section_capacity`.
|
||||
7. If total placement pool size is less than or equal to capacity, create one section.
|
||||
8. If total placement pool size is greater than capacity, create ceil(total / capacity) sections.
|
||||
9. Returning students are grouped by final promotion score bands.
|
||||
10. New students without assessment are placed in D band for balancing only.
|
||||
11. Multiple sections must be balanced by total section size first and score-band mix second.
|
||||
12. Placement must be previewed before finalization.
|
||||
13. Final placement must store a snapshot of the algorithm, capacity, distribution, and assigned students.
|
||||
14. Old data remains displayable and must not be silently changed.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 25. Bottom Line
|
||||
|
||||
The strong model is:
|
||||
|
||||
```text
|
||||
student_decisions = academic eligibility
|
||||
parent returning enrollment = family intent to continue
|
||||
admin approval = new-student admission or exception handling
|
||||
configuration = section capacity
|
||||
placement algorithm = balanced section assignment
|
||||
snapshot = audit trail
|
||||
```
|
||||
|
||||
That separation is the whole point.
|
||||
|
||||
Without it, the system will keep confusing pass/fail, enrollment, and placement. Then someone will ask why a failed student is in the next class, why a parent never enrolled but the child appears in attendance, or why one section has all the high scorers. And then everyone will stare at the database like it committed a moral crime on its own.
|
||||
|
||||
It did not. The design did.
|
||||
@@ -1,315 +0,0 @@
|
||||
# Laravel-Compatible Financial System Plan With Stakeholder Reporting
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This document converts the uploaded financial-system remediation plan into a Laravel-compatible implementation plan for this project.
|
||||
|
||||
The previous plan was written mostly in CodeIgniter terms: `app/Config/Routes.php`, Spark commands, `app/Libraries`, and CI-style controllers. This project is Laravel, so implementation must use Laravel routes, controllers, services, form requests, resources, middleware, policies, migrations, console commands, storage, and Eloquent/DB transactions.
|
||||
|
||||
The goal is not to win a framework cosplay contest. The goal is to fix finance without breaking finance, which is a surprisingly radical position in software.
|
||||
|
||||
## 2. Non-Negotiable Compatibility Rule
|
||||
|
||||
The existing financial system must keep working while improvements are added.
|
||||
|
||||
That means:
|
||||
|
||||
```text
|
||||
Do not delete existing finance routes in the first pass.
|
||||
Do not remove existing PayPal records or controllers until a separate decommission is approved.
|
||||
Do not rewrite payment, invoice, refund, discount, expense, or reimbursement flows destructively.
|
||||
Do not mutate historical invoice math during reporting.
|
||||
Do not silently reinterpret old statuses.
|
||||
Add new services and reporting endpoints beside the old flow first.
|
||||
```
|
||||
|
||||
The original plan says to remove PayPal completely. That may still be a valid future phase, but it is not compatible with the current instruction to keep the old financial system working. Therefore, in this Laravel-compatible version, PayPal removal becomes a later controlled decommission phase, not a first-pass code deletion.
|
||||
|
||||
## 3. Laravel Mapping
|
||||
|
||||
| Old plan concept | Laravel-compatible location |
|
||||
|---|---|
|
||||
| `app/Config/Routes.php` | `routes/api.php`, `routes/web.php` |
|
||||
| `app/Libraries/*` | `app/Services/*` or `app/Support/*` |
|
||||
| CI controller validation | `app/Http/Requests/*` FormRequest classes |
|
||||
| CI controllers | `app/Http/Controllers/Api/*` |
|
||||
| Spark command | Laravel Artisan command in `app/Console/Commands` |
|
||||
| CI filters | Laravel middleware in `app/Http/Middleware` |
|
||||
| CI models | Eloquent models in `app/Models` |
|
||||
| CI views | API resources, frontend pages, or Blade/Inertia only if used |
|
||||
| `WRITEPATH/uploads` | Laravel `storage/app` or `storage/app/private` |
|
||||
| Direct route filters | `auth:api`, custom middleware, policies, and gates |
|
||||
|
||||
## 4. First-Pass Implementation Strategy
|
||||
|
||||
Use an additive approach.
|
||||
|
||||
Phase 1 should add:
|
||||
|
||||
```text
|
||||
StakeholderFinancialAnalysisService
|
||||
StakeholderFinancialAnalysisRequest
|
||||
GET /api/v1/finance/stakeholder-analysis
|
||||
GET /api/v1/finance/stakeholder-analysis/csv
|
||||
administrator aliases for the same stakeholder analysis endpoints
|
||||
Laravel-compatible documentation
|
||||
```
|
||||
|
||||
Phase 1 must not remove:
|
||||
|
||||
```text
|
||||
existing payment routes
|
||||
existing invoice routes
|
||||
existing PayPal routes
|
||||
existing PayPal transaction exports
|
||||
existing payment sync code
|
||||
existing invoice generation code
|
||||
existing financial summary/report endpoints
|
||||
```
|
||||
|
||||
This lets leadership inspect finance without detonating operations. Boring, safe, good. A rare trilogy.
|
||||
|
||||
## 5. Stakeholder Financial Analysis And Reporting
|
||||
|
||||
Stakeholders need management-level financial visibility, not raw operational dumps.
|
||||
|
||||
The report should be read-only and expose:
|
||||
|
||||
```text
|
||||
gross revenue
|
||||
net revenue
|
||||
cash collected
|
||||
refunds
|
||||
discounts
|
||||
expenses
|
||||
reimbursements
|
||||
net cash
|
||||
open receivables
|
||||
collection rate
|
||||
expense ratio
|
||||
operating margin
|
||||
invoice count
|
||||
paying family count
|
||||
payment method mix
|
||||
monthly charge/collection/expense trend
|
||||
receivables aging
|
||||
largest open invoices
|
||||
risk flags
|
||||
school-year comparison
|
||||
CSV export
|
||||
```
|
||||
|
||||
The stakeholder report must not mutate invoices or trigger recalculation. Reports that change accounting are not reports. They are traps with charts.
|
||||
|
||||
## 6. Laravel Endpoint Design
|
||||
|
||||
Add routes under the existing authenticated finance group:
|
||||
|
||||
```php
|
||||
Route::prefix('finance')->group(function () {
|
||||
Route::get('stakeholder-analysis', [FinancialController::class, 'stakeholderAnalysis']);
|
||||
Route::get('stakeholder-analysis/csv', [FinancialController::class, 'stakeholderAnalysisCsv']);
|
||||
});
|
||||
```
|
||||
|
||||
Also add administrator aliases if the admin dashboard uses `/administrator/...` finance paths:
|
||||
|
||||
```php
|
||||
Route::prefix('reports')->group(function () {
|
||||
Route::get('stakeholder-analysis', [FinancialController::class, 'stakeholderAnalysis']);
|
||||
Route::get('stakeholder-analysis/csv', [FinancialController::class, 'stakeholderAnalysisCsv']);
|
||||
});
|
||||
```
|
||||
|
||||
The routes should remain inside existing `auth:api` and `admin.access` protected groups where applicable.
|
||||
|
||||
## 7. Request Validation
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
app/Http/Requests/Finance/StakeholderFinancialAnalysisRequest.php
|
||||
```
|
||||
|
||||
Allowed inputs:
|
||||
|
||||
```text
|
||||
date_from: nullable YYYY-MM-DD
|
||||
date_to: nullable YYYY-MM-DD, after or equal to date_from
|
||||
school_year: nullable string
|
||||
compare_school_year: nullable string
|
||||
include_monthly_trend: nullable boolean
|
||||
include_parent_risk: nullable boolean
|
||||
```
|
||||
|
||||
The request should extend the existing `FinancialReportRequest` to preserve behavior and avoid yet another tiny validation kingdom.
|
||||
|
||||
## 8. Service Design
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
app/Services/Finance/StakeholderFinancialAnalysisService.php
|
||||
```
|
||||
|
||||
Responsibilities:
|
||||
|
||||
```text
|
||||
read existing invoices, payments, refunds, discounts, expenses, reimbursements, extra charges, and event charges
|
||||
exclude voided/failed/canceled records from management totals
|
||||
calculate management KPIs
|
||||
build monthly trends
|
||||
build receivables aging
|
||||
build payment method mix
|
||||
build expense category mix
|
||||
compare selected school year against another school year
|
||||
produce CSV rows
|
||||
return JSON-ready arrays
|
||||
```
|
||||
|
||||
The service must use `Schema::hasTable()` and `Schema::hasColumn()` guards where the legacy schema is inconsistent. This project has legacy compatibility layers, so brittle assumptions are how bugs sneak in wearing a school hoodie.
|
||||
|
||||
## 9. Keep Old Financial System Working
|
||||
|
||||
In the first pass, do not replace the existing `FinancialSummaryService`, `FinancialReportService`, `PaymentController`, `InvoiceController`, `RefundController`, or PayPal controllers.
|
||||
|
||||
Instead:
|
||||
|
||||
```text
|
||||
Reuse existing financial tables.
|
||||
Reuse existing auth groups.
|
||||
Add read-only reporting beside the current operations.
|
||||
Leave existing payment/invoice mutation paths untouched.
|
||||
Do not force status normalization yet.
|
||||
Do not force invoice recalculation yet.
|
||||
```
|
||||
|
||||
Once stakeholder reporting is stable, a later phase can introduce a centralized Laravel ledger service behind feature flags.
|
||||
|
||||
## 10. Later Laravel Ledger Phase
|
||||
|
||||
The old CodeIgniter-style `InvoiceLedgerService` recommendation should become:
|
||||
|
||||
```text
|
||||
app/Services/Finance/InvoiceLedgerService.php
|
||||
```
|
||||
|
||||
It should be introduced behind a config flag:
|
||||
|
||||
```text
|
||||
finance.ledger_mode = legacy|centralized
|
||||
```
|
||||
|
||||
When `legacy`, existing controllers continue using current behavior.
|
||||
When `centralized`, payment/refund/discount/additional-charge writes call the ledger service after database writes.
|
||||
|
||||
Every write should eventually use:
|
||||
|
||||
```php
|
||||
DB::transaction(function () use ($invoiceId) {
|
||||
Invoice::query()->whereKey($invoiceId)->lockForUpdate()->firstOrFail();
|
||||
// create/update financial record
|
||||
$this->invoiceLedgerService->recalculate($invoiceId);
|
||||
});
|
||||
```
|
||||
|
||||
But do not force that transition before tests exist. Accounting rewrites without tests are just gambling with extra syntax.
|
||||
|
||||
## 11. Tuition Forecast Compatibility
|
||||
|
||||
The tuition forecast idea from the uploaded plan is valid, but it should be Laravelized:
|
||||
|
||||
```text
|
||||
app/Services/Finance/Tuition/OldTuitionCalculatorService.php
|
||||
app/Services/Finance/Tuition/NewTuitionCalculatorService.php
|
||||
app/Services/Finance/Tuition/TuitionForecastService.php
|
||||
app/Http/Requests/Finance/TuitionForecastRequest.php
|
||||
app/Http/Controllers/Api/Finance/TuitionForecastController.php
|
||||
```
|
||||
|
||||
The old calculator must remain the default until leadership approves switching invoice generation.
|
||||
|
||||
Use:
|
||||
|
||||
```text
|
||||
tuition_calculator_version = old|new
|
||||
```
|
||||
|
||||
stored in the existing `configuration` table through `Configuration::getConfigValueByKey()` / `setConfigValueByKey()`.
|
||||
|
||||
## 12. PayPal Compatibility Position
|
||||
|
||||
Because the current instruction is to keep the old financial system working, PayPal is not removed in this pass.
|
||||
|
||||
Instead:
|
||||
|
||||
```text
|
||||
Keep existing PayPal routes/controllers/models active.
|
||||
Keep historical PayPal reports available.
|
||||
Do not drop or rename PayPal tables.
|
||||
Document PayPal as legacy.
|
||||
Plan separate decommission only after staff confirms no operational dependency remains.
|
||||
```
|
||||
|
||||
Future PayPal decommission should be its own release with:
|
||||
|
||||
```text
|
||||
route inventory
|
||||
frontend dependency scan
|
||||
data archive migration
|
||||
rollback plan
|
||||
parent communication
|
||||
post-release monitoring
|
||||
```
|
||||
|
||||
## 13. Reporting Security
|
||||
|
||||
Stakeholder financial reporting should be restricted to authenticated admin/finance users.
|
||||
|
||||
Minimum:
|
||||
|
||||
```text
|
||||
Route inside auth:api group.
|
||||
Route inside administrator/admin finance area when exposed to admin UI.
|
||||
Add permission middleware later if the permission catalog has a finance-report permission.
|
||||
```
|
||||
|
||||
Preferred future middleware:
|
||||
|
||||
```php
|
||||
->middleware(['auth:api', 'permission:view_financial_reports'])
|
||||
```
|
||||
|
||||
Do not expose stakeholder reports to ordinary parent users. This should not need saying, but software history suggests otherwise.
|
||||
|
||||
## 14. Acceptance Criteria
|
||||
|
||||
This Laravel-compatible phase is complete when:
|
||||
|
||||
```text
|
||||
Existing finance endpoints still work.
|
||||
Existing PayPal endpoints still exist.
|
||||
New stakeholder analysis JSON endpoint works.
|
||||
New stakeholder analysis CSV endpoint works.
|
||||
No financial write flow is changed.
|
||||
No invoice is recalculated by the stakeholder report.
|
||||
The report handles missing optional legacy columns safely.
|
||||
The code passes PHP lint.
|
||||
The plan is stored in docs/ for future implementation work.
|
||||
```
|
||||
|
||||
## 15. Next Hardening Steps
|
||||
|
||||
After the additive reporting phase:
|
||||
|
||||
```text
|
||||
Add tests for stakeholder report calculations.
|
||||
Add permission middleware for finance reports.
|
||||
Add centralized InvoiceLedgerService behind a feature flag.
|
||||
Add a dry-run invoice recalculation Artisan command.
|
||||
Add tuition forecast services.
|
||||
Normalize financial statuses through migrations only after database backup.
|
||||
Design a separate PayPal decommission release.
|
||||
```
|
||||
|
||||
The brutal truth: the uploaded plan had the right instincts, but it assumed the wrong framework and moved too aggressively on removal. In this project, compatibility wins first. Then hardening. Then cleanup. In that order, unless the goal is accounting chaos with a nicer namespace.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,45 +0,0 @@
|
||||
# Islamic Sunday School Modular Plan Update Index
|
||||
|
||||
This bundle updates the global modular plan and Phases 1-6 so the extension domain is explicitly **Islamic Sunday School**.
|
||||
|
||||
Core rule: `SchoolCore` remains neutral. Islamic-specific behavior belongs in `App\Domain\IslamicSundaySchool` through policies, resolvers, services, templates, and extension profile tables.
|
||||
|
||||
## Updated Files
|
||||
|
||||
- `app_project_plan_global_modular_islamic.md`
|
||||
- `phase_1_school_context_execution_plan_islamic.md`
|
||||
- `phase_2_core_contracts_execution_plan_islamic.md`
|
||||
- `phase_3_modular_finance_execution_plan_islamic.md`
|
||||
- `phase_4_modular_attendance_execution_plan_islamic.md`
|
||||
- `phase_5_modular_student_lifecycle_execution_plan_islamic.md`
|
||||
- `phase_6_modular_communication_execution_plan_islamic.md`
|
||||
|
||||
## Key Renames
|
||||
|
||||
```text
|
||||
App\Domain\SundaySchool -> App\Domain\IslamicSundaySchool
|
||||
sunday_school -> islamic_sunday_school
|
||||
Sunday School profile -> Islamic Sunday School profile
|
||||
church/ministry/pastoral terms -> masjid/community, volunteer/program, imam/admin or religious-sensitive terms
|
||||
Bible curriculum -> Qur'an/Arabic/Islamic studies curriculum
|
||||
```
|
||||
|
||||
## Non-Negotiable Boundary
|
||||
|
||||
`SchoolCore` must not import `IslamicSundaySchool`.
|
||||
|
||||
Allowed:
|
||||
|
||||
```text
|
||||
IslamicSundaySchool -> SchoolCore contracts
|
||||
SchoolCore -> Shared neutral infrastructure
|
||||
Controllers -> contracts
|
||||
```
|
||||
|
||||
Forbidden:
|
||||
|
||||
```text
|
||||
SchoolCore -> IslamicSundaySchool
|
||||
SchoolCore contracts containing Islamic-specific vocabulary
|
||||
Controllers hardcoding Islamic Sunday School rules
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,764 +0,0 @@
|
||||
# Phase 1 Execution Plan: SchoolContext Foundation
|
||||
|
||||
|
||||
# Islamic Sunday School Alignment Update
|
||||
|
||||
Phase 1 must support **Islamic Sunday School** as a domain profile, not generic Sunday School or church-oriented Sunday School.
|
||||
|
||||
Add the domain profile value:
|
||||
|
||||
```text
|
||||
islamic_sunday_school
|
||||
```
|
||||
|
||||
`SchoolContext` may expose this profile neutrally as `domain_profile`, but `SchoolCore` services must not branch on Islamic-specific concepts directly. Any Islamic Sunday School behavior must be resolved through extension bindings, policy contracts, or context-aware providers.
|
||||
|
||||
Examples of extension-only concepts:
|
||||
|
||||
```text
|
||||
masjid_id
|
||||
program_session_id
|
||||
halaqa_id
|
||||
quran_level_id
|
||||
arabic_level_id
|
||||
islamic_studies_track_id
|
||||
volunteer_team_id
|
||||
imam_admin_note_permission
|
||||
```
|
||||
|
||||
The core still uses neutral terms: `school`, `term`, `session`, `program`, `group`, `family`, `guardian`, `student`, and `staff`.
|
||||
|
||||
---
|
||||
## 1. Objective
|
||||
|
||||
Create a mandatory, immutable `SchoolContext` foundation so every reusable service can operate safely across different school systems.
|
||||
|
||||
This phase is the base layer for the modular platform. Without it, `SchoolCore` cannot be reliably reused by Islamic Sunday School, traditional schools, training centers, after-school programs, or any other future domain. A modular platform without tenant/context isolation is just a data leak wearing a nicer folder structure.
|
||||
|
||||
## 2. Target Outcome
|
||||
|
||||
At the end of Phase 1:
|
||||
|
||||
- Every new core service receives an explicit `SchoolContext`.
|
||||
- Controllers no longer rely only on scattered `auth()`, request values, global configuration, or session-like assumptions to determine school scope.
|
||||
- Cross-school access fails by default.
|
||||
- Islamic Sunday School can resolve its own domain profile through context, without forcing Islamic Sunday School concepts into global services.
|
||||
- Finance, attendance, students, files, reporting, and communication can begin migration into `SchoolCore` safely.
|
||||
|
||||
## 3. Current Problem to Fix
|
||||
|
||||
The current app has school/year/semester context scattered across:
|
||||
|
||||
- authenticated user fields such as `school_id`, `school_year`, and `semester`;
|
||||
- global configuration access such as `Configuration::getConfig('school_year')` and `Configuration::getConfig('semester')`;
|
||||
- request query/body values;
|
||||
- controller-specific assumptions;
|
||||
- service-level fallback behavior;
|
||||
- model methods that directly read global configuration;
|
||||
- finance, attendance, reporting, and student queries that depend on string school-year/semester filters.
|
||||
|
||||
This makes the app hard to reuse for multiple school types because context is implicit instead of explicit.
|
||||
|
||||
## 4. Design Principle
|
||||
|
||||
`SchoolContext` is not a convenience object. It is a safety boundary.
|
||||
|
||||
Core rule:
|
||||
|
||||
```php
|
||||
SchoolCore services must not guess school, year, term, actor, timezone, currency, or domain profile.
|
||||
```
|
||||
|
||||
They must receive those values from an explicit context object created at the API boundary.
|
||||
|
||||
## 5. Proposed Namespace Structure
|
||||
|
||||
Create these namespaces:
|
||||
|
||||
```txt
|
||||
app/
|
||||
Domain/
|
||||
SchoolCore/
|
||||
Context/
|
||||
SchoolContext.php
|
||||
SchoolContextResolver.php
|
||||
SchoolContextFactory.php
|
||||
SchoolContextValidator.php
|
||||
SchoolContextStore.php
|
||||
SchoolContextException.php
|
||||
Contracts/
|
||||
RequiresSchoolContext.php
|
||||
Support/
|
||||
SchoolContextAware.php
|
||||
Http/
|
||||
Middleware/
|
||||
ResolveSchoolContext.php
|
||||
```
|
||||
|
||||
Optional later namespaces:
|
||||
|
||||
```txt
|
||||
app/
|
||||
Domain/
|
||||
IslamicSundaySchool/
|
||||
Context/
|
||||
IslamicSundaySchoolContextExtender.php
|
||||
```
|
||||
|
||||
Do not create the Islamic Sunday School extender in Phase 1 unless there is a concrete field or rule that cannot live in the neutral core context. Premature extension objects are how clean architecture becomes a museum of unused abstractions.
|
||||
|
||||
## 6. `SchoolContext` Value Object
|
||||
|
||||
### 6.1 Required Fields
|
||||
|
||||
Initial version:
|
||||
|
||||
```php
|
||||
final readonly class SchoolContext
|
||||
{
|
||||
public function __construct(
|
||||
public int $schoolId,
|
||||
public int $actorUserId,
|
||||
public array $actorRoleIds,
|
||||
public ?string $schoolYear,
|
||||
public ?string $term,
|
||||
public string $timezone,
|
||||
public string $locale,
|
||||
public string $currency,
|
||||
public string $domainProfile,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Field Rules
|
||||
|
||||
| Field | Required? | Purpose |
|
||||
|---|---:|---|
|
||||
| `schoolId` | Yes | Primary isolation boundary. Every scoped query must use it where schema supports it. |
|
||||
| `actorUserId` | Yes | Audit, authorization, ownership checks. |
|
||||
| `actorRoleIds` | Yes | Policy/gate checks without repeated role queries. |
|
||||
| `schoolYear` | Nullable during migration | Current academic year or legacy string value. |
|
||||
| `term` | Nullable during migration | Current term/semester. Keep name neutral in core. |
|
||||
| `timezone` | Yes | Date boundaries for attendance, deadlines, reports, finance timestamps. |
|
||||
| `locale` | Yes | Formatting, translation, report language. |
|
||||
| `currency` | Yes | Finance defaults. |
|
||||
| `domainProfile` | Yes | Example: `standard_school`, `islamic_sunday_school`, `training_center`. |
|
||||
|
||||
### 6.3 Naming Decision
|
||||
|
||||
Use `term` in the core context, not `semester`.
|
||||
|
||||
Reason: `semester` is one possible academic model. A global school platform may use quarters, trimesters, sessions, Sunday program cycles, training cohorts, or rolling terms. Use `semester` only inside legacy adapters until migration is complete.
|
||||
|
||||
## 7. Context Resolution Strategy
|
||||
|
||||
### 7.1 Resolution Priority
|
||||
|
||||
`SchoolContextResolver` should resolve context in this order:
|
||||
|
||||
1. Authenticated user from JWT/Auth.
|
||||
2. Explicit school selector from request header or route, if allowed.
|
||||
3. User default school, currently likely `users.school_id`.
|
||||
4. Current school year/term from request, if allowed and validated.
|
||||
5. Config fallback for legacy behavior, such as current `Configuration::getConfig('school_year')` and `Configuration::getConfig('semester')`.
|
||||
6. Safe defaults for timezone/locale/currency.
|
||||
|
||||
### 7.2 Recommended Request Headers
|
||||
|
||||
Support these headers during migration:
|
||||
|
||||
```txt
|
||||
X-School-Id
|
||||
X-School-Year
|
||||
X-School-Term
|
||||
X-Domain-Profile
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `X-School-Id` may only switch context if the actor belongs to that school or has platform-level permission.
|
||||
- `X-School-Year` and `X-School-Term` must be validated against available academic periods once those models exist.
|
||||
- `X-Domain-Profile` must not override server-side school configuration unless explicitly allowed for test/dev tooling.
|
||||
|
||||
## 8. Middleware Plan
|
||||
|
||||
### 8.1 Create `ResolveSchoolContext`
|
||||
|
||||
Middleware behavior:
|
||||
|
||||
```php
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$context = $this->resolver->resolve($request);
|
||||
$this->validator->assertValid($context, $request->user());
|
||||
$this->store->set($context);
|
||||
$request->attributes->set(SchoolContext::class, $context);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 Middleware Order
|
||||
|
||||
Run after authentication and before authorization-sensitive controllers:
|
||||
|
||||
```txt
|
||||
ApiJwtAuth
|
||||
ResolveSchoolContext
|
||||
RequirePermission / policies / controller
|
||||
```
|
||||
|
||||
Do not run it before authentication unless supporting public routes. Public routes should either not use `SchoolContext`, or should use an explicit public context resolver with sharply limited behavior.
|
||||
|
||||
### 8.3 Route Group Rollout
|
||||
|
||||
Start with high-risk route groups only:
|
||||
|
||||
```txt
|
||||
/api/finance/*
|
||||
/api/payments/*
|
||||
/api/invoices/*
|
||||
/api/students/*
|
||||
/api/attendance/*
|
||||
/api/scanner/*
|
||||
/api/files/*
|
||||
/api/reports/*
|
||||
/api/messages/*
|
||||
/api/roles/*
|
||||
```
|
||||
|
||||
Then expand to all authenticated API routes after compatibility is verified.
|
||||
|
||||
## 9. Service Container Bindings
|
||||
|
||||
Create container bindings:
|
||||
|
||||
```php
|
||||
$this->app->singleton(SchoolContextStore::class);
|
||||
$this->app->bind(SchoolContextResolver::class);
|
||||
$this->app->bind(SchoolContextValidator::class);
|
||||
$this->app->bind(SchoolContextFactory::class);
|
||||
```
|
||||
|
||||
Add a helper only if necessary:
|
||||
|
||||
```php
|
||||
school_context(): SchoolContext
|
||||
```
|
||||
|
||||
Do not let the helper become the main service API. Core services should receive context explicitly in command DTOs or method arguments. A global helper is useful for legacy adapters, not for clean new code. Humans love turning helpers into hidden dependencies, and then act surprised when tests are brittle.
|
||||
|
||||
## 10. Service Usage Pattern
|
||||
|
||||
### 10.1 Preferred Command DTO Pattern
|
||||
|
||||
```php
|
||||
final readonly class RecordPaymentCommand
|
||||
{
|
||||
public function __construct(
|
||||
public SchoolContext $context,
|
||||
public int $invoiceId,
|
||||
public int $studentId,
|
||||
public string $method,
|
||||
public int $amountCents,
|
||||
public ?UploadedFile $file,
|
||||
public ?string $idempotencyKey,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 Allowed Transitional Pattern
|
||||
|
||||
```php
|
||||
public function recordPayment(SchoolContext $context, array $payload): PaymentResult
|
||||
```
|
||||
|
||||
### 10.3 Forbidden New Pattern
|
||||
|
||||
```php
|
||||
public function recordPayment(array $payload): PaymentResult
|
||||
{
|
||||
$user = auth()->user();
|
||||
$schoolYear = Configuration::getConfig('school_year');
|
||||
}
|
||||
```
|
||||
|
||||
That pattern is legacy-only and must be marked as such.
|
||||
|
||||
## 11. Repository and Query Scoping
|
||||
|
||||
### 11.1 Scope Helper
|
||||
|
||||
Create a reusable query helper or trait:
|
||||
|
||||
```php
|
||||
trait AppliesSchoolContext
|
||||
{
|
||||
protected function applySchoolScope(Builder $query, SchoolContext $context, string $column = 'school_id'): Builder
|
||||
{
|
||||
return $query->where($column, $context->schoolId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 11.2 Legacy Schema Handling
|
||||
|
||||
Some tables may not have `school_id`. For Phase 1, classify tables into:
|
||||
|
||||
| Type | Handling |
|
||||
|---|---|
|
||||
| Has `school_id` | Apply strict `school_id` filter. |
|
||||
| Has academic year/term only | Apply `schoolYear` and `term` filters as legacy scope. |
|
||||
| Global lookup table | Explicitly mark as global. |
|
||||
| Missing required scope | Add to schema remediation backlog. |
|
||||
|
||||
Do not silently use `Schema::hasColumn()` inside business logic forever. Temporary schema detection is acceptable only inside migration adapters with comments and removal tickets.
|
||||
|
||||
## 12. Migration Strategy
|
||||
|
||||
### Step 1: Add classes without changing behavior
|
||||
|
||||
Create:
|
||||
|
||||
- `SchoolContext`
|
||||
- `SchoolContextFactory`
|
||||
- `SchoolContextResolver`
|
||||
- `SchoolContextValidator`
|
||||
- `SchoolContextStore`
|
||||
- `ResolveSchoolContext` middleware
|
||||
|
||||
Add tests for object creation and resolution.
|
||||
|
||||
### Step 2: Resolve context for authenticated routes
|
||||
|
||||
Enable middleware in a small route group first. Recommended first group:
|
||||
|
||||
```txt
|
||||
finance/payment read-only endpoint or student read-only endpoint
|
||||
```
|
||||
|
||||
Avoid starting with payment mutation until context resolution has tests. Bravery is not a rollout strategy.
|
||||
|
||||
### Step 3: Add context to audit/logging
|
||||
|
||||
Every high-risk action should have actor and school context available, even before full service refactor.
|
||||
|
||||
Add to logs where possible:
|
||||
|
||||
```txt
|
||||
school_id
|
||||
actor_user_id
|
||||
school_year
|
||||
term
|
||||
domain_profile
|
||||
request_id
|
||||
```
|
||||
|
||||
### Step 4: Convert first vertical slice
|
||||
|
||||
Choose one low-risk read path first:
|
||||
|
||||
```txt
|
||||
Student list/read
|
||||
```
|
||||
|
||||
Then one high-risk mutation path:
|
||||
|
||||
```txt
|
||||
Manual payment record/edit after tests exist
|
||||
```
|
||||
|
||||
### Step 5: Enforce context in new services
|
||||
|
||||
Any new `SchoolCore` service must accept context. No exceptions except explicitly global lookup services.
|
||||
|
||||
## 13. First Vertical Slice: Student Read Context
|
||||
|
||||
Use student read/list endpoints as the first validation slice because they are easier to test than finance mutations.
|
||||
|
||||
### Tasks
|
||||
|
||||
- Add `ResolveSchoolContext` to student read route group.
|
||||
- Inject/request `SchoolContext` in controller.
|
||||
- Pass context to student query service.
|
||||
- Apply `school_id` scope where available.
|
||||
- If student table lacks correct school scoping, document schema gap.
|
||||
- Add wrong-school read test.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- User from School A can list School A students.
|
||||
- User from School A cannot list School B students.
|
||||
- Missing/invalid context returns `403` or `422`, not a silent fallback.
|
||||
- Existing valid Islamic Sunday School requests still work.
|
||||
|
||||
## 14. Second Vertical Slice: Payment File Access Context
|
||||
|
||||
This should connect to the P0 fix already identified.
|
||||
|
||||
### Tasks
|
||||
|
||||
- Add context to payment file download route.
|
||||
- Change route from filename-only to payment/file-id-based access.
|
||||
- Load payment under `school_id` context.
|
||||
- Authorize actor against payment/invoice/family/finance role.
|
||||
- Resolve physical file only from stored payment record.
|
||||
- Log access attempt with context.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Finance/admin from same school can download payment file.
|
||||
- Parent/guardian can download only if policy allows and relationship matches.
|
||||
- User from another school cannot download even if they know the filename.
|
||||
- Unknown payment returns `404`.
|
||||
- Known payment with unauthorized actor returns `403`.
|
||||
|
||||
## 15. Validation Rules
|
||||
|
||||
`SchoolContextValidator` must check:
|
||||
|
||||
- actor user exists;
|
||||
- actor is active/not suspended;
|
||||
- `schoolId` is positive;
|
||||
- actor belongs to selected school;
|
||||
- selected school is active;
|
||||
- selected year/term is allowed for selected school when academic-period tables are available;
|
||||
- timezone is valid IANA timezone;
|
||||
- locale is supported;
|
||||
- currency is valid ISO currency code;
|
||||
- domain profile is supported.
|
||||
|
||||
During migration, when academic period tables are not yet reliable, log warnings instead of blocking all requests. Do not pretend this is perfect. Transitional systems lie unless you make the lies visible.
|
||||
|
||||
## 16. Authorization Integration
|
||||
|
||||
Policies and gates should receive or resolve context.
|
||||
|
||||
Preferred:
|
||||
|
||||
```php
|
||||
$this->authorize('view', [$student, $context]);
|
||||
```
|
||||
|
||||
Alternative transitional behavior:
|
||||
|
||||
```php
|
||||
$context = app(SchoolContextStore::class)->current();
|
||||
$this->authorize('view', $student);
|
||||
```
|
||||
|
||||
Policy checks must compare resource scope against context:
|
||||
|
||||
```php
|
||||
return $student->school_id === $context->schoolId;
|
||||
```
|
||||
|
||||
If a model does not have `school_id`, the policy must use a documented relationship path.
|
||||
|
||||
## 17. Jobs, Events, and Notifications
|
||||
|
||||
Any job created from a scoped action must carry context values explicitly.
|
||||
|
||||
### Required Job Fields
|
||||
|
||||
```txt
|
||||
school_id
|
||||
actor_user_id
|
||||
school_year
|
||||
term
|
||||
domain_profile
|
||||
locale
|
||||
timezone
|
||||
request_id or correlation_id
|
||||
```
|
||||
|
||||
Do not let queued jobs recalculate context from the current logged-in user. There is no current logged-in user in a worker. This is apparently easy to forget until production sends the wrong family a message.
|
||||
|
||||
## 18. Files and Storage
|
||||
|
||||
File services must receive context.
|
||||
|
||||
Recommended storage path pattern:
|
||||
|
||||
```txt
|
||||
schools/{school_id}/{module}/{resource_type}/{resource_id}/{filename}
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```txt
|
||||
schools/12/payments/checks/8821/check_8821.pdf
|
||||
schools/12/students/documents/440/immunization.pdf
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- never serve files by arbitrary filename alone;
|
||||
- never infer authorization from path;
|
||||
- always authorize resource first;
|
||||
- path resolution happens after authorization;
|
||||
- logs include context and resource ID.
|
||||
|
||||
## 19. Reporting
|
||||
|
||||
Reports must include context in every query.
|
||||
|
||||
Minimum rules:
|
||||
|
||||
- every report query must accept `SchoolContext`;
|
||||
- report export filenames should include school/year/term where appropriate;
|
||||
- cross-school report access must fail;
|
||||
- raw SQL reports must bind context parameters explicitly.
|
||||
|
||||
## 20. Test Plan
|
||||
|
||||
### 20.1 Unit Tests
|
||||
|
||||
Create tests for:
|
||||
|
||||
- `SchoolContext` construction;
|
||||
- invalid school ID rejection;
|
||||
- invalid timezone rejection;
|
||||
- invalid currency rejection;
|
||||
- resolver reads authenticated user school;
|
||||
- resolver rejects unauthorized `X-School-Id` switch;
|
||||
- resolver applies legacy school year/term fallback;
|
||||
- context store returns current context;
|
||||
- context store throws when missing context.
|
||||
|
||||
### 20.2 Feature Tests
|
||||
|
||||
Create tests for:
|
||||
|
||||
- authenticated route receives context;
|
||||
- unauthenticated route cannot resolve protected context;
|
||||
- School A user cannot access School B student;
|
||||
- School A finance user cannot access School B payment file;
|
||||
- `X-School-Id` override works only for authorized multi-school/platform actor;
|
||||
- timezone affects attendance date boundary;
|
||||
- context is logged for high-risk mutation.
|
||||
|
||||
### 20.3 Architecture Tests
|
||||
|
||||
Add static tests/rules:
|
||||
|
||||
```txt
|
||||
SchoolCore services must not call auth()
|
||||
SchoolCore services must not read Request directly
|
||||
SchoolCore services must not call Configuration::getConfig directly
|
||||
SchoolCore services must accept SchoolContext for scoped operations
|
||||
SchoolCore must not import IslamicSundaySchool namespace
|
||||
```
|
||||
|
||||
## 21. Implementation Tickets
|
||||
|
||||
### SCX-001: Create `SchoolContext` value object
|
||||
|
||||
**Goal:** Define immutable context object used by core services.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- Create `App\Domain\SchoolCore\Context\SchoolContext`.
|
||||
- Add constructor fields listed in this plan.
|
||||
- Add helper methods:
|
||||
- `isIslamicSundaySchool(): bool`
|
||||
- `requiresTerm(): bool`
|
||||
- `auditPayload(): array`
|
||||
- `withTerm(?string $term): self`
|
||||
- Add unit tests.
|
||||
|
||||
**Done when:** object is immutable, tested, and contains no request/auth dependencies.
|
||||
|
||||
### SCX-002: Create context resolver and factory
|
||||
|
||||
**Goal:** Build context from authenticated request safely.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- Create `SchoolContextFactory`.
|
||||
- Create `SchoolContextResolver`.
|
||||
- Read user, headers, legacy config, and defaults.
|
||||
- Validate switch permissions for `X-School-Id`.
|
||||
- Return deterministic context.
|
||||
|
||||
**Done when:** resolver works for normal user, admin user, invalid school, and missing school cases.
|
||||
|
||||
### SCX-003: Create context validator
|
||||
|
||||
**Goal:** Prevent invalid or unauthorized context.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- Create `SchoolContextValidator`.
|
||||
- Validate actor belongs to selected school.
|
||||
- Validate school active state where model exists.
|
||||
- Validate timezone/locale/currency/domain profile.
|
||||
- Add migration warnings for missing academic period tables.
|
||||
|
||||
**Done when:** invalid contexts fail before controller logic executes.
|
||||
|
||||
### SCX-004: Add middleware
|
||||
|
||||
**Goal:** Attach context to authenticated API requests.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- Create `ResolveSchoolContext` middleware.
|
||||
- Register middleware alias.
|
||||
- Add to selected route groups after auth.
|
||||
- Store context in request attributes and `SchoolContextStore`.
|
||||
|
||||
**Done when:** protected routes can retrieve context and tests prove middleware order.
|
||||
|
||||
### SCX-005: Add query scoping helpers
|
||||
|
||||
**Goal:** Make school scoping repeatable.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- Create `AppliesSchoolContext` trait or query helper.
|
||||
- Add strict school scope behavior.
|
||||
- Add legacy year/term scope behavior.
|
||||
- Add table classification list for global vs scoped tables.
|
||||
|
||||
**Done when:** first read service uses helper and wrong-school reads fail.
|
||||
|
||||
### SCX-006: Convert first student read/list slice
|
||||
|
||||
**Goal:** Prove context works on a low-risk endpoint.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- Add context middleware to selected student route.
|
||||
- Pass context to student query service.
|
||||
- Scope query by school.
|
||||
- Add cross-school tests.
|
||||
|
||||
**Done when:** same-school student reads pass and cross-school reads fail.
|
||||
|
||||
### SCX-007: Convert payment file access slice
|
||||
|
||||
**Goal:** Use context on a high-risk P0 issue.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- Replace filename-only access with payment-id-based access.
|
||||
- Load payment using context.
|
||||
- Authorize resource before file path resolution.
|
||||
- Add logs and tests.
|
||||
|
||||
**Done when:** guessed filenames cannot access files and cross-school access fails.
|
||||
|
||||
### SCX-008: Add job/event context payload convention
|
||||
|
||||
**Goal:** Prevent async work from losing school/actor context.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- Define `ContextPayload` array format.
|
||||
- Add helper to create payload from `SchoolContext`.
|
||||
- Update one notification or payment event as pilot.
|
||||
- Add tests.
|
||||
|
||||
**Done when:** queued pilot job does not depend on live auth/session/request state.
|
||||
|
||||
### SCX-009: Add architecture enforcement
|
||||
|
||||
**Goal:** Stop new code from bypassing context.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- Add static/architecture tests.
|
||||
- Ban `auth()`, `request()`, and direct `Configuration::getConfig()` inside new `SchoolCore` services.
|
||||
- Ban `SchoolCore -> IslamicSundaySchool` imports.
|
||||
|
||||
**Done when:** CI fails on forbidden dependency direction or hidden context access.
|
||||
|
||||
## 22. Rollout Timeline by Workstream
|
||||
|
||||
### Workstream A: Foundation
|
||||
|
||||
1. `SchoolContext`
|
||||
2. Factory/resolver/validator
|
||||
3. Middleware/store
|
||||
4. Tests
|
||||
|
||||
### Workstream B: First Usage
|
||||
|
||||
1. Student read/list
|
||||
2. Payment file access
|
||||
3. Context-aware logging
|
||||
|
||||
### Workstream C: Enforcement
|
||||
|
||||
1. Static rules
|
||||
2. Architecture tests
|
||||
3. Migration checklist for all future services
|
||||
|
||||
## 23. Acceptance Criteria for Phase 1
|
||||
|
||||
Phase 1 is complete only when all of this is true:
|
||||
|
||||
- `SchoolContext` exists and is immutable.
|
||||
- Context is resolved for selected authenticated API routes.
|
||||
- Context includes school, actor, year/term, timezone, locale, currency, and domain profile.
|
||||
- Cross-school student read test passes.
|
||||
- Cross-school payment file access test passes.
|
||||
- At least one read service and one high-risk access path use explicit context.
|
||||
- New `SchoolCore` services are forbidden from reading request/auth/config directly.
|
||||
- `SchoolCore` cannot depend on `IslamicSundaySchool` namespace.
|
||||
- Islamic Sunday School still works through context profile and legacy adapters.
|
||||
- Known schema gaps are documented instead of silently hidden.
|
||||
|
||||
## 24. Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|---|---:|---|
|
||||
| Tables lack `school_id` | Cross-school isolation may be incomplete | Classify tables and add schema remediation tickets. |
|
||||
| Legacy code depends on global config | Context behavior may differ from old behavior | Use resolver fallback during migration and log warnings. |
|
||||
| Too much migration at once | High regression risk | Start with student read/list, then payment file access. |
|
||||
| Developers bypass context | Architecture decay | Add static rules and CI checks. |
|
||||
| Islamic Sunday School assumptions leak into core | Platform becomes non-reusable | Enforce dependency direction and neutral vocabulary. |
|
||||
|
||||
## 25. Non-Goals
|
||||
|
||||
Phase 1 does not include:
|
||||
|
||||
- moving every service into `SchoolCore`;
|
||||
- fully refactoring finance;
|
||||
- fully refactoring attendance;
|
||||
- replacing all `semester` fields;
|
||||
- adding a full multi-tenant school administration UI;
|
||||
- converting every controller;
|
||||
- renaming every legacy table.
|
||||
|
||||
Trying to do all of that in Phase 1 would be how a refactor becomes a bonfire.
|
||||
|
||||
## 26. Immediate Next Implementation Order
|
||||
|
||||
Use this order:
|
||||
|
||||
```txt
|
||||
1. Create SchoolContext value object
|
||||
2. Create resolver/factory/validator/store
|
||||
3. Add middleware after ApiJwtAuth
|
||||
4. Add unit tests for resolver and validator
|
||||
5. Add student read/list context slice
|
||||
6. Add cross-school student tests
|
||||
7. Add payment file context slice
|
||||
8. Add cross-school payment file tests
|
||||
9. Add architecture rules
|
||||
10. Document remaining schema gaps
|
||||
```
|
||||
|
||||
## 27. Definition of Success
|
||||
|
||||
A developer should be able to write this in a future core service:
|
||||
|
||||
```php
|
||||
public function listStudents(SchoolContext $context, StudentListQuery $query): StudentListResult
|
||||
```
|
||||
|
||||
And the service should not need to know whether the school is a Islamic Sunday School, a private academy, a tutoring center, or a training program.
|
||||
|
||||
That is the point. The global platform owns the universal school workflow. Islamic Sunday School supplies policies, labels, calendars, and domain-specific extensions. The core stays boring, reusable, and annoyingly difficult to misuse. Good architecture is mostly removing opportunities for future nonsense.
|
||||
@@ -1,942 +0,0 @@
|
||||
# Phase 2 Execution Plan: Core Contracts and Module Boundaries
|
||||
|
||||
|
||||
# Islamic Sunday School Alignment Update
|
||||
|
||||
Phase 2 must define contracts that support **Islamic Sunday School** through extension implementations, not by adding Islamic-specific language to the core interfaces.
|
||||
|
||||
Preferred extension namespace:
|
||||
|
||||
```text
|
||||
App\Domain\IslamicSundaySchool
|
||||
```
|
||||
|
||||
The core contracts remain school-neutral. Islamic Sunday School implementations may bind policies for:
|
||||
|
||||
```text
|
||||
Qur'an progression
|
||||
Arabic level placement
|
||||
Islamic studies tracks
|
||||
halaqa/class grouping
|
||||
masjid family/community profile
|
||||
program-session attendance
|
||||
volunteer team communication
|
||||
tuition, sadaqah, scholarship, and fee classification
|
||||
```
|
||||
|
||||
Do not put these terms in `SchoolCore` contracts. The contracts define capabilities. The extension defines Islamic Sunday School behavior.
|
||||
|
||||
---
|
||||
## Purpose
|
||||
|
||||
Phase 2 establishes the contract layer for the modular school platform. The goal is to define stable interfaces for shared school operations before moving implementation logic into `SchoolCore`.
|
||||
|
||||
This phase prevents the platform from becoming Sunday-School-shaped by accident. Islamic Sunday School may extend the global platform, but the global platform must not know Islamic Sunday School exists.
|
||||
|
||||
## Phase 2 Outcome
|
||||
|
||||
At the end of this phase:
|
||||
|
||||
- `SchoolCore` exposes stable contracts for core school operations.
|
||||
- Islamic Sunday School behavior plugs into the platform through policies, providers, and extension services.
|
||||
- Controllers and application services begin depending on contracts instead of concrete legacy services.
|
||||
- Dependency direction is enforced by automated tests.
|
||||
- No `SchoolCore` class imports from `IslamicSundaySchool`.
|
||||
- New implementation work has a clear place to go instead of continuing the existing service sprawl.
|
||||
|
||||
## Dependency Rule
|
||||
|
||||
The dependency direction must be enforced as architecture law:
|
||||
|
||||
```text
|
||||
Http / Controllers / Requests / Resources
|
||||
↓
|
||||
Application Services / Use Cases
|
||||
↓
|
||||
SchoolCore Contracts
|
||||
↓
|
||||
SchoolCore Implementations
|
||||
↑
|
||||
Domain Extensions, such as IslamicSundaySchool, may provide policies/providers
|
||||
```
|
||||
|
||||
Allowed:
|
||||
|
||||
```text
|
||||
IslamicSundaySchool → SchoolCore
|
||||
Controllers → SchoolCore Contracts
|
||||
Application Services → SchoolCore Contracts
|
||||
SchoolCore Implementations → SchoolCore Models / Repositories
|
||||
```
|
||||
|
||||
Forbidden:
|
||||
|
||||
```text
|
||||
SchoolCore → IslamicSundaySchool
|
||||
SchoolCore → Http Request
|
||||
SchoolCore → auth()
|
||||
SchoolCore → controller classes
|
||||
SchoolCore → route-specific assumptions
|
||||
SchoolCore → Islamic Sunday School program calendar assumptions
|
||||
SchoolCore → masjid/community/ministry vocabulary unless stored as extension metadata
|
||||
```
|
||||
|
||||
## Required Namespace Structure
|
||||
|
||||
Create this structure first. Do not migrate every class immediately. Empty boundaries are acceptable at the start. Fake modularity is not.
|
||||
|
||||
```text
|
||||
app/
|
||||
Domain/
|
||||
SchoolCore/
|
||||
Contracts/
|
||||
Context/
|
||||
Identity/
|
||||
Students/
|
||||
Guardians/
|
||||
Enrollment/
|
||||
Attendance/
|
||||
Academics/
|
||||
Finance/
|
||||
Communication/
|
||||
Files/
|
||||
Reporting/
|
||||
Authorization/
|
||||
Support/
|
||||
IslamicSundaySchool/
|
||||
Contracts/
|
||||
Providers/
|
||||
Policies/
|
||||
Attendance/
|
||||
Curriculum/
|
||||
Ministry/
|
||||
Families/
|
||||
Reports/
|
||||
Shared/
|
||||
Contracts/
|
||||
ValueObjects/
|
||||
Exceptions/
|
||||
Support/
|
||||
Providers/
|
||||
SchoolCoreServiceProvider.php
|
||||
IslamicSundaySchoolServiceProvider.php
|
||||
```
|
||||
|
||||
## Contract Design Rules
|
||||
|
||||
Every contract must follow these rules:
|
||||
|
||||
1. Accept `SchoolContext` explicitly or be resolved from a context-aware application service.
|
||||
2. Use DTOs/value objects for input where payloads are complex.
|
||||
3. Return DTOs/read models where behavior crosses module boundaries.
|
||||
4. Never accept raw `Request` objects.
|
||||
5. Never call `auth()` inside domain services.
|
||||
6. Never use Islamic Sunday School terminology in `SchoolCore` contracts.
|
||||
7. Never expose Eloquent models as the required public contract unless the service is strictly internal.
|
||||
8. Define authorization expectations explicitly.
|
||||
9. Define transaction ownership explicitly for mutation methods.
|
||||
10. Include failure behavior in the contract documentation.
|
||||
|
||||
## Core Contracts to Create
|
||||
|
||||
### Context
|
||||
|
||||
```php
|
||||
namespace App\Domain\SchoolCore\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
|
||||
interface RequiresSchoolContext
|
||||
{
|
||||
public function setSchoolContext(SchoolContext $context): void;
|
||||
}
|
||||
```
|
||||
|
||||
### Students
|
||||
|
||||
```php
|
||||
namespace App\Domain\SchoolCore\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Students\Data\CreateStudentData;
|
||||
use App\Domain\SchoolCore\Students\Data\UpdateStudentData;
|
||||
use App\Domain\SchoolCore\Students\Data\StudentReadModel;
|
||||
|
||||
interface StudentServiceContract
|
||||
{
|
||||
public function create(SchoolContext $context, CreateStudentData $data): StudentReadModel;
|
||||
|
||||
public function update(SchoolContext $context, int $studentId, UpdateStudentData $data): StudentReadModel;
|
||||
|
||||
public function find(SchoolContext $context, int $studentId): ?StudentReadModel;
|
||||
|
||||
public function archive(SchoolContext $context, int $studentId, string $reason): void;
|
||||
}
|
||||
```
|
||||
|
||||
### Student Identifier Generation
|
||||
|
||||
```php
|
||||
namespace App\Domain\SchoolCore\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
|
||||
interface StudentIdentifierGeneratorContract
|
||||
{
|
||||
public function generate(SchoolContext $context, int $studentId): string;
|
||||
}
|
||||
```
|
||||
|
||||
### Guardians
|
||||
|
||||
```php
|
||||
namespace App\Domain\SchoolCore\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Guardians\Data\GuardianRelationshipData;
|
||||
|
||||
interface GuardianServiceContract
|
||||
{
|
||||
public function attachGuardian(SchoolContext $context, int $studentId, GuardianRelationshipData $data): void;
|
||||
|
||||
public function detachGuardian(SchoolContext $context, int $studentId, int $guardianId): void;
|
||||
|
||||
public function listForStudent(SchoolContext $context, int $studentId): array;
|
||||
}
|
||||
```
|
||||
|
||||
### Enrollment
|
||||
|
||||
```php
|
||||
namespace App\Domain\SchoolCore\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Enrollment\Data\EnrollmentData;
|
||||
|
||||
interface EnrollmentServiceContract
|
||||
{
|
||||
public function enroll(SchoolContext $context, int $studentId, EnrollmentData $data): void;
|
||||
|
||||
public function transfer(SchoolContext $context, int $studentId, EnrollmentData $data): void;
|
||||
|
||||
public function withdraw(SchoolContext $context, int $studentId, string $reason): void;
|
||||
}
|
||||
```
|
||||
|
||||
### Attendance
|
||||
|
||||
```php
|
||||
namespace App\Domain\SchoolCore\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Attendance\Data\AttendanceMarkData;
|
||||
use App\Domain\SchoolCore\Attendance\Data\AttendanceScanData;
|
||||
use App\Domain\SchoolCore\Attendance\Data\AttendanceResult;
|
||||
|
||||
interface AttendanceServiceContract
|
||||
{
|
||||
public function markStudent(SchoolContext $context, AttendanceMarkData $data): AttendanceResult;
|
||||
|
||||
public function processScan(SchoolContext $context, AttendanceScanData $data): AttendanceResult;
|
||||
|
||||
public function summarizeDay(SchoolContext $context, string $date): array;
|
||||
}
|
||||
```
|
||||
|
||||
### Attendance Policy
|
||||
|
||||
```php
|
||||
namespace App\Domain\SchoolCore\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use DateTimeInterface;
|
||||
|
||||
interface AttendancePolicyContract
|
||||
{
|
||||
public function isSchoolDay(SchoolContext $context, DateTimeInterface $date): bool;
|
||||
|
||||
public function determineStatus(SchoolContext $context, DateTimeInterface $scanTime, array $session): string;
|
||||
|
||||
public function duplicateScanWindowSeconds(SchoolContext $context): int;
|
||||
}
|
||||
```
|
||||
|
||||
### Academic Calendar
|
||||
|
||||
```php
|
||||
namespace App\Domain\SchoolCore\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use DateTimeInterface;
|
||||
|
||||
interface AcademicCalendarProviderContract
|
||||
{
|
||||
public function currentAcademicYear(SchoolContext $context): ?int;
|
||||
|
||||
public function currentTerm(SchoolContext $context): ?int;
|
||||
|
||||
public function sessionsForDate(SchoolContext $context, DateTimeInterface $date): array;
|
||||
}
|
||||
```
|
||||
|
||||
### Academics
|
||||
|
||||
```php
|
||||
namespace App\Domain\SchoolCore\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
|
||||
interface GradeScalePolicyContract
|
||||
{
|
||||
public function gradeForScore(SchoolContext $context, float $score): string;
|
||||
|
||||
public function passingScore(SchoolContext $context): float;
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
namespace App\Domain\SchoolCore\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
|
||||
interface PromotionPolicyContract
|
||||
{
|
||||
public function canPromote(SchoolContext $context, int $studentId): bool;
|
||||
|
||||
public function nextPlacement(SchoolContext $context, int $studentId): array;
|
||||
}
|
||||
```
|
||||
|
||||
### Finance
|
||||
|
||||
```php
|
||||
namespace App\Domain\SchoolCore\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Finance\Data\CreateInvoiceData;
|
||||
use App\Domain\SchoolCore\Finance\Data\InvoiceReadModel;
|
||||
|
||||
interface InvoiceServiceContract
|
||||
{
|
||||
public function create(SchoolContext $context, CreateInvoiceData $data): InvoiceReadModel;
|
||||
|
||||
public function recalculate(SchoolContext $context, int $invoiceId): InvoiceReadModel;
|
||||
|
||||
public function find(SchoolContext $context, int $invoiceId): ?InvoiceReadModel;
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
namespace App\Domain\SchoolCore\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Finance\Data\RecordPaymentData;
|
||||
use App\Domain\SchoolCore\Finance\Data\EditPaymentData;
|
||||
use App\Domain\SchoolCore\Finance\Data\PaymentReadModel;
|
||||
|
||||
interface PaymentServiceContract
|
||||
{
|
||||
public function record(SchoolContext $context, RecordPaymentData $data): PaymentReadModel;
|
||||
|
||||
public function edit(SchoolContext $context, int $paymentId, EditPaymentData $data): PaymentReadModel;
|
||||
|
||||
public function void(SchoolContext $context, int $paymentId, string $reason): void;
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
namespace App\Domain\SchoolCore\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
|
||||
interface PaymentAllocationPolicyContract
|
||||
{
|
||||
public function allocate(SchoolContext $context, int $invoiceId, float $amount): array;
|
||||
}
|
||||
```
|
||||
|
||||
### Files
|
||||
|
||||
```php
|
||||
namespace App\Domain\SchoolCore\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
|
||||
interface FileAccessPolicyContract
|
||||
{
|
||||
public function canAccess(SchoolContext $context, string $filePurpose, int $ownerId, array $metadata = []): bool;
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
namespace App\Domain\SchoolCore\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
|
||||
interface FileStorageServiceContract
|
||||
{
|
||||
public function store(SchoolContext $context, string $purpose, mixed $file, array $metadata = []): string;
|
||||
|
||||
public function resolvePath(SchoolContext $context, string $fileReference): string;
|
||||
|
||||
public function delete(SchoolContext $context, string $fileReference): void;
|
||||
}
|
||||
```
|
||||
|
||||
### Communication
|
||||
|
||||
```php
|
||||
namespace App\Domain\SchoolCore\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Communication\Data\MessageData;
|
||||
use App\Domain\SchoolCore\Communication\Data\RecipientQuery;
|
||||
|
||||
interface CommunicationServiceContract
|
||||
{
|
||||
public function previewRecipients(SchoolContext $context, RecipientQuery $query): array;
|
||||
|
||||
public function send(SchoolContext $context, MessageData $message): array;
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
namespace App\Domain\SchoolCore\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
use App\Domain\SchoolCore\Communication\Data\RecipientQuery;
|
||||
|
||||
interface RecipientResolverContract
|
||||
{
|
||||
public function resolve(SchoolContext $context, RecipientQuery $query): array;
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
namespace App\Domain\SchoolCore\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
|
||||
interface CommunicationPreferencePolicyContract
|
||||
{
|
||||
public function mayContact(SchoolContext $context, int $recipientId, string $channel, string $purpose): bool;
|
||||
}
|
||||
```
|
||||
|
||||
### Reporting
|
||||
|
||||
```php
|
||||
namespace App\Domain\SchoolCore\Contracts;
|
||||
|
||||
use App\Domain\SchoolCore\Context\SchoolContext;
|
||||
|
||||
interface ReportServiceContract
|
||||
{
|
||||
public function generate(SchoolContext $context, string $reportKey, array $filters = []): array;
|
||||
}
|
||||
```
|
||||
|
||||
## DTO and Read Model Rules
|
||||
|
||||
Create DTOs only where contracts need them. Do not invent DTOs for every two-field method just to feel enterprise.
|
||||
|
||||
Recommended naming:
|
||||
|
||||
```text
|
||||
CreateStudentData
|
||||
UpdateStudentData
|
||||
StudentReadModel
|
||||
AttendanceMarkData
|
||||
AttendanceScanData
|
||||
AttendanceResult
|
||||
CreateInvoiceData
|
||||
RecordPaymentData
|
||||
EditPaymentData
|
||||
PaymentReadModel
|
||||
MessageData
|
||||
RecipientQuery
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- DTOs must be immutable where practical.
|
||||
- DTOs must be created from validated request data, not raw request data.
|
||||
- DTOs must not call the database.
|
||||
- Read models must not expose sensitive fields by default.
|
||||
- Read models must include `school_id` only when needed for authorization/debugging, not casually leaked in every response.
|
||||
|
||||
## Service Provider Bindings
|
||||
|
||||
Create `SchoolCoreServiceProvider`.
|
||||
|
||||
```php
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use App\Domain\SchoolCore\Contracts\StudentServiceContract;
|
||||
use App\Domain\SchoolCore\Students\Services\StudentService;
|
||||
|
||||
class SchoolCoreServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(StudentServiceContract::class, StudentService::class);
|
||||
// Bind additional core contracts as implementations are introduced.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create `IslamicSundaySchoolServiceProvider` for extension policies.
|
||||
|
||||
```php
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use App\Domain\SchoolCore\Contracts\AttendancePolicyContract;
|
||||
use App\Domain\IslamicIslamicSundaySchool\Attendance\IslamicSundaySchoolAttendancePolicy;
|
||||
|
||||
class IslamicSundaySchoolServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(AttendancePolicyContract::class, IslamicSundaySchoolAttendancePolicy::class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Important: core contract bindings may point to core defaults. Islamic Sunday School may override only extension contracts/policies, not replace the entire platform unless a module-specific reason is documented.
|
||||
|
||||
## Binding Strategy
|
||||
|
||||
Use this three-level binding model:
|
||||
|
||||
### 1. Core service contracts
|
||||
|
||||
These are stable platform services.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
StudentServiceContract → SchoolCore\Students\Services\StudentService
|
||||
PaymentServiceContract → SchoolCore\Finance\Services\PaymentService
|
||||
AttendanceServiceContract → SchoolCore\Attendance\Services\AttendanceService
|
||||
```
|
||||
|
||||
### 2. Policy/provider contracts
|
||||
|
||||
These are extension points.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
AttendancePolicyContract → IslamicSundaySchool\Attendance\IslamicSundaySchoolAttendancePolicy
|
||||
AcademicCalendarProviderContract → IslamicSundaySchool\Providers\IslamicSundaySchoolCalendarProvider
|
||||
PaymentAllocationPolicyContract → IslamicSundaySchool\Policies\IslamicSundaySchoolPaymentAllocationPolicy
|
||||
```
|
||||
|
||||
### 3. Infrastructure contracts
|
||||
|
||||
These provide storage, messaging, and integration adapters.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
FileStorageServiceContract → LaravelLocalFileStorageService
|
||||
CommunicationServiceContract → LaravelCommunicationService
|
||||
```
|
||||
|
||||
## First Migration Slice: Student Identifier Generation
|
||||
|
||||
This is the safest first slice because it fixes a real design bug without moving the whole student module.
|
||||
|
||||
### Current Problem
|
||||
|
||||
Student creation currently risks race conditions when identifiers are generated using `max(id) + 1` before the student row is safely persisted.
|
||||
|
||||
### Target Design
|
||||
|
||||
```text
|
||||
StudentController
|
||||
→ CreateStudentRequest
|
||||
→ CreateStudentData
|
||||
→ StudentServiceContract
|
||||
→ StudentService
|
||||
→ StudentIdentifierGeneratorContract
|
||||
```
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Create `StudentIdentifierGeneratorContract`.
|
||||
2. Create default implementation: `SequentialStudentIdentifierGenerator`.
|
||||
3. Generate identifier after student insert using the persisted student ID.
|
||||
4. Add unique database constraint for `(school_id, school_id_number)` or equivalent identifier column.
|
||||
5. Add retry behavior for collisions.
|
||||
6. Add concurrent creation test.
|
||||
7. Keep the existing API response compatible.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Two concurrent student creations cannot produce the same identifier.
|
||||
- Student ID format can be changed by binding a different generator.
|
||||
- Islamic Sunday School-specific identifier formatting does not live inside core student creation logic.
|
||||
- Existing student creation endpoint still works.
|
||||
|
||||
## Second Migration Slice: Payment File Access Policy
|
||||
|
||||
This slice validates that contracts can enforce security boundaries.
|
||||
|
||||
### Current Problem
|
||||
|
||||
Filename-based file serving risks unauthorized access if a user can guess a payment file name.
|
||||
|
||||
### Target Design
|
||||
|
||||
```text
|
||||
PaymentManualController
|
||||
→ PaymentFileDownloadRequest
|
||||
→ PaymentServiceContract or PaymentFileService
|
||||
→ FileAccessPolicyContract
|
||||
→ FileStorageServiceContract
|
||||
```
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Create `FileAccessPolicyContract`.
|
||||
2. Create `PaymentFileAccessPolicy` implementation.
|
||||
3. Replace filename-only lookup with payment-ID-based lookup.
|
||||
4. Resolve file path from the payment record.
|
||||
5. Authorize against school context and actor permissions.
|
||||
6. Add audit log entry for file access.
|
||||
7. Return `403` for unauthorized access.
|
||||
8. Return `404` for missing file or missing payment.
|
||||
9. Add tests for guessed filename access.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Users cannot download payment files by guessing filenames.
|
||||
- Finance/admin users can download authorized payment files.
|
||||
- Parents can only download files tied to their own financial records, if product rules allow parent access.
|
||||
- Cross-school access fails.
|
||||
- Payment file access uses `SchoolContext`.
|
||||
|
||||
## Third Migration Slice: Attendance Policy Stub
|
||||
|
||||
This slice prepares Phase 4 without fully extracting scanner logic yet.
|
||||
|
||||
### Target Design
|
||||
|
||||
```text
|
||||
ScannerController
|
||||
→ AttendanceServiceContract
|
||||
→ AttendancePolicyContract
|
||||
→ AcademicCalendarProviderContract
|
||||
```
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Create `AttendancePolicyContract`.
|
||||
2. Create default `StandardSchoolAttendancePolicy`.
|
||||
3. Create `IslamicSundaySchoolAttendancePolicy`.
|
||||
4. Create `AcademicCalendarProviderContract`.
|
||||
5. Create default provider.
|
||||
6. Bind Islamic Sunday School provider in `IslamicSundaySchoolServiceProvider`.
|
||||
7. Update scanner service extraction plan to call policies instead of hardcoded date/session assumptions.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Islamic Sunday School program attendance logic lives outside core.
|
||||
- Attendance duplicate window is policy-driven.
|
||||
- Scanner code can call a policy without knowing the school type.
|
||||
|
||||
## Controller Dependency Rule
|
||||
|
||||
New or touched controllers should depend on contracts, not concrete domain services.
|
||||
|
||||
Good:
|
||||
|
||||
```php
|
||||
public function __construct(private StudentServiceContract $students) {}
|
||||
```
|
||||
|
||||
Bad:
|
||||
|
||||
```php
|
||||
public function __construct(private IslamicSundaySchoolStudentService $students) {}
|
||||
```
|
||||
|
||||
Also bad:
|
||||
|
||||
```php
|
||||
public function store(Request $request)
|
||||
{
|
||||
$schoolId = auth()->user()->school_id;
|
||||
// 120 lines of student creation logic
|
||||
}
|
||||
```
|
||||
|
||||
Because apparently controllers become landfill if left unsupervised.
|
||||
|
||||
## Architecture Tests
|
||||
|
||||
Add architecture tests using one of these approaches:
|
||||
|
||||
- Pest architecture tests if Pest is available.
|
||||
- PHPUnit custom tests scanning namespaces/imports.
|
||||
- Static analysis rule in PHPStan/Larastan if already installed.
|
||||
|
||||
### Required Architecture Tests
|
||||
|
||||
#### `SchoolCore` must not import `IslamicSundaySchool`
|
||||
|
||||
```php
|
||||
public function test_school_core_does_not_depend_on_islamic_sunday_school(): void
|
||||
{
|
||||
$files = glob(app_path('Domain/SchoolCore/**/*.php'));
|
||||
|
||||
foreach ($files as $file) {
|
||||
$contents = file_get_contents($file);
|
||||
$this->assertStringNotContainsString('App\\Domain\\IslamicSundaySchool', $contents, $file);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Domain services must not use raw request
|
||||
|
||||
```php
|
||||
public function test_domain_services_do_not_depend_on_http_request(): void
|
||||
{
|
||||
$files = glob(app_path('Domain/**/*.php'));
|
||||
|
||||
foreach ($files as $file) {
|
||||
$contents = file_get_contents($file);
|
||||
$this->assertStringNotContainsString('Illuminate\\Http\\Request', $contents, $file);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Domain services must not call `auth()`
|
||||
|
||||
```php
|
||||
public function test_domain_services_do_not_call_auth_helper(): void
|
||||
{
|
||||
$files = glob(app_path('Domain/**/*.php'));
|
||||
|
||||
foreach ($files as $file) {
|
||||
$contents = file_get_contents($file);
|
||||
$this->assertStringNotContainsString('auth()', $contents, $file);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Core contracts must be neutral
|
||||
|
||||
Search contract names and method names for Islamic Sunday School vocabulary:
|
||||
|
||||
```text
|
||||
masjid/community
|
||||
ministry
|
||||
sunday
|
||||
bible
|
||||
servant
|
||||
service_day
|
||||
```
|
||||
|
||||
Those words may appear in `IslamicSundaySchool`, not `SchoolCore`.
|
||||
|
||||
## Contract Review Checklist
|
||||
|
||||
Before approving a contract, answer:
|
||||
|
||||
- Is the name globally meaningful for any school?
|
||||
- Does it require `SchoolContext`?
|
||||
- Does it avoid `Request`?
|
||||
- Does it avoid `auth()`?
|
||||
- Does it avoid Eloquent leakage across module boundaries?
|
||||
- Does it describe transaction ownership?
|
||||
- Does it describe authorization expectations?
|
||||
- Can Islamic Sunday School customize behavior without editing the contract?
|
||||
- Can another school type use this contract without pretending to be a Islamic Sunday School?
|
||||
|
||||
## Implementation Tickets
|
||||
|
||||
### CC-001: Create Domain Namespace Skeleton
|
||||
|
||||
**Objective:** Create the folder and namespace structure for `SchoolCore`, `IslamicSundaySchool`, and `Shared`.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- Add directory structure.
|
||||
- Add placeholder README files for each major module.
|
||||
- Document dependency direction in `app/Domain/README.md`.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
- Namespace structure exists.
|
||||
- Dependency rule is documented.
|
||||
- No production behavior changes.
|
||||
|
||||
### CC-002: Create Core Contract Interfaces
|
||||
|
||||
**Objective:** Add first-pass contracts for students, attendance, finance, files, communication, academics, enrollment, and reporting.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- Add contracts under `App\Domain\SchoolCore\Contracts`.
|
||||
- Add minimal DTO namespaces.
|
||||
- Add docblocks for transaction and authorization expectations.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
- Contracts compile.
|
||||
- Contracts use `SchoolContext`.
|
||||
- Contracts do not mention Islamic Sunday School concepts.
|
||||
|
||||
### CC-003: Add Service Providers and Bindings
|
||||
|
||||
**Objective:** Bind contracts to initial implementations.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- Create `SchoolCoreServiceProvider`.
|
||||
- Create `IslamicSundaySchoolServiceProvider`.
|
||||
- Register providers.
|
||||
- Bind first implementations.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
- Container resolves all initial contracts.
|
||||
- Islamic Sunday School extension policies can override policy/provider contracts.
|
||||
- Core service bindings remain neutral.
|
||||
|
||||
### CC-004: Add Architecture Boundary Tests
|
||||
|
||||
**Objective:** Prevent forbidden dependencies.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- Add tests for `SchoolCore -> IslamicSundaySchool` imports.
|
||||
- Add tests preventing `Request` in domain services.
|
||||
- Add tests preventing `auth()` in domain services.
|
||||
- Add tests for forbidden vocabulary in `SchoolCore` contracts.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
- Tests fail when forbidden dependencies are introduced.
|
||||
- Tests run in CI.
|
||||
|
||||
### CC-005: Migrate Student Identifier Generation Behind Contract
|
||||
|
||||
**Objective:** Fix student identifier race condition and introduce the first real core contract usage.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- Create `StudentIdentifierGeneratorContract`.
|
||||
- Add default implementation.
|
||||
- Update student creation flow.
|
||||
- Add unique constraint.
|
||||
- Add concurrency test.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
- No duplicate student identifiers under concurrent creation.
|
||||
- Identifier format can be replaced by binding.
|
||||
- Existing API response is preserved.
|
||||
|
||||
### CC-006: Migrate Payment File Access Behind Policy Contract
|
||||
|
||||
**Objective:** Use contract architecture to fix payment file authorization.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- Create `FileAccessPolicyContract`.
|
||||
- Create payment-specific policy implementation.
|
||||
- Replace filename-only access with payment-ID-based access.
|
||||
- Add audit log.
|
||||
- Add authorization tests.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
- Guessed filenames cannot access files.
|
||||
- Cross-school file access fails.
|
||||
- Authorized finance/admin access succeeds.
|
||||
|
||||
### CC-007: Add Attendance Policy Stub
|
||||
|
||||
**Objective:** Prepare attendance extraction by separating attendance rules from scanner implementation.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
- Create `AttendancePolicyContract`.
|
||||
- Create `AcademicCalendarProviderContract`.
|
||||
- Add default and Islamic Sunday School implementations.
|
||||
- Bind policy/provider implementations.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
- Islamic Sunday School attendance rules are outside `SchoolCore`.
|
||||
- Scanner extraction can use contracts in Phase 4.
|
||||
|
||||
## Phase 2 Rollout Order
|
||||
|
||||
Use this order:
|
||||
|
||||
1. Create namespace skeleton.
|
||||
2. Add core contracts.
|
||||
3. Add DTO/read model skeletons.
|
||||
4. Add providers and empty bindings.
|
||||
5. Add architecture tests.
|
||||
6. Migrate student identifier generation.
|
||||
7. Migrate payment file access.
|
||||
8. Add attendance policy stub.
|
||||
9. Update modular platform plan with completed contracts.
|
||||
|
||||
## Phase 2 Done Definition
|
||||
|
||||
Phase 2 is complete when:
|
||||
|
||||
- Core contracts exist and compile.
|
||||
- Initial service providers are registered.
|
||||
- `SchoolCore` cannot depend on `IslamicSundaySchool` without failing tests.
|
||||
- Domain services cannot use `Request` or `auth()` without failing tests.
|
||||
- Student identifier generation is contract-based and race-safe.
|
||||
- Payment file access uses policy-based authorization.
|
||||
- Attendance has policy/provider contracts ready for extraction.
|
||||
- Islamic Sunday School behavior is expressed through extension contracts, not hardcoded core logic.
|
||||
|
||||
## Risks
|
||||
|
||||
### Risk: Too many contracts too early
|
||||
|
||||
Mitigation: only create contracts that are needed by migration slices. Keep method count small.
|
||||
|
||||
### Risk: Interfaces mirror bad legacy services
|
||||
|
||||
Mitigation: design contracts around platform capabilities, not current class names.
|
||||
|
||||
### Risk: Islamic Sunday School rules leak into core
|
||||
|
||||
Mitigation: forbidden vocabulary tests and provider-based extension points.
|
||||
|
||||
### Risk: Controllers keep calling old services
|
||||
|
||||
Mitigation: each touched controller must move toward contract injection.
|
||||
|
||||
### Risk: DTO explosion
|
||||
|
||||
Mitigation: create DTOs for cross-module payloads only. Do not manufacture object bureaucracy for two strings and a prayer.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
Phase 2 does not fully refactor finance, attendance, students, communication, or reporting.
|
||||
|
||||
Phase 2 does not remove every legacy service.
|
||||
|
||||
Phase 2 does not redesign every database table.
|
||||
|
||||
Phase 2 creates the contract layer and proves the boundary works through a few real migration slices.
|
||||
|
||||
## Final Notes
|
||||
|
||||
This phase must be boring on purpose. Contracts are not where creativity belongs. They are where the platform decides what it promises and what it refuses to know.
|
||||
|
||||
The most important rule: `SchoolCore` must remain generic. Islamic Sunday School extends it. It does not secretly define it.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,42 +0,0 @@
|
||||
http://localhost:5173/app/administrator/attendance/badge-scans
|
||||
http://localhost:5173/app/administrator/sections/auto-distribute
|
||||
http://localhost:5173/app/administrator/sections/promotion-totals
|
||||
http://localhost:5173/app/administrator/parent-email-extractor
|
||||
http://localhost:5173/app/administrator/parent_profiles
|
||||
http://localhost:5173/app/administrator/student_profiles
|
||||
http://localhost:5173/app/whatsapp?school_year=2025-2026
|
||||
http://localhost:5173/app/competition-winners
|
||||
http://localhost:5173/app/admin/progress
|
||||
http://localhost:5173/app/administrator/subject-curriculum
|
||||
http://localhost:5173/app/administrator/teacher-submissions
|
||||
http://localhost:5173/app/administrator/calendar
|
||||
http://localhost:5173/app/administrator/events?school_year=2025-2026
|
||||
http://localhost:5173/app/administrator/exam-drafts
|
||||
http://localhost:5173/app/administrator/family
|
||||
http://localhost:5173/app/families/import-legacy
|
||||
http://localhost:5173/app/inventory/book
|
||||
http://localhost:5173/app/inventory
|
||||
http://localhost:5173/app/inventory/kitchen
|
||||
http://localhost:5173/app/inventory/office
|
||||
http://localhost:5173/app/inventory/summary-all
|
||||
http://localhost:5173/app/inventory/movements
|
||||
http://localhost:5173/app/administrator/notifications_alerts
|
||||
http://localhost:5173/app/administrator/certificates?school_year=2025-2026
|
||||
http://localhost:5173/app/administrator/class-prep
|
||||
http://localhost:5173/app/admin/print-requests
|
||||
http://localhost:5173/app/administrator/printables/report-cards
|
||||
http://localhost:5173/app/grading/decisions?school_year=2025-2026
|
||||
http://localhost:5173/app/grading/below-60/decisions?school_year=2025-2026
|
||||
http://localhost:5173/app/grading?school_year=2025-2026&semester=Fall
|
||||
http://localhost:5173/app/staff
|
||||
http://localhost:5173/app/administrator/teacher_class_assignment
|
||||
http://localhost:5173/app/administrator/emergency-contacts
|
||||
http://localhost:5173/app/administrator/enroll-withdraw/enrollment-withdrawal
|
||||
http://localhost:5173/app/grading/placement
|
||||
http://localhost:5173/app/administrator/removed_students
|
||||
http://localhost:5173/app/administrator/calendar_view?school_year=2025-2026
|
||||
http://localhost:5173/app/administrator/student_class_assignment
|
||||
http://localhost:5173/app/administrator/student_profiles
|
||||
http://localhost:5173/app/administrator/absence
|
||||
http://localhost:5173/app/administrator/ip_bans
|
||||
http://localhost:5173/app/user/user-list
|
||||
@@ -0,0 +1,83 @@
|
||||
# API Refactor Status
|
||||
|
||||
This file replaces the older pre-refactor task list that referenced route paths and module boundaries no longer present in the codebase.
|
||||
|
||||
Source of truth:
|
||||
|
||||
- `apps/api/src/app.ts`
|
||||
- `apps/api/src/modules/*`
|
||||
- `apps/api/src/http/*`
|
||||
|
||||
## Refactor State
|
||||
|
||||
The API has already been refactored into the modular structure the old task list was aiming for.
|
||||
|
||||
Current structure includes:
|
||||
|
||||
- `modules/*` for route/service/repo/presenter grouping
|
||||
- `http/errors` for centralized error types and middleware
|
||||
- `http/validate` for request parsing helpers
|
||||
- `http/respond` for success helpers
|
||||
- module-level tests and integration tests under `apps/api/src/tests`
|
||||
|
||||
The older checklist items that referenced:
|
||||
|
||||
- `apps/api/src/routes/*.ts`
|
||||
- missing test tooling
|
||||
- missing shared validation helpers
|
||||
- missing shared response helpers
|
||||
|
||||
are no longer accurate and should not be used as active work items.
|
||||
|
||||
## Remaining Follow-Up Work
|
||||
|
||||
These are the only meaningful refactor follow-ups still worth tracking at a high level.
|
||||
|
||||
### 1. Keep OpenAPI coverage aligned with route growth
|
||||
|
||||
The API now has:
|
||||
|
||||
- Swagger UI at `/docs`
|
||||
- OpenAPI JSON at `/api/v1/openapi.json`
|
||||
|
||||
But the OpenAPI document does not yet mirror every newer route group perfectly. Continue updating:
|
||||
|
||||
- `apps/api/src/swagger/openapi.ts`
|
||||
- route schemas
|
||||
- module docs
|
||||
|
||||
whenever new endpoints are added.
|
||||
|
||||
### 2. Expand integration test coverage
|
||||
|
||||
Integration tests exist, but the heaviest workflows still benefit from deeper coverage:
|
||||
|
||||
- subscription billing transitions
|
||||
- marketplace reservation intake
|
||||
- public booking and payment initialization
|
||||
- reservation inspection and close flows
|
||||
- admin billing operations
|
||||
|
||||
### 3. Reduce remaining direct Prisma orchestration in large services
|
||||
|
||||
The route layer is already thin in the current API. The next cleanup target is inside larger service files where orchestration still mixes:
|
||||
|
||||
- multi-step workflow logic
|
||||
- transaction boundaries
|
||||
- some direct Prisma writes
|
||||
|
||||
especially in:
|
||||
|
||||
- reservation flows
|
||||
- subscription/billing flows
|
||||
- admin billing flows
|
||||
|
||||
### 4. Keep docs aligned with disabled flows
|
||||
|
||||
Some legacy surfaces still exist as placeholders or schema remnants, for example:
|
||||
|
||||
- disabled Clerk webhook endpoint
|
||||
- legacy `clerkUserId` field on `Employee`
|
||||
- disabled renter signup/login API endpoints
|
||||
|
||||
Those should stay clearly marked in docs so design specs do not drift back toward removed implementations.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Documentation Audit
|
||||
|
||||
Date: 2026-05-26
|
||||
|
||||
## Purpose
|
||||
|
||||
This note records the cleanup applied to the `docs/project-design` folder after comparing it with the live codebase.
|
||||
|
||||
## Drift That Was Removed
|
||||
|
||||
The older design docs included several features or assumptions that are not active in the current implementation:
|
||||
|
||||
- Clerk-based employee auth and webhooks
|
||||
- Clerk-based team invite acceptance
|
||||
- renter self-service signup/login as an active user flow
|
||||
- renter email verification as an active flow
|
||||
- a separate white-label company public-site frontend app
|
||||
- multi-currency SaaS subscription checkout beyond `MAD`
|
||||
- marketing routes such as `/about`, `/contact`, and `/blog`
|
||||
- outdated API file paths under `apps/api/src/routes/*`
|
||||
|
||||
## Current Documentation Rule
|
||||
|
||||
The `docs/project-design` files should describe only one of two things:
|
||||
|
||||
1. current implemented behavior
|
||||
2. explicit future plans, clearly labeled as plans
|
||||
|
||||
They should not describe removed systems as if they are still live.
|
||||
|
||||
## Current References
|
||||
|
||||
Use these documents as the current implementation references:
|
||||
|
||||
- `api-routes.md`
|
||||
- `schema.md`
|
||||
- `FEATURES.md`
|
||||
- `PAGES.md`
|
||||
- `INTEGRATION.md`
|
||||
|
||||
Use the execution-plan documents only as future/planning material, not as evidence that a feature is already shipped.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
# Cookie Policy
|
||||
|
||||
**Platform:** RentalDriveGo
|
||||
**Domain:** rentaldrivego.ma
|
||||
**Last updated:** 2026-05-23
|
||||
|
||||
---
|
||||
|
||||
## 1. What Are Cookies
|
||||
|
||||
Cookies are small text files that a website stores on your device when you visit. They allow the site to remember information about your visit — such as your preferred language or whether you are signed in — so you do not have to re-enter it every time.
|
||||
|
||||
RentalDriveGo uses cookies only for the purposes described in this document. We do not use cookies to track your activity across third-party websites, serve advertisements, or build behavioural profiles.
|
||||
|
||||
---
|
||||
|
||||
## 2. Types of Cookies We Use
|
||||
|
||||
We use two categories of cookies: **strictly necessary** and **preference/functional**.
|
||||
|
||||
We do not use analytics cookies, advertising cookies, or third-party tracking cookies.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cookie Details
|
||||
|
||||
### 3.1 Authentication Cookie
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Name** | `employee_session` |
|
||||
| **Category** | Strictly necessary |
|
||||
| **Duration** | 8 hours (session) |
|
||||
| **Scope** | All pages (`path=/`) |
|
||||
| **Third-party** | No |
|
||||
|
||||
**Purpose:** This cookie is set when an employee signs in to the RentalDriveGo workspace. Admins receive a separate `admin_session` cookie. It stores a cryptographically signed token (JWT) that proves your identity to the platform. Without this cookie, the dashboard and protected API endpoints cannot verify who you are and will redirect you to the sign-in page.
|
||||
|
||||
**When it is set:** On a successful sign-in.
|
||||
**When it is removed:** When you sign out, or automatically after 8 hours of inactivity.
|
||||
**Can you opt out?** No. This cookie is required for the platform to function. Blocking it will prevent you from signing in.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Language Preference Cookie
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Name** | `rentaldrivego-language` |
|
||||
| **Category** | Functional / preference |
|
||||
| **Duration** | 1 year |
|
||||
| **Scope** | All pages (`path=/`) |
|
||||
| **Third-party** | No |
|
||||
|
||||
**Purpose:** Stores your chosen display language so the interface appears in your preferred language on every visit across all parts of the platform (public marketplace, dashboard, and admin panel). Supported values are English (`en`), French (`fr`), and Arabic (`ar`).
|
||||
|
||||
**When it is set:** When you change the language using the language selector, or automatically on first visit based on your browser's language settings.
|
||||
**Can you opt out?** You can block this cookie. If you do, the platform will fall back to a default language and you may need to select your language on every visit.
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Theme Preference Cookie
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Name** | `rentaldrivego-theme` |
|
||||
| **Category** | Functional / preference |
|
||||
| **Duration** | 1 year |
|
||||
| **Scope** | All pages (`path=/`) |
|
||||
| **Third-party** | No |
|
||||
|
||||
**Purpose:** Stores your preferred colour scheme — light mode or dark mode. This cookie is read immediately when a page loads (before the interface is drawn) to apply the correct theme without any visible flash of the wrong colours.
|
||||
|
||||
**When it is set:** When you toggle the light/dark mode switch.
|
||||
**Can you opt out?** You can block this cookie. If you do, the platform will fall back to your operating system's colour scheme preference and the theme may reset on every visit.
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Per-User Scoped Preference Cookies
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Name** | `rentaldrivego-language--{userID}`, `rentaldrivego-theme--{userID}` |
|
||||
| **Category** | Functional / preference |
|
||||
| **Duration** | 1 year |
|
||||
| **Scope** | All pages (`path=/`) |
|
||||
| **Third-party** | No |
|
||||
|
||||
**Purpose:** When you are signed in, the platform saves your language and theme preferences under a cookie that is tied to your account identifier. This allows multiple employees who share a device (for example, in a shared office environment) to each maintain independent preferences. The `{userID}` portion is derived from the authentication token and does not contain personal information beyond an internal account identifier.
|
||||
|
||||
**When it is set:** On every language or theme change while you are signed in.
|
||||
**Can you opt out?** You can block these cookies. If you do, your preferences will revert to the shared or default settings each time you sign in on a shared device.
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Legacy Preference Cookies (Transitional)
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Names** | `dashboard-language`, `marketplace-language` |
|
||||
| **Category** | Functional / preference |
|
||||
| **Duration** | 1 year |
|
||||
| **Scope** | All pages (`path=/`) |
|
||||
| **Third-party** | No |
|
||||
|
||||
**Purpose:** These cookies exist for backward compatibility with older versions of the platform. They store the same language preference as `rentaldrivego-language` but were scoped to individual sub-applications. New code always reads `rentaldrivego-language` first and only falls back to these if the primary cookie is absent.
|
||||
|
||||
**Planned removal:** These cookies will be removed once all users have transitioned to the unified preference system. They do not store any additional personal data.
|
||||
|
||||
---
|
||||
|
||||
## 4. What We Do Not Do
|
||||
|
||||
- We do **not** use cookies to track you across other websites.
|
||||
- We do **not** share cookie data with advertisers or data brokers.
|
||||
- We do **not** use session-replay or behavioural analytics cookies.
|
||||
- We do **not** set cookies from third-party domains on our pages.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cookie Consent
|
||||
|
||||
**Strictly necessary cookies** (section 3.1) do not require your consent because they are essential to provide the service you have requested.
|
||||
|
||||
**Functional / preference cookies** (sections 3.2–3.5) are used solely to remember your choices and improve your experience. They do not process personal data for marketing or tracking purposes. Under most privacy regulations (including GDPR), these may be set without explicit consent when used exclusively to fulfil user-requested functionality. Where local law requires explicit consent, a consent prompt will be displayed on your first visit.
|
||||
|
||||
---
|
||||
|
||||
## 6. How to Manage or Delete Cookies
|
||||
|
||||
You can control cookies through your browser settings. Common options include:
|
||||
|
||||
- **Block all cookies** — the platform will work but you will need to re-enter preferences on every visit and you will not be able to sign in.
|
||||
- **Block third-party cookies** — this will have no effect on RentalDriveGo as we do not use third-party cookies.
|
||||
- **Delete cookies on close** — you will be signed out and your preferences will reset each time you close the browser.
|
||||
- **Delete specific cookies** — use your browser's developer tools (Application → Cookies) to remove individual cookies by name.
|
||||
|
||||
Browser-specific instructions:
|
||||
|
||||
| Browser | Settings location |
|
||||
|---|---|
|
||||
| Chrome | Settings → Privacy and security → Cookies and other site data |
|
||||
| Firefox | Settings → Privacy & Security → Cookies and Site Data |
|
||||
| Safari | Settings → Privacy → Manage Website Data |
|
||||
| Edge | Settings → Cookies and site permissions → Cookies and site data |
|
||||
|
||||
---
|
||||
|
||||
## 7. Security
|
||||
|
||||
The authentication cookies (`employee_session` and `admin_session`) are signed, HttpOnly, and expire after the configured session window. They use `Secure` in production and are not readable by JavaScript. We recommend using the platform on trusted devices only and signing out when you are finished.
|
||||
|
||||
---
|
||||
|
||||
## 8. Changes to This Policy
|
||||
|
||||
We may update this Cookie Policy when we add new features or change how existing cookies work. The "Last updated" date at the top of this document will reflect any changes. Significant changes will be communicated through the platform or by email.
|
||||
|
||||
---
|
||||
|
||||
## 9. Contact
|
||||
|
||||
If you have questions about this Cookie Policy or how we handle your data, please contact us at:
|
||||
|
||||
**Email:** rentaldrivego@gmail.com
|
||||
**Website:** https://rentaldrivego.ma
|
||||
@@ -0,0 +1,152 @@
|
||||
# Features — Current Product Scope
|
||||
|
||||
This document lists the features that are active in the current codebase.
|
||||
|
||||
Source of truth:
|
||||
|
||||
- `apps/marketplace`
|
||||
- `apps/dashboard`
|
||||
- `apps/admin`
|
||||
- `apps/api`
|
||||
- `packages/database/prisma/schema.prisma`
|
||||
|
||||
## Active Applications
|
||||
|
||||
The current workspace contains four active apps:
|
||||
|
||||
- `apps/marketplace`
|
||||
- `apps/dashboard`
|
||||
- `apps/admin`
|
||||
- `apps/api`
|
||||
|
||||
There is no separate frontend app for a white-label company public site in this repo at the moment.
|
||||
|
||||
## Active Platform Features
|
||||
|
||||
### Company signup and employee access
|
||||
|
||||
- Company signup through the dashboard sign-up flow backed by `POST /auth/company/signup`
|
||||
- Employee sign-in with local email/password auth
|
||||
- Employee password reset flow
|
||||
- Team invitation flow that creates a pending employee and sends a reset-password invite link
|
||||
- Employee roles: `OWNER`, `MANAGER`, `AGENT`
|
||||
|
||||
### Multi-tenant company operations
|
||||
|
||||
- Company-scoped vehicles, customers, reservations, offers, payments, complaints, and notifications
|
||||
- API tenant isolation through employee auth plus `companyId` scoping
|
||||
- Company profile, brand, contract settings, insurance policies, pricing rules, and accounting settings
|
||||
- Public API key generation/regeneration for company integrations
|
||||
|
||||
### Dashboard operations
|
||||
|
||||
- Dashboard home with KPI cards and booking-source analytics
|
||||
- Fleet management
|
||||
- Vehicle photo uploads
|
||||
- Vehicle publish/unpublish
|
||||
- Vehicle status management
|
||||
- Vehicle maintenance logs
|
||||
- Vehicle calendar blocks
|
||||
- Reservation list, detail, create, update, confirm, check-in, check-out, close, extend, cancel
|
||||
- Reservation pickup/dropoff inspections
|
||||
- Reservation photo uploads
|
||||
- Additional-driver approval workflow
|
||||
- Online reservation intake queue
|
||||
- Customer CRM
|
||||
- Offer management
|
||||
- Team management
|
||||
- Company billing page for rental payments
|
||||
- Contract generation/viewing
|
||||
- Reviews and complaints management
|
||||
- Notification inbox and preferences
|
||||
- Subscription management
|
||||
|
||||
### Marketplace and public platform
|
||||
|
||||
- Public marketing homepage
|
||||
- Public pricing page
|
||||
- Public features page
|
||||
- Public marketplace/explore flow
|
||||
- Explore company profile pages under `/explore/[slug]`
|
||||
- Public review submission page via review token
|
||||
- Public legal/app policy pages
|
||||
- Public platform content loaded from `/site/platform/*` API endpoints
|
||||
|
||||
### Subscription and billing
|
||||
|
||||
- SaaS trial and subscription lifecycle
|
||||
- Subscription status handling including `TRIALING`, `ACTIVE`, `PAYMENT_PENDING`, `PAST_DUE`, `SUSPENDED`, `CANCELLED`, `EXPIRED`, `PAUSED`, `UNPAID`
|
||||
- Plan pricing loaded from DB or fallback config
|
||||
- AmanPay and PayPal provider support for SaaS subscription checkout
|
||||
- Subscription invoice history
|
||||
- Platform billing accounts, billing invoices, billing events, refunds, credit notes, and tax records
|
||||
|
||||
### Rental payments
|
||||
|
||||
- AmanPay payment initialization and webhook handling
|
||||
- PayPal payment initialization and capture handling
|
||||
- Manual rental payment recording
|
||||
- Reservation refund support for successful online payments
|
||||
- Payment status tracking on reservations
|
||||
|
||||
### Notifications
|
||||
|
||||
- Email notifications
|
||||
- SMS notifications
|
||||
- WhatsApp notifications
|
||||
- In-app notifications
|
||||
- Notification templates and per-channel preferences
|
||||
- Notification history for company users
|
||||
|
||||
### Reservation-adjacent advanced operations
|
||||
|
||||
- Insurance policy configuration
|
||||
- Reservation insurance snapshots
|
||||
- Additional-driver charging rules
|
||||
- Driver license validation and approval states
|
||||
- Pricing rules and reservation-time pricing-rule snapshots
|
||||
- Damage inspections and damage points
|
||||
- Contract settings for fuel policy, tax, numbering, and additional-driver behavior
|
||||
- Reporting and export-oriented accounting defaults
|
||||
|
||||
### Admin app
|
||||
|
||||
- Admin login and password reset
|
||||
- Admin dashboard
|
||||
- Company management
|
||||
- Renter management
|
||||
- Admin-user management
|
||||
- Audit-log views
|
||||
- Billing operations
|
||||
- Pricing configuration and promotions
|
||||
- Notification review
|
||||
- Marketplace/site config management
|
||||
|
||||
## Present But Not Active Product Features
|
||||
|
||||
These exist partially in code or schema, but they are not active end-user product flows and should not be treated as shipped features.
|
||||
|
||||
- Renter self-service signup
|
||||
- Renter self-service login
|
||||
- Renter email verification flow
|
||||
- Clerk-based employee auth
|
||||
- Clerk webhooks
|
||||
- Clerk invitation acceptance
|
||||
- Admin impersonation through Clerk sessions
|
||||
- Multi-currency SaaS checkout beyond `MAD`
|
||||
- Separate white-label company-site frontend app
|
||||
|
||||
## Explicitly Out Of Scope For Current Docs
|
||||
|
||||
The following old design ideas should not be treated as active features unless code is added later:
|
||||
|
||||
- marketing blog
|
||||
- standalone about/contact page set
|
||||
- Google renter auth
|
||||
- automatic custom-domain provisioning
|
||||
- Zapier/webhook marketplace integrations beyond the current API/webhook handlers
|
||||
|
||||
## Notes
|
||||
|
||||
- The database still contains legacy fields such as `Employee.clerkUserId`, but those fields are no longer evidence of active Clerk integration.
|
||||
- Some renter-facing `/renter/*` pages exist in the marketplace app, but the auth entrypoints they depend on are not active. They are therefore not listed as active product scope here.
|
||||
@@ -0,0 +1,141 @@
|
||||
# Team Management Integration — Current Implementation
|
||||
|
||||
This document describes the current team-management integration in the live codebase.
|
||||
|
||||
Source of truth:
|
||||
|
||||
- `apps/api/src/modules/team/team.routes.ts`
|
||||
- `apps/api/src/services/teamService.ts`
|
||||
- `apps/dashboard/src/hooks/useTeam.ts`
|
||||
- `apps/dashboard/src/app/(dashboard)/team/page.tsx`
|
||||
- `apps/dashboard/src/app/reset-password/*`
|
||||
|
||||
## Current Model
|
||||
|
||||
Team management is local to RentalDriveGo. It no longer depends on Clerk.
|
||||
|
||||
The live flow is:
|
||||
|
||||
1. an owner invites a staff member from the dashboard
|
||||
2. the API creates a pending `Employee`
|
||||
3. the API generates a password-reset token
|
||||
4. an email is sent with a reset-password link
|
||||
5. the invited employee sets a password through the dashboard reset-password page
|
||||
6. the employee can then sign in through the standard employee login flow
|
||||
|
||||
There is no webhook step in the active implementation.
|
||||
|
||||
## Backend Integration
|
||||
|
||||
The team router is already mounted by the main API app in `apps/api/src/app.ts` under:
|
||||
|
||||
- `/api/v1/team`
|
||||
|
||||
The public webhook placeholder under `/api/v1/webhooks/clerk` is intentionally disabled and should not be used for team onboarding.
|
||||
|
||||
### Team endpoints
|
||||
|
||||
- `GET /api/v1/team`
|
||||
- `GET /api/v1/team/stats`
|
||||
- `POST /api/v1/team/invite`
|
||||
- `PATCH /api/v1/team/:id/role`
|
||||
- `POST /api/v1/team/:id/deactivate`
|
||||
- `POST /api/v1/team/:id/reactivate`
|
||||
- `DELETE /api/v1/team/:id`
|
||||
|
||||
### Auth and authorization
|
||||
|
||||
These routes are protected by:
|
||||
|
||||
- employee JWT auth
|
||||
- tenant resolution
|
||||
- subscription guard
|
||||
- role checks inside the router and service
|
||||
|
||||
Important rules in the current implementation:
|
||||
|
||||
- only the `OWNER` can invite, change roles, deactivate/reactivate, or remove team members
|
||||
- invited team members cannot be created with the `OWNER` role
|
||||
- the account owner cannot be deactivated or removed through team endpoints
|
||||
|
||||
## Invite Email Flow
|
||||
|
||||
Invitations are generated in `apps/api/src/services/teamService.ts`.
|
||||
|
||||
Current behavior:
|
||||
|
||||
- a pending employee row is created
|
||||
- `passwordResetToken` and `passwordResetExpiresAt` are populated
|
||||
- the invite email links directly to the dashboard reset-password page
|
||||
|
||||
Expected URL shape:
|
||||
|
||||
```text
|
||||
{DASHBOARD_URL}/reset-password?token=...
|
||||
```
|
||||
|
||||
Relevant environment variables:
|
||||
|
||||
```env
|
||||
DASHBOARD_URL=https://your-host/dashboard
|
||||
NEXT_PUBLIC_DASHBOARD_URL=https://your-host/dashboard
|
||||
NEXT_PUBLIC_API_URL=https://your-host/api/v1
|
||||
```
|
||||
|
||||
## Dashboard Integration
|
||||
|
||||
The team UI lives here:
|
||||
|
||||
- `apps/dashboard/src/app/(dashboard)/team/page.tsx`
|
||||
- `apps/dashboard/src/hooks/useTeam.ts`
|
||||
- `apps/dashboard/src/components/team/InviteModal.tsx`
|
||||
- `apps/dashboard/src/components/team/EditMemberModal.tsx`
|
||||
- `apps/dashboard/src/components/team/PermissionsMatrix.tsx`
|
||||
|
||||
The dashboard consumes the team API directly through `apiFetch(...)`.
|
||||
|
||||
### Dashboard page behavior
|
||||
|
||||
- loads members from `/team`
|
||||
- loads summary counts from `/team/stats`
|
||||
- opens invite/edit flows through local modals
|
||||
- updates local state after invite, role change, deactivate/reactivate, and removal
|
||||
|
||||
## Password Setup / Invite Acceptance
|
||||
|
||||
The active invite-acceptance mechanism is the dashboard reset-password flow, not a Clerk callback flow.
|
||||
|
||||
Relevant pages:
|
||||
|
||||
- `apps/dashboard/src/app/reset-password/page.tsx`
|
||||
- `apps/dashboard/src/app/reset-password/ResetPasswordPageClient.tsx`
|
||||
|
||||
This means:
|
||||
|
||||
- there is no `__clerk_ticket`
|
||||
- there is no Clerk redirect callback
|
||||
- there is no employee activation webhook
|
||||
|
||||
Invite completion is simply password setup using the token created by the API.
|
||||
|
||||
## Request Typing
|
||||
|
||||
The request object is extended in the API so team routes can rely on:
|
||||
|
||||
- `req.employee`
|
||||
- `req.company`
|
||||
- `req.companyId`
|
||||
|
||||
Those values are attached by the company auth and tenant middleware before team handlers run.
|
||||
|
||||
## What Was Removed
|
||||
|
||||
These older integration assumptions are no longer valid:
|
||||
|
||||
- Clerk webhooks
|
||||
- Clerk invitation acceptance
|
||||
- Clerk `user.created` activation flow
|
||||
- `/webhooks/clerk` as a required part of team onboarding
|
||||
- `@clerk/nextjs` integration in the dashboard team flow
|
||||
|
||||
If this functionality returns in the future, it should be documented as a new integration rather than assumed from this file.
|
||||
@@ -0,0 +1,739 @@
|
||||
# Notification Language and Localization Policy
|
||||
|
||||
## 1. Objective
|
||||
|
||||
The notification system must send all customer-facing notifications in the language selected by the user during account creation.
|
||||
|
||||
Supported initial languages:
|
||||
|
||||
```text
|
||||
EN
|
||||
AR
|
||||
FR
|
||||
```
|
||||
|
||||
Language mapping:
|
||||
|
||||
| User Selection | Locale Code | Notification Language |
|
||||
|---|---|---|
|
||||
| `EN` | `en` | English |
|
||||
| `AR` | `ar` | Arabic |
|
||||
| `FR` | `fr` | French |
|
||||
|
||||
This applies to account creation, workspace creation, invitations, trial notifications, subscription status notifications, billing notifications, invoice notifications, payment failure notifications, refunds, credits, in-app notifications, email notifications, SMS notifications if enabled, and customer-facing webhook labels if applicable.
|
||||
|
||||
Internal admin alerts may use the internal team’s default language unless configured otherwise.
|
||||
|
||||
---
|
||||
|
||||
## 2. Source of Truth for Notification Language
|
||||
|
||||
The user’s selected language at account creation must be stored and used as the primary language preference.
|
||||
|
||||
### Required User Field
|
||||
|
||||
```sql
|
||||
ALTER TABLE users
|
||||
ADD COLUMN preferred_language TEXT NOT NULL DEFAULT 'en';
|
||||
```
|
||||
|
||||
Allowed values:
|
||||
|
||||
```text
|
||||
en
|
||||
ar
|
||||
fr
|
||||
```
|
||||
|
||||
### Account Creation Requirement
|
||||
|
||||
During account creation, the user must select or confirm a language.
|
||||
|
||||
Example payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"name": "Example User",
|
||||
"preferred_language": "ar"
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `preferred_language` is required at account creation.
|
||||
- If not provided, default to `en`.
|
||||
- The selected language must be saved on the user profile.
|
||||
- The selected language must be used for all customer-facing notifications.
|
||||
- Users may update their preferred language later from account settings.
|
||||
- Updating the language affects future notifications only, not historical notifications.
|
||||
|
||||
---
|
||||
|
||||
## 3. Language Resolution Policy
|
||||
|
||||
When sending a notification, resolve the language in this order:
|
||||
|
||||
```text
|
||||
recipient user preferred_language
|
||||
workspace default_language
|
||||
billing account preferred_language
|
||||
organization default_language
|
||||
system default_language
|
||||
```
|
||||
|
||||
Default:
|
||||
|
||||
```text
|
||||
en
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
User preferred_language = ar
|
||||
Workspace default_language = fr
|
||||
System default_language = en
|
||||
|
||||
Selected notification language = ar
|
||||
```
|
||||
|
||||
The recipient’s own language wins. Not the workspace. Not the billing account. Not a hardcoded backend default.
|
||||
|
||||
---
|
||||
|
||||
## 4. Workspace and Billing Language Fields
|
||||
|
||||
In addition to the user’s language, store default language on workspace and billing account.
|
||||
|
||||
### Workspace Field
|
||||
|
||||
```sql
|
||||
ALTER TABLE workspaces
|
||||
ADD COLUMN default_language TEXT NOT NULL DEFAULT 'en';
|
||||
```
|
||||
|
||||
### Billing Account Field
|
||||
|
||||
```sql
|
||||
ALTER TABLE billing_accounts
|
||||
ADD COLUMN preferred_language TEXT NOT NULL DEFAULT 'en';
|
||||
```
|
||||
|
||||
Purpose:
|
||||
|
||||
| Field | Purpose |
|
||||
|---|---|
|
||||
| `users.preferred_language` | Primary language for direct user notifications |
|
||||
| `workspaces.default_language` | Fallback for workspace-level notifications |
|
||||
| `billing_accounts.preferred_language` | Fallback for finance/billing notifications |
|
||||
| `system.default_language` | Final fallback |
|
||||
|
||||
---
|
||||
|
||||
## 5. Template Localization Policy
|
||||
|
||||
Every customer-facing notification template must exist in all supported languages.
|
||||
|
||||
Required locales:
|
||||
|
||||
```text
|
||||
en
|
||||
ar
|
||||
fr
|
||||
```
|
||||
|
||||
### Template Table Update
|
||||
|
||||
```sql
|
||||
CREATE TABLE notification_templates (
|
||||
id UUID PRIMARY KEY,
|
||||
|
||||
template_key TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
|
||||
locale TEXT NOT NULL,
|
||||
|
||||
subject TEXT,
|
||||
body TEXT NOT NULL,
|
||||
|
||||
required_variables JSONB,
|
||||
optional_variables JSONB,
|
||||
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE(template_key, channel, locale, version)
|
||||
);
|
||||
```
|
||||
|
||||
Preferred template structure:
|
||||
|
||||
```text
|
||||
template_key = account.created.email
|
||||
locale = en
|
||||
|
||||
template_key = account.created.email
|
||||
locale = ar
|
||||
|
||||
template_key = account.created.email
|
||||
locale = fr
|
||||
```
|
||||
|
||||
Do not encode the locale into the template key if the table already has a `locale` column. Duplicating state is how small mistakes become expensive folklore.
|
||||
|
||||
---
|
||||
|
||||
## 6. Template Lookup Policy
|
||||
|
||||
When rendering a notification:
|
||||
|
||||
```text
|
||||
1. Resolve recipient language.
|
||||
2. Find active template for template_key + channel + resolved locale.
|
||||
3. If unavailable, fall back to English.
|
||||
4. If English template is unavailable, fail notification creation and alert admins.
|
||||
```
|
||||
|
||||
Lookup flow:
|
||||
|
||||
```text
|
||||
event received
|
||||
↓
|
||||
resolve recipient
|
||||
↓
|
||||
resolve recipient language
|
||||
↓
|
||||
find localized template
|
||||
↓
|
||||
render template
|
||||
↓
|
||||
send notification
|
||||
```
|
||||
|
||||
Fallback rules:
|
||||
|
||||
| Condition | Action |
|
||||
|---|---|
|
||||
| `ar` template exists | Send Arabic notification |
|
||||
| `fr` template exists | Send French notification |
|
||||
| Requested locale missing | Fall back to English |
|
||||
| English fallback missing | Fail and alert internal admins |
|
||||
| Template variables missing | Fail and alert internal admins |
|
||||
|
||||
Fallback to English is allowed only as a safety net. It should be monitored as a product defect.
|
||||
|
||||
---
|
||||
|
||||
## 7. Arabic Language Requirements
|
||||
|
||||
Arabic notifications must support right-to-left rendering.
|
||||
|
||||
For locale:
|
||||
|
||||
```text
|
||||
ar
|
||||
```
|
||||
|
||||
Apply:
|
||||
|
||||
```html
|
||||
<html lang="ar" dir="rtl">
|
||||
```
|
||||
|
||||
or for partial rendering:
|
||||
|
||||
```html
|
||||
<div lang="ar" dir="rtl">
|
||||
...
|
||||
</div>
|
||||
```
|
||||
|
||||
Arabic requirements:
|
||||
|
||||
- Use RTL layout for email and in-app notifications.
|
||||
- Align Arabic text to the right.
|
||||
- Use Arabic translations for all customer-facing labels.
|
||||
- Use localized date formatting where possible.
|
||||
- Keep invoice numbers, amounts, and technical IDs readable.
|
||||
- Avoid mixing English UI labels into Arabic notifications unless they are brand or product names.
|
||||
- Test Arabic templates separately.
|
||||
|
||||
---
|
||||
|
||||
## 8. French Language Requirements
|
||||
|
||||
For locale:
|
||||
|
||||
```text
|
||||
fr
|
||||
```
|
||||
|
||||
Apply:
|
||||
|
||||
```html
|
||||
<html lang="fr">
|
||||
```
|
||||
|
||||
French requirements:
|
||||
|
||||
- Use French templates for all customer-facing messages.
|
||||
- Use localized date formatting.
|
||||
- Use localized money formatting where appropriate.
|
||||
- Avoid partial English/French mixed messages.
|
||||
- Keep plan names and product names unchanged unless officially translated.
|
||||
|
||||
---
|
||||
|
||||
## 9. English Language Requirements
|
||||
|
||||
For locale:
|
||||
|
||||
```text
|
||||
en
|
||||
```
|
||||
|
||||
Apply:
|
||||
|
||||
```html
|
||||
<html lang="en">
|
||||
```
|
||||
|
||||
English is the default fallback language.
|
||||
|
||||
---
|
||||
|
||||
## 10. Localized Formatting Policy
|
||||
|
||||
Notifications should localize:
|
||||
|
||||
- Subject lines
|
||||
- Body text
|
||||
- Button labels
|
||||
- Status labels
|
||||
- Dates
|
||||
- Currency display
|
||||
- Invoice labels
|
||||
- Subscription status labels
|
||||
- Error messages
|
||||
- Call-to-action text
|
||||
|
||||
Date formatting examples:
|
||||
|
||||
| Locale | Display |
|
||||
|---|---|
|
||||
| `en` | June 15, 2026 |
|
||||
| `fr` | 15 juin 2026 |
|
||||
| `ar` | ١٥ يونيو ٢٠٢٦ |
|
||||
|
||||
Currency formatting should respect both billing currency and locale.
|
||||
|
||||
Example for USD:
|
||||
|
||||
| Locale | Display |
|
||||
|---|---|
|
||||
| `en` | $1,200.00 |
|
||||
| `fr` | 1 200,00 $US |
|
||||
| `ar` | ١٬٢٠٠٫٠٠ US$ |
|
||||
|
||||
Do not store localized amounts as the source of truth. Store money as integer minor units and format at render time.
|
||||
|
||||
---
|
||||
|
||||
## 11. Localized Subscription Status Labels
|
||||
|
||||
### English
|
||||
|
||||
| Internal Status | English Label |
|
||||
|---|---|
|
||||
| `trialing` | Trial active |
|
||||
| `active` | Active |
|
||||
| `payment_pending` | Payment pending |
|
||||
| `past_due` | Payment overdue |
|
||||
| `suspended` | Suspended |
|
||||
| `canceled` | Canceled |
|
||||
| `expired` | Expired |
|
||||
|
||||
### French
|
||||
|
||||
| Internal Status | French Label |
|
||||
|---|---|
|
||||
| `trialing` | Essai actif |
|
||||
| `active` | Actif |
|
||||
| `payment_pending` | Paiement en attente |
|
||||
| `past_due` | Paiement en retard |
|
||||
| `suspended` | Suspendu |
|
||||
| `canceled` | Annulé |
|
||||
| `expired` | Expiré |
|
||||
|
||||
### Arabic
|
||||
|
||||
| Internal Status | Arabic Label |
|
||||
|---|---|
|
||||
| `trialing` | الفترة التجريبية نشطة |
|
||||
| `active` | نشط |
|
||||
| `payment_pending` | الدفع قيد الانتظار |
|
||||
| `past_due` | الدفع متأخر |
|
||||
| `suspended` | معلّق |
|
||||
| `canceled` | ملغى |
|
||||
| `expired` | منتهي |
|
||||
|
||||
---
|
||||
|
||||
## 12. Localized Invoice Labels
|
||||
|
||||
### English
|
||||
|
||||
| Internal Invoice Status | English Label |
|
||||
|---|---|
|
||||
| `draft` | Preparing |
|
||||
| `open` | Due |
|
||||
| `payment_pending` | Payment processing |
|
||||
| `paid` | Paid |
|
||||
| `partially_paid` | Partially paid |
|
||||
| `past_due` | Past due |
|
||||
| `void` | Canceled |
|
||||
| `uncollectible` | Contact support |
|
||||
| `refunded` | Refunded |
|
||||
| `partially_refunded` | Partially refunded |
|
||||
|
||||
### French
|
||||
|
||||
| Internal Invoice Status | French Label |
|
||||
|---|---|
|
||||
| `draft` | En préparation |
|
||||
| `open` | À payer |
|
||||
| `payment_pending` | Paiement en cours |
|
||||
| `paid` | Payée |
|
||||
| `partially_paid` | Partiellement payée |
|
||||
| `past_due` | En retard |
|
||||
| `void` | Annulée |
|
||||
| `uncollectible` | Contacter le support |
|
||||
| `refunded` | Remboursée |
|
||||
| `partially_refunded` | Partiellement remboursée |
|
||||
|
||||
### Arabic
|
||||
|
||||
| Internal Invoice Status | Arabic Label |
|
||||
|---|---|
|
||||
| `draft` | قيد الإعداد |
|
||||
| `open` | مستحقة الدفع |
|
||||
| `payment_pending` | الدفع قيد المعالجة |
|
||||
| `paid` | مدفوعة |
|
||||
| `partially_paid` | مدفوعة جزئياً |
|
||||
| `past_due` | متأخرة |
|
||||
| `void` | ملغاة |
|
||||
| `uncollectible` | تواصل مع الدعم |
|
||||
| `refunded` | مستردة |
|
||||
| `partially_refunded` | مستردة جزئياً |
|
||||
|
||||
---
|
||||
|
||||
## 13. Notification Creation Logic
|
||||
|
||||
When an event creates a notification, include the resolved locale.
|
||||
|
||||
### Notification Record Update
|
||||
|
||||
```sql
|
||||
ALTER TABLE notifications
|
||||
ADD COLUMN locale TEXT NOT NULL DEFAULT 'en';
|
||||
```
|
||||
|
||||
Example notification record:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "invoice.finalized",
|
||||
"recipient_email": "finance@example.com",
|
||||
"recipient_role": "billing_owner",
|
||||
"channel": "email",
|
||||
"template_key": "invoice.finalized.email",
|
||||
"locale": "fr",
|
||||
"status": "pending"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Notification Rendering Pseudocode
|
||||
|
||||
```ts
|
||||
type SupportedLocale = "en" | "ar" | "fr";
|
||||
|
||||
function resolveNotificationLocale(recipient, workspace, billingAccount): SupportedLocale {
|
||||
return (
|
||||
recipient.preferredLanguage ||
|
||||
workspace.defaultLanguage ||
|
||||
billingAccount.preferredLanguage ||
|
||||
"en"
|
||||
);
|
||||
}
|
||||
|
||||
async function createNotification(event, recipient) {
|
||||
const locale = resolveNotificationLocale(
|
||||
recipient.user,
|
||||
event.workspace,
|
||||
event.billingAccount
|
||||
);
|
||||
|
||||
const template = await findTemplate({
|
||||
templateKey: event.templateKey,
|
||||
channel: event.channel,
|
||||
locale
|
||||
});
|
||||
|
||||
const fallbackTemplate = await findTemplate({
|
||||
templateKey: event.templateKey,
|
||||
channel: event.channel,
|
||||
locale: "en"
|
||||
});
|
||||
|
||||
const selectedTemplate = template || fallbackTemplate;
|
||||
|
||||
if (!selectedTemplate) {
|
||||
throw new Error("Missing notification template");
|
||||
}
|
||||
|
||||
return {
|
||||
eventType: event.type,
|
||||
recipientEmail: recipient.email,
|
||||
channel: event.channel,
|
||||
templateKey: event.templateKey,
|
||||
locale: selectedTemplate.locale,
|
||||
renderedContent: renderTemplate(selectedTemplate, event.payload)
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. Account Creation Flow Update
|
||||
|
||||
During account creation:
|
||||
|
||||
```text
|
||||
user selects language
|
||||
↓
|
||||
system stores preferred_language
|
||||
↓
|
||||
account.created event emitted
|
||||
↓
|
||||
notification system resolves preferred_language
|
||||
↓
|
||||
localized welcome notification sent
|
||||
```
|
||||
|
||||
### Account Creation API Update
|
||||
|
||||
```http
|
||||
POST /accounts
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Example User",
|
||||
"email": "user@example.com",
|
||||
"password": "secure_password",
|
||||
"preferred_language": "ar"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"account_id": "acct_123",
|
||||
"user_id": "user_123",
|
||||
"preferred_language": "ar"
|
||||
}
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
```text
|
||||
preferred_language must be one of: en, ar, fr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. Notification Preference Update
|
||||
|
||||
Language preference should be separate from notification category preferences.
|
||||
|
||||
Example user settings:
|
||||
|
||||
```text
|
||||
preferred_language = ar
|
||||
invoice.email.enabled = true
|
||||
billing.email.enabled = true
|
||||
onboarding.email.enabled = false
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Language controls the language of notifications.
|
||||
- Preferences control whether allowed notifications are sent.
|
||||
- Critical billing, invoice, payment, and security notifications still follow mandatory delivery rules.
|
||||
- Changing language does not unsubscribe the user from notifications.
|
||||
- Changing language affects future notifications only.
|
||||
|
||||
---
|
||||
|
||||
## 17. Testing Requirements
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Test:
|
||||
|
||||
```text
|
||||
User with preferred_language=en receives English template
|
||||
User with preferred_language=ar receives Arabic template
|
||||
User with preferred_language=fr receives French template
|
||||
Missing Arabic template falls back to English
|
||||
Missing French template falls back to English
|
||||
Missing English fallback fails notification creation
|
||||
Arabic email renders with dir="rtl"
|
||||
French email renders with lang="fr"
|
||||
English email renders with lang="en"
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Test:
|
||||
|
||||
```text
|
||||
Account created with EN → welcome email sent in English
|
||||
Account created with AR → welcome email sent in Arabic
|
||||
Account created with FR → welcome email sent in French
|
||||
Invoice finalized for AR billing owner → Arabic invoice notification
|
||||
Payment failed for FR billing owner → French payment failure notification
|
||||
Subscription suspended for EN admin → English suspension notification
|
||||
```
|
||||
|
||||
### End-to-End Tests
|
||||
|
||||
Test:
|
||||
|
||||
```text
|
||||
User creates account and selects Arabic
|
||||
↓
|
||||
Arabic welcome email is sent
|
||||
↓
|
||||
User starts trial
|
||||
↓
|
||||
Arabic trial notification is sent
|
||||
↓
|
||||
Invoice is generated
|
||||
↓
|
||||
Arabic invoice notification is sent
|
||||
↓
|
||||
Payment fails
|
||||
↓
|
||||
Arabic payment failure notification is sent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 18. Monitoring Requirements
|
||||
|
||||
Track localization metrics:
|
||||
|
||||
```text
|
||||
notifications_sent_by_locale
|
||||
notifications_failed_by_locale
|
||||
template_missing_by_locale
|
||||
fallback_to_english_count
|
||||
rtl_rendering_test_failures
|
||||
language_preference_update_count
|
||||
```
|
||||
|
||||
Alert on:
|
||||
|
||||
```text
|
||||
Missing Arabic template
|
||||
Missing French template
|
||||
Fallback-to-English spike
|
||||
Arabic rendering failure
|
||||
Localized template variable error
|
||||
```
|
||||
|
||||
Fallback-to-English should be treated as a defect, not a harmless convenience.
|
||||
|
||||
---
|
||||
|
||||
## 19. Updated Notification Localization Section
|
||||
|
||||
Replace the earlier notification localization section with this stricter version.
|
||||
|
||||
### Notification Localization
|
||||
|
||||
Notification templates must support the user’s selected account language.
|
||||
|
||||
Supported locales:
|
||||
|
||||
```text
|
||||
en
|
||||
ar
|
||||
fr
|
||||
```
|
||||
|
||||
Template lookup order:
|
||||
|
||||
```text
|
||||
recipient user preferred_language
|
||||
workspace default_language
|
||||
billing account preferred_language
|
||||
system default_language
|
||||
```
|
||||
|
||||
The language selected during account creation is the primary source of truth for user-facing notifications.
|
||||
|
||||
Arabic notifications must render in RTL mode.
|
||||
|
||||
French and English notifications must use localized date, currency, and status labels.
|
||||
|
||||
Fallback to English is allowed only when a localized template is missing, and every fallback must create an internal alert.
|
||||
|
||||
---
|
||||
|
||||
## 20. Final Localization Policy
|
||||
|
||||
Use this as the baseline rule:
|
||||
|
||||
```text
|
||||
The language selected by the user during account creation is the primary language for all customer-facing notifications.
|
||||
Supported languages are English, Arabic, and French.
|
||||
English uses locale en.
|
||||
Arabic uses locale ar and must render right-to-left.
|
||||
French uses locale fr.
|
||||
Each customer-facing notification template must exist in en, ar, and fr.
|
||||
If a localized template is missing, the system may fall back to English, but must log and alert the missing localization.
|
||||
Billing, invoice, subscription, and account notifications must use the recipient’s preferred language.
|
||||
Language preference is separate from notification opt-in preferences.
|
||||
Changing language affects future notifications only.
|
||||
Internal admin alerts may use the internal default language unless configured otherwise.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 21. Definition of Done
|
||||
|
||||
The notification localization system is complete when:
|
||||
|
||||
- Account creation stores `preferred_language`.
|
||||
- Supported languages are limited to `en`, `ar`, and `fr`.
|
||||
- Notification records store the resolved locale.
|
||||
- Email templates exist in English, Arabic, and French.
|
||||
- In-app notification templates exist in English, Arabic, and French.
|
||||
- Arabic templates render correctly in RTL.
|
||||
- French templates render with French labels and formatting.
|
||||
- Dates and currency are localized during rendering.
|
||||
- Missing localized templates fall back to English and create alerts.
|
||||
- Tests cover account, subscription, billing, invoice, and payment notifications for all supported languages.
|
||||
@@ -0,0 +1,179 @@
|
||||
# Pages and Route Inventory — Current Apps
|
||||
|
||||
This document lists the pages that are actually present in the current frontend apps.
|
||||
|
||||
Source of truth:
|
||||
|
||||
- `apps/marketplace/src/app`
|
||||
- `apps/dashboard/src/app`
|
||||
- `apps/admin/src/app`
|
||||
|
||||
## Marketplace App
|
||||
|
||||
App:
|
||||
|
||||
- `apps/marketplace`
|
||||
|
||||
### Public routes
|
||||
|
||||
- `/`
|
||||
- `/pricing`
|
||||
- `/features`
|
||||
- `/explore`
|
||||
- `/explore/[slug]`
|
||||
- `/review`
|
||||
- `/company-workspace`
|
||||
- `/platform-operations`
|
||||
- `/sign-in`
|
||||
- `/app-privacy-en`
|
||||
- `/app-privacy-fr`
|
||||
- `/app-privacy-ar`
|
||||
- `/app-tc-en`
|
||||
- `/app-tc-fr`
|
||||
- `/app-tc-ar`
|
||||
- `/footer/[slug]`
|
||||
|
||||
### Active route behavior
|
||||
|
||||
- `/` is the public marketing home.
|
||||
- `/pricing` consumes platform pricing from `/site/platform/pricing`.
|
||||
- `/explore` is the public marketplace search/discovery page.
|
||||
- `/explore/[slug]` is the company marketplace profile page.
|
||||
- `/review` is the review submission page driven by a reservation review token.
|
||||
|
||||
### Not present in the current marketplace app
|
||||
|
||||
- `/about`
|
||||
- `/contact`
|
||||
- `/blog`
|
||||
- `/explore/[slug]/vehicles/[id]`
|
||||
- a full company public-site booking frontend under its own app
|
||||
|
||||
### Renter routes present in code but not active product entrypoints
|
||||
|
||||
These routes exist in the marketplace app:
|
||||
|
||||
- `/renter/dashboard`
|
||||
- `/renter/notifications`
|
||||
- `/renter/profile`
|
||||
- `/renter/saved-companies`
|
||||
- `/renter/sign-in`
|
||||
- `/renter/sign-up`
|
||||
|
||||
However:
|
||||
|
||||
- `/renter/sign-in` redirects to the dashboard sign-in page
|
||||
- `/renter/sign-up` redirects to the dashboard sign-in page
|
||||
- API renter signup/login endpoints are disabled
|
||||
|
||||
Because of that, the renter self-service area should be treated as dormant or incomplete, not as an active supported user flow.
|
||||
|
||||
## Dashboard App
|
||||
|
||||
App:
|
||||
|
||||
- `apps/dashboard`
|
||||
|
||||
Base path:
|
||||
|
||||
- `/dashboard`
|
||||
|
||||
### Public/auth routes
|
||||
|
||||
- `/dashboard/sign-in`
|
||||
- `/dashboard/sign-up`
|
||||
- `/dashboard/forgot-password`
|
||||
- `/dashboard/reset-password`
|
||||
- `/dashboard/onboarding`
|
||||
- `/dashboard/onboarding/accept-invite`
|
||||
|
||||
### Private company workspace routes
|
||||
|
||||
- `/dashboard`
|
||||
- `/dashboard/fleet`
|
||||
- `/dashboard/fleet/[id]`
|
||||
- `/dashboard/reservations`
|
||||
- `/dashboard/reservations/new`
|
||||
- `/dashboard/reservations/[id]`
|
||||
- `/dashboard/online-reservations`
|
||||
- `/dashboard/customers`
|
||||
- `/dashboard/offers`
|
||||
- `/dashboard/team`
|
||||
- `/dashboard/reports`
|
||||
- `/dashboard/subscription`
|
||||
- `/dashboard/billing`
|
||||
- `/dashboard/contracts`
|
||||
- `/dashboard/contracts/[id]`
|
||||
- `/dashboard/reviews`
|
||||
- `/dashboard/complaints`
|
||||
- `/dashboard/notifications`
|
||||
- `/dashboard/settings`
|
||||
|
||||
### Route responsibilities
|
||||
|
||||
- `/dashboard` shows KPIs and booking-source analytics.
|
||||
- `/dashboard/fleet*` manages vehicles, maintenance, and calendar blocks.
|
||||
- `/dashboard/reservations*` manages the booking lifecycle and inspection workflows.
|
||||
- `/dashboard/online-reservations` handles public/marketplace booking intake.
|
||||
- `/dashboard/customers` is the company CRM view.
|
||||
- `/dashboard/offers` manages promotions.
|
||||
- `/dashboard/team` manages employees.
|
||||
- `/dashboard/reports` shows analytics/reporting views.
|
||||
- `/dashboard/subscription` manages SaaS plan state and invoices.
|
||||
- `/dashboard/billing` manages rental payment collection and balances.
|
||||
- `/dashboard/contracts*` opens rental contracts.
|
||||
- `/dashboard/reviews` and `/dashboard/complaints` handle post-rental follow-up.
|
||||
- `/dashboard/notifications` shows notification inbox/history/preferences.
|
||||
- `/dashboard/settings` manages brand, domains, policies, insurance, pricing, and accounting settings.
|
||||
|
||||
## Admin App
|
||||
|
||||
App:
|
||||
|
||||
- `apps/admin`
|
||||
|
||||
### Public routes
|
||||
|
||||
- `/`
|
||||
- `/login`
|
||||
- `/forgot-password`
|
||||
- `/reset-password`
|
||||
- `/auth-redirect`
|
||||
|
||||
### Private admin dashboard routes
|
||||
|
||||
- `/dashboard`
|
||||
- `/dashboard/companies`
|
||||
- `/dashboard/companies/[id]`
|
||||
- `/dashboard/renters`
|
||||
- `/dashboard/admin-users`
|
||||
- `/dashboard/audit-logs`
|
||||
- `/dashboard/billing`
|
||||
- `/dashboard/pricing`
|
||||
- `/dashboard/notifications`
|
||||
- `/dashboard/site-config`
|
||||
- `/dashboard/containers`
|
||||
|
||||
### Route responsibilities
|
||||
|
||||
- `/dashboard` is the admin overview.
|
||||
- `/dashboard/companies*` manages tenant companies.
|
||||
- `/dashboard/renters` inspects and blocks/unblocks renter accounts.
|
||||
- `/dashboard/admin-users` manages admin operators.
|
||||
- `/dashboard/audit-logs` reviews admin activity.
|
||||
- `/dashboard/billing` manages platform billing operations.
|
||||
- `/dashboard/pricing` edits platform pricing, features, and promotions.
|
||||
- `/dashboard/notifications` inspects platform notifications.
|
||||
- `/dashboard/site-config` edits marketplace/site configuration content.
|
||||
|
||||
## Deliberately Not Listed
|
||||
|
||||
This document excludes:
|
||||
|
||||
- API endpoints
|
||||
- dormant backend-only concepts that do not have an active frontend route
|
||||
- design-spec routes that are not present in the current app trees
|
||||
|
||||
For backend route inventory, use:
|
||||
|
||||
- `docs/project-design/api-routes.md`
|
||||
@@ -0,0 +1,179 @@
|
||||
# Security Vulnerability Fix Report
|
||||
|
||||
**Date:** 2026-05-22
|
||||
**Scope:** API (`apps/api`) — automated scan + manual review
|
||||
**Branch:** `develop`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
A full security review of the API codebase identified 3 confirmed, high-confidence vulnerabilities. All 3 were fixed in the same session. A separate frontend scan (dashboard, admin, marketplace, public-site) found no confirmed vulnerabilities above the reporting threshold.
|
||||
|
||||
| # | Severity | Category | Status |
|
||||
|---|----------|----------|--------|
|
||||
| [VF-01](#vf-01--payment-credential-exposure) | High | Credential / Data Exposure | Fixed |
|
||||
| [VF-02](#vf-02--webhook-signature-bypass) | High | Authentication Bypass | Fixed |
|
||||
| [VF-03](#vf-03--idor-on-vehicle-maintenance-logs) | Medium-High | IDOR / Tenant Isolation | Fixed |
|
||||
|
||||
---
|
||||
|
||||
## VF-01 — Payment Credential Exposure
|
||||
|
||||
**Severity:** High
|
||||
**Confidence:** 9/10
|
||||
**Category:** Credential / Data Exposure
|
||||
|
||||
### Description
|
||||
|
||||
`GET /api/v1/companies/me/brand` returned the full `BrandSettings` database row, including the live payment gateway credentials `amanpaySecretKey`, `amanpayMerchantId`, `paypalEmail`, and `paypalMerchantId` in the JSON response. The endpoint had no role guard, so any authenticated employee — including the lowest-privilege `AGENT` role — could call it and receive the secret key in plaintext.
|
||||
|
||||
### Exploit Scenario
|
||||
|
||||
An `AGENT`-role employee calls `GET /api/v1/companies/me/brand` with a valid session token. The response body includes `amanpaySecretKey`. The attacker uses the merchant ID and secret key to interact directly with the AmanPay payment gateway on behalf of the company, initiating fraudulent charges or refunds outside the application.
|
||||
|
||||
### Root Cause
|
||||
|
||||
`presentBrand()` in `company.presenter.ts` was a transparent passthrough (`return brand`). The DB query had no `select` clause, so Prisma returned every column. The public-facing site module correctly stripped these fields (evidenced by explicit test assertions), but the authenticated dashboard endpoint did not.
|
||||
|
||||
### Fix
|
||||
|
||||
**File:** `apps/api/src/modules/companies/company.presenter.ts`
|
||||
|
||||
`presentBrand()` now destructures and drops all four credential fields before returning. The caller receives boolean presence indicators (`amanpayConfigured`, `paypalConfigured`) instead of the raw secrets.
|
||||
|
||||
```diff
|
||||
export function presentBrand(brand: any) {
|
||||
- return brand
|
||||
+ if (!brand) return brand
|
||||
+ const { amanpaySecretKey, amanpayMerchantId, paypalEmail, paypalMerchantId, ...safe } = brand
|
||||
+ return {
|
||||
+ ...safe,
|
||||
+ amanpayConfigured: !!(amanpayMerchantId && amanpaySecretKey),
|
||||
+ paypalConfigured: !!(paypalEmail || paypalMerchantId),
|
||||
+ }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VF-02 — Webhook Signature Bypass
|
||||
|
||||
**Severity:** High
|
||||
**Confidence:** 8/10
|
||||
**Category:** Authentication Bypass / Payment Fraud
|
||||
|
||||
### Description
|
||||
|
||||
Both payment and subscription webhook endpoints for AmanPay and PayPal used a short-circuit AND guard for signature verification:
|
||||
|
||||
```ts
|
||||
if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) {
|
||||
return res.status(401).json({ error: 'invalid_signature' })
|
||||
}
|
||||
await service.handleAmanpayWebhook(req.body) // executed unconditionally
|
||||
```
|
||||
|
||||
When `isConfigured()` returns `false` (i.e., credentials are absent or set to placeholder defaults), the entire guard short-circuits and the webhook handler executes on any unauthenticated request. The endpoints had no IP allowlist and were explicitly placed before `requireCompanyAuth`.
|
||||
|
||||
This affected 4 endpoints:
|
||||
- `POST /api/v1/payments/webhooks/amanpay`
|
||||
- `POST /api/v1/payments/webhooks/paypal`
|
||||
- `POST /api/v1/subscriptions/webhooks/amanpay`
|
||||
- `POST /api/v1/subscriptions/webhooks/paypal`
|
||||
|
||||
### Exploit Scenario
|
||||
|
||||
On any staging or misconfigured production environment where AmanPay credentials are not set, an attacker posts `{"transaction_id": "<known_id>", "status": "PAID"}` to `/api/v1/payments/webhooks/amanpay`. The handler finds the pending payment by `transactionId`, marks it succeeded, and unlocks the reservation — with no money changing hands. A valid `amanpayTransactionId` can leak through receipts, logs, or API responses visible to renters.
|
||||
|
||||
The same attack against `/api/v1/subscriptions/webhooks/amanpay` activates a paid subscription tier for free.
|
||||
|
||||
### Fix
|
||||
|
||||
**Files:** `apps/api/src/modules/payments/payment.routes.ts`, `apps/api/src/modules/subscriptions/subscription.routes.ts`
|
||||
|
||||
Inverted the guard logic: an unconfigured provider now immediately returns `401` instead of passing through. Applied to all 4 endpoints.
|
||||
|
||||
```diff
|
||||
-if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) {
|
||||
+if (!amanpay.isConfigured() || !amanpay.verifyWebhookSignature(rawBody, signature)) {
|
||||
return res.status(401).json({ error: 'invalid_signature' })
|
||||
}
|
||||
```
|
||||
|
||||
```diff
|
||||
-if (paypal.isConfigured() && !isValid) return res.status(401).json({ error: 'invalid_signature' })
|
||||
+if (!paypal.isConfigured() || !isValid) return res.status(401).json({ error: 'invalid_signature' })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VF-03 — IDOR on Vehicle Maintenance Logs
|
||||
|
||||
**Severity:** Medium-High
|
||||
**Confidence:** 9/10
|
||||
**Category:** Insecure Direct Object Reference (IDOR) / Tenant Isolation Bypass
|
||||
|
||||
### Description
|
||||
|
||||
`GET /api/v1/vehicles/:id/maintenance` was protected by `requireCompanyAuth` and `requireTenant`, making `req.companyId` available. However, `req.companyId` was never forwarded to the service or repository. The database query used only `vehicleId` with no tenant filter:
|
||||
|
||||
```ts
|
||||
// vehicle.repo.ts
|
||||
prisma.maintenanceLog.findMany({ where: { vehicleId } })
|
||||
```
|
||||
|
||||
An authenticated employee from Company A could fetch the full maintenance history of any vehicle belonging to any other company by knowing its UUID. The write path (`POST /:id/maintenance`) already performed the correct ownership check using `repo.findById(id, companyId)` — the read path did not.
|
||||
|
||||
### Exploit Scenario
|
||||
|
||||
An employee of Company A calls `GET /api/v1/vehicles/<vehicleId_from_company_B>/maintenance`. Vehicle UUIDs can leak through marketplace listings, shared bookings, or support interactions. The response contains the complete maintenance history of Company B's vehicle — including service records, mileage data, and operational schedules — across tenant boundaries.
|
||||
|
||||
### Fix
|
||||
|
||||
**Files:** `apps/api/src/modules/vehicles/vehicle.routes.ts`, `apps/api/src/modules/vehicles/vehicle.service.ts`
|
||||
|
||||
`req.companyId` is now threaded through to `getMaintenanceLogs`, which performs the same ownership pre-check already used by the write path. A cross-tenant request returns `404`.
|
||||
|
||||
```diff
|
||||
// vehicle.routes.ts
|
||||
-const logs = await service.getMaintenanceLogs(id)
|
||||
+const logs = await service.getMaintenanceLogs(id, req.companyId)
|
||||
```
|
||||
|
||||
```diff
|
||||
// vehicle.service.ts
|
||||
-export async function getMaintenanceLogs(id: string) {
|
||||
- return repo.findMaintenanceLogs(id)
|
||||
+export async function getMaintenanceLogs(id: string, companyId: string) {
|
||||
+ const vehicle = await repo.findById(id, companyId)
|
||||
+ if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||
+ return repo.findMaintenanceLogs(id)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Scan Results
|
||||
|
||||
A full scan of all four frontend applications (dashboard, admin, marketplace, public-site) was performed. No vulnerabilities above the reporting threshold (confidence ≥ 8/10) were confirmed.
|
||||
|
||||
### Hardening Recommendations (not vulnerabilities)
|
||||
|
||||
Two items were identified as best-practice improvements:
|
||||
|
||||
1. **Admin auth-redirect `next` param** (`apps/admin/src/app/auth-redirect/page.tsx`) — Validate the `next` query parameter is a relative path (starts with `/`, does not contain `://`) before passing it to `router.replace`. The router itself does not perform cross-origin navigation, but defense-in-depth is recommended.
|
||||
|
||||
2. **`postMessage` wildcard origin + missing `frame-ancestors` CSP** (`apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx`, `apps/dashboard/next.config.js`) — Replace `targetOrigin: '*'` with the marketplace origin, and add `Content-Security-Policy: frame-ancestors 'self' <marketplace-origin>` to prevent the sign-in page from being embeddable by arbitrary third-party domains.
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `apps/api/src/modules/companies/company.presenter.ts` | Strip payment credentials in `presentBrand()` |
|
||||
| `apps/api/src/modules/payments/payment.routes.ts` | Fix webhook signature bypass (AmanPay + PayPal) |
|
||||
| `apps/api/src/modules/subscriptions/subscription.routes.ts` | Fix webhook signature bypass (AmanPay + PayPal) |
|
||||
| `apps/api/src/modules/vehicles/vehicle.routes.ts` | Pass `companyId` to maintenance log service |
|
||||
| `apps/api/src/modules/vehicles/vehicle.service.ts` | Add ownership check in `getMaintenanceLogs` |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,396 @@
|
||||
# API Architecture and Route Design
|
||||
|
||||
This document explains how the API is structured today, how requests move through the stack, and what each route group is responsible for.
|
||||
|
||||
Source of truth for the runtime wiring:
|
||||
|
||||
- `apps/api/src/app.ts`
|
||||
- `apps/api/src/modules/*`
|
||||
- `apps/api/src/middleware/*`
|
||||
- `apps/api/src/swagger/openapi.ts`
|
||||
|
||||
## Runtime Overview
|
||||
|
||||
The API is an Express application mounted under `/api/v1` with a few non-versioned utility endpoints:
|
||||
|
||||
- `GET /health` returns process health.
|
||||
- `GET /docs` serves Swagger UI.
|
||||
- `GET /api/v1/openapi.json` returns the generated OpenAPI document.
|
||||
- `/storage/*` serves uploaded assets such as logos, hero images, vehicle photos, and reservation/customer documents.
|
||||
|
||||
The app boot sequence in `apps/api/src/app.ts` is intentionally ordered:
|
||||
|
||||
1. CORS is applied first.
|
||||
2. storage guards are applied before static serving so private customer license files are never anonymously retrievable.
|
||||
3. Swagger UI is mounted before Helmet so the UI assets are not blocked by CSP.
|
||||
4. webhook routes that require raw payload handling are mounted before `express.json()`.
|
||||
5. Helmet, request logging, JSON parsing, and the module routers are mounted.
|
||||
6. the centralized error middleware converts validation, Prisma, and application errors into consistent JSON responses.
|
||||
|
||||
## Request Lifecycle
|
||||
|
||||
Most internal company routes follow the same pattern:
|
||||
|
||||
1. Express router receives the request.
|
||||
2. Zod schemas validate `req.params`, `req.query`, and `req.body` through `parseParams`, `parseQuery`, and `parseBody`.
|
||||
3. auth middleware resolves the caller identity.
|
||||
4. tenant middleware resolves the company record.
|
||||
5. subscription middleware blocks suspended or pending companies where required.
|
||||
6. role middleware enforces employee or admin permissions.
|
||||
7. the route handler calls a service function.
|
||||
8. the service applies business rules and calls a repo or Prisma directly.
|
||||
9. a presenter may normalize the payload for frontend consumption.
|
||||
10. `ok()` or `created()` wraps successful responses in `{ "data": ... }`.
|
||||
|
||||
Common exceptions:
|
||||
|
||||
- `GET /health` and `GET /api/v1/docs` return raw JSON, not the `{ data }` envelope.
|
||||
- webhook endpoints return minimal receipt payloads.
|
||||
- some public endpoints return graceful fallback responses if the database is unavailable.
|
||||
|
||||
## Security and Access Model
|
||||
|
||||
There are four main access patterns.
|
||||
|
||||
### Employee JWT
|
||||
|
||||
`requireCompanyAuth` validates a Bearer token with `type === "employee"`, loads the employee, and attaches:
|
||||
|
||||
- `req.employee`
|
||||
- `req.company`
|
||||
- `req.companyId`
|
||||
|
||||
This is the default for dashboard/company management routes.
|
||||
|
||||
### Tenant Resolution
|
||||
|
||||
`requireTenant` reloads the company from `req.companyId` and guarantees the tenant context exists. Company-scoped modules then query by `companyId`.
|
||||
|
||||
### Subscription Guard
|
||||
|
||||
`requireSubscription` blocks company routes when the company status is `PENDING` or `SUSPENDED`. Public site and marketplace routes are intentionally not behind this guard.
|
||||
|
||||
### Role-Based Access Control
|
||||
|
||||
Employee roles are hierarchical:
|
||||
|
||||
- `OWNER`
|
||||
- `MANAGER`
|
||||
- `AGENT`
|
||||
|
||||
Admin roles are hierarchical:
|
||||
|
||||
- `SUPER_ADMIN`
|
||||
- `ADMIN`
|
||||
- `SUPPORT`
|
||||
- `FINANCE`
|
||||
- `VIEWER`
|
||||
|
||||
### Renter JWT
|
||||
|
||||
`requireRenterAuth` validates a Bearer token with `type === "renter"` and attaches `req.renterId`.
|
||||
|
||||
`optionalRenterAuth` is used on marketplace routes so public reads still work without a token.
|
||||
|
||||
### API Key
|
||||
|
||||
`requireApiKey` accepts a company public API key in `x-api-key`. This is the low-friction integration path for public company-site style calls.
|
||||
|
||||
## Implementation Pattern
|
||||
|
||||
The API codebase is organized per module. The most common pattern is:
|
||||
|
||||
- `*.routes.ts`: route definitions and middleware composition
|
||||
- `*.schemas.ts`: Zod input validation
|
||||
- `*.service.ts`: business rules and orchestration
|
||||
- `*.repo.ts`: Prisma access helpers
|
||||
- `*.presenter.ts`: response shaping
|
||||
|
||||
The `vehicles` module is representative:
|
||||
|
||||
- `vehicle.routes.ts` mounts auth, tenant, and subscription guards once for the router.
|
||||
- route handlers validate input and call service functions.
|
||||
- `vehicle.service.ts` contains business rules such as publish-state normalization and availability calculation.
|
||||
- `vehicle.repo.ts` owns the Prisma queries and enforces tenant filters in reads.
|
||||
- `vehicle.presenter.ts` shapes the response returned to clients.
|
||||
|
||||
Some modules split specialized workflows into additional services instead of one large file. The reservations module is the clearest example:
|
||||
|
||||
- `reservation.service.ts` handles CRUD and list behavior
|
||||
- `reservation.lifecycle.service.ts` handles confirm/checkin/checkout/close/extend/cancel transitions
|
||||
- `reservation.inspection.service.ts` handles pickup/dropoff inspections
|
||||
- `reservation.additional-driver.service.ts` handles approval workflows
|
||||
- `reservation.photo.service.ts` handles pickup/dropoff photo uploads
|
||||
- `reservation.document.service.ts` generates contract and billing payloads
|
||||
|
||||
## Route Groups
|
||||
|
||||
The table below describes the current route groups mounted in `app.ts`.
|
||||
|
||||
| Base path | Access | Purpose | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `/health` | Public | Process health | Non-versioned utility endpoint |
|
||||
| `/docs` | Public | Swagger UI | Backed by `openapi.ts` |
|
||||
| `/api/v1/openapi.json` | Public | OpenAPI JSON | Generated from Zod schemas and manual path entries |
|
||||
| `/api/v1/auth/company` | Public | Company signup | `complete-signup` and `verify-email` now return disabled errors |
|
||||
| `/api/v1/auth/employee` | Public plus employee JWT | Employee login, password reset, profile, language | Dashboard/staff authentication path |
|
||||
| `/api/v1/auth/renter` | Renter JWT for profile routes | Renter profile and push token | `signup` and `login` are currently disabled in this codebase |
|
||||
| `/api/v1/admin` | Admin JWT | Platform operations | Includes company admin, billing, subscriptions, pricing, audit, and admin-user management |
|
||||
| `/api/v1/marketplace` | Public, optional renter JWT | Marketplace discovery and reservation intake | Designed for discovery and lead capture across companies |
|
||||
| `/api/v1/site` | Public | White-label company site APIs | Drives each company-branded booking site |
|
||||
| `/api/v1/subscriptions` | Mixed | Plan listing, webhooks, subscription lifecycle | Public, webhook, and authenticated sub-routers share the same prefix |
|
||||
| `/api/v1/vehicles` | Employee JWT + tenant + subscription | Fleet management | Includes photos, status, calendar blocks, maintenance, and availability |
|
||||
| `/api/v1/reservations` | Employee JWT + tenant + subscription | Reservation operations | Central booking lifecycle for internal staff |
|
||||
| `/api/v1/team` | Employee JWT + tenant + subscription | Employee/team management | Owner-controlled invite, role, activation, removal |
|
||||
| `/api/v1/customers` | Employee JWT + tenant + subscription | Company CRM and license handling | Includes protected license-image retrieval and approval flows |
|
||||
| `/api/v1/offers` | Employee JWT + tenant + subscription | Promotional offers | Company marketing and promo codes |
|
||||
| `/api/v1/analytics` | Employee JWT + tenant + subscription | Dashboard metrics and reports | Includes summary, dashboard, source, and report routes |
|
||||
| `/api/v1/notifications` | Employee or renter JWT depending on route | Notification inbox and preferences | Separate company and renter surfaces |
|
||||
| `/api/v1/companies` | Employee JWT + tenant + subscription | Company profile and settings | Brand, domains, contract settings, insurance policies, pricing rules, accounting, API key |
|
||||
| `/api/v1/payments` | Mixed | Rental payment webhooks and company payment actions | Webhooks are public; operational routes are company-authenticated |
|
||||
| `/api/v1/reviews` | Employee JWT + tenant + subscription | Review moderation | Company-side review visibility and replies |
|
||||
| `/api/v1/complaints` | Employee JWT + tenant + subscription | Complaint handling | Internal complaint tracking |
|
||||
| `/api/v1/webhooks` | Public | External webhook receivers | Currently includes `/clerk` placeholder |
|
||||
|
||||
## Functional Breakdown by Module
|
||||
|
||||
### Company and employee onboarding
|
||||
|
||||
- `POST /api/v1/auth/company/signup` creates the company tenant, owner employee, default subscription state, and related baseline records in one transaction.
|
||||
- The signup flow generates a unique company slug, hashes the owner password, starts a 90-day trial window, and sends account-created notifications.
|
||||
- Employee auth supports login, forgot-password, reset-password, profile read, and language updates.
|
||||
|
||||
### Company profile and business configuration
|
||||
|
||||
The `companies` module owns tenant-level settings:
|
||||
|
||||
- company identity
|
||||
- brand settings and asset upload
|
||||
- subdomain/custom-domain configuration
|
||||
- contract settings used by rental documents
|
||||
- insurance policies
|
||||
- pricing rules
|
||||
- accounting settings
|
||||
- public API key generation/regeneration
|
||||
|
||||
This module is where most long-lived company configuration lives.
|
||||
|
||||
### Fleet management
|
||||
|
||||
The `vehicles` module handles:
|
||||
|
||||
- CRUD for vehicles
|
||||
- publish/unpublish
|
||||
- explicit status updates
|
||||
- photo upload and deletion
|
||||
- availability checks
|
||||
- monthly vehicle calendar events
|
||||
- manual calendar blocks
|
||||
- maintenance logs
|
||||
|
||||
Uploads are persisted through the storage service, then surfaced under `/storage/*`.
|
||||
|
||||
### Reservations and rental workflow
|
||||
|
||||
The reservation aggregate is the center of the operational API.
|
||||
|
||||
Important transitions:
|
||||
|
||||
1. create draft reservation
|
||||
2. confirm reservation after license checks
|
||||
3. check in and move the reservation to `ACTIVE`
|
||||
4. check out and mark the reservation `COMPLETED`
|
||||
5. close the reservation operationally after post-rental tasks are done
|
||||
|
||||
The lifecycle service also handles:
|
||||
|
||||
- extensions with conflict checks
|
||||
- cancellations
|
||||
- vehicle status synchronization
|
||||
- review token generation
|
||||
- review request email dispatch on close
|
||||
|
||||
The reservations module also owns:
|
||||
|
||||
- contract and billing payload generation
|
||||
- pickup/dropoff inspections
|
||||
- additional-driver approval
|
||||
- pickup/dropoff photo upload
|
||||
|
||||
### Customer CRM and license compliance
|
||||
|
||||
Customers are company-scoped CRM records, even when a renter identity also exists. The `customers` module supports:
|
||||
|
||||
- customer CRUD
|
||||
- flag/unflag flows
|
||||
- license image upload and protected retrieval
|
||||
- license validation and manager approval
|
||||
|
||||
This is intentionally separate from renter identity because one renter may interact with multiple companies while each company still needs its own operational customer record.
|
||||
|
||||
### Marketplace and public site
|
||||
|
||||
The public surface is split in two modules on purpose.
|
||||
|
||||
`marketplace` is the cross-company discovery layer:
|
||||
|
||||
- featured/public offers
|
||||
- marketplace cities
|
||||
- listed companies
|
||||
- vehicle search
|
||||
- marketplace reservation intake
|
||||
- review submission by token
|
||||
- company public pages under `/:slug`
|
||||
|
||||
`site` is the white-label company booking API:
|
||||
|
||||
- company brand payload
|
||||
- published vehicle catalog
|
||||
- booking options
|
||||
- date-range availability
|
||||
- promo validation
|
||||
- booking creation
|
||||
- payment initialization
|
||||
- booking lookup
|
||||
- company contact form
|
||||
|
||||
Both modules deliberately include graceful degradation paths when the database is unavailable so public pages can fail softer than internal operations.
|
||||
|
||||
### Payments
|
||||
|
||||
Rental payments are company-side operational payments, distinct from SaaS subscription billing.
|
||||
|
||||
The `payments` module handles:
|
||||
|
||||
- AmanPay and PayPal rental webhooks
|
||||
- listing payments by company or reservation
|
||||
- initializing online charges
|
||||
- capturing PayPal orders
|
||||
- recording manual payments
|
||||
- refunding successful online payments
|
||||
|
||||
Payment success updates both the payment record and the reservation paid amount.
|
||||
|
||||
### Subscriptions and SaaS billing
|
||||
|
||||
The `subscriptions` module owns the platform billing lifecycle:
|
||||
|
||||
- public plan/provider/feature listing
|
||||
- company subscription read endpoints
|
||||
- trial start
|
||||
- checkout
|
||||
- plan changes
|
||||
- cancellation/resume/reactivation
|
||||
- provider webhooks
|
||||
- admin overrides
|
||||
|
||||
It also exposes background-job style functions for:
|
||||
|
||||
- trial expiration
|
||||
- payment-pending timeout
|
||||
- past-due timeout
|
||||
- suspension timeout
|
||||
- period-end cancellation
|
||||
|
||||
### Notifications
|
||||
|
||||
Notifications are multi-channel and multi-audience:
|
||||
|
||||
- company inbox
|
||||
- renter inbox
|
||||
- unread counts
|
||||
- preferences per type and channel
|
||||
- notification history
|
||||
|
||||
Templates and preference records live in the database; delivery orchestration lives in `services/notificationService.ts`.
|
||||
|
||||
### Admin surface
|
||||
|
||||
The admin router is broad because it covers platform operations across multiple domains:
|
||||
|
||||
- admin authentication and 2FA
|
||||
- company listing, inspection, status changes, deletion, impersonation
|
||||
- renter blocking/unblocking
|
||||
- platform metrics
|
||||
- notification inspection
|
||||
- audit log access
|
||||
- admin-user and permission management
|
||||
- billing account and billing invoice operations
|
||||
- pricing config, plan features, and promotions
|
||||
- subscription overrides and extensions
|
||||
- marketplace homepage configuration
|
||||
|
||||
This is the only route group allowed to work across tenants.
|
||||
|
||||
## Important Workflow Examples
|
||||
|
||||
### 1. Company signup
|
||||
|
||||
`POST /api/v1/auth/company/signup`
|
||||
|
||||
- validates the payload with Zod
|
||||
- checks for duplicate company and owner email addresses
|
||||
- generates a unique slug
|
||||
- hashes the owner password
|
||||
- creates the tenant, owner employee, and initial subscription state in one transaction
|
||||
- sends localized account-created notifications
|
||||
|
||||
### 2. Public booking from a company site
|
||||
|
||||
`POST /api/v1/site/:slug/book`
|
||||
|
||||
- resolves the company from the slug
|
||||
- verifies vehicle availability for the requested date range
|
||||
- upserts the company-scoped customer record
|
||||
- computes total days and base rental price
|
||||
- applies promo code discount if present
|
||||
- applies pricing rules
|
||||
- stores a draft reservation
|
||||
- attaches reservation insurance snapshots and additional-driver snapshots
|
||||
- triggers license validation flags
|
||||
|
||||
Payment is then started with `POST /api/v1/site/:slug/booking/:id/pay`.
|
||||
|
||||
### 3. Reservation lifecycle after booking
|
||||
|
||||
- reservation starts in `DRAFT`
|
||||
- staff confirms it after license compliance checks
|
||||
- vehicle status moves to `RESERVED`
|
||||
- checkin moves reservation to `ACTIVE` and vehicle to `RENTED`
|
||||
- checkout moves reservation to `COMPLETED`, records mileage, and generates a review token
|
||||
- close marks the operational workflow complete and can send the review request email
|
||||
|
||||
## Error Handling and Response Shape
|
||||
|
||||
The API standardizes error handling in `http/errors/errorMiddleware.ts`.
|
||||
|
||||
Special handling exists for:
|
||||
|
||||
- Zod validation errors -> `400 validation_error`
|
||||
- Prisma `P2025` -> `404 not_found`
|
||||
- Prisma `P2002` -> `409 conflict`
|
||||
- custom `AppError` subclasses -> explicit status and machine-readable code
|
||||
|
||||
Successful route handlers usually return:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
Common deviations:
|
||||
|
||||
- `/health`
|
||||
- `/api/v1/docs`
|
||||
- webhook receipt payloads
|
||||
- file downloads such as admin invoice PDFs
|
||||
|
||||
## Documentation Artifacts
|
||||
|
||||
There are two documentation layers in the codebase:
|
||||
|
||||
1. runtime API docs from Swagger/OpenAPI at `/docs` and `/api/v1/openapi.json`
|
||||
2. this markdown document, which explains design decisions, route grouping, and request flow
|
||||
|
||||
The OpenAPI file is useful for request/response contracts, but it is not yet a perfect mirror of every newer route. This document should be updated together with `app.ts`, route files, and the OpenAPI generator whenever route groups change.
|
||||
@@ -0,0 +1,62 @@
|
||||
The seed script skips creation if the email already exists. The
|
||||
most flexible approach is a direct API call (if you have a token)
|
||||
or a one-liner script. Here are both options:
|
||||
|
||||
Option 1 — via API (requires an existing admin to be logged in
|
||||
first):
|
||||
|
||||
curl -s -X POST http://localhost:4000/api/v1/admin/admins \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <your_admin_token>" \
|
||||
-d '{
|
||||
"email": "newadmin@example.com",
|
||||
"firstName": "First",
|
||||
"lastName": "Last",
|
||||
"role": "ADMIN",
|
||||
"password": "yourpassword123"
|
||||
}'
|
||||
|
||||
Option 2 — directly in the database (no token needed, works any
|
||||
time):
|
||||
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/renta
|
||||
ldrivego" node -e "
|
||||
const { PrismaClient } =
|
||||
require('./packages/database/generated');
|
||||
const bcrypt = require('./node_modules/bcryptjs');
|
||||
const p = new PrismaClient();
|
||||
const EMAIL = 'newadmin@example.com';
|
||||
const PASSWORD = 'yourpassword123';
|
||||
const FIRST = 'First';
|
||||
const LAST = 'Last';
|
||||
const ROLE = 'SUPER_ADMIN'; // SUPER_ADMIN | ADMIN | SUPPORT |
|
||||
FINANCE | VIEWER
|
||||
bcrypt.hash(PASSWORD, 12).then(hash =>
|
||||
p.adminUser.create({ data: { email: EMAIL, firstName: FIRST,
|
||||
lastName: LAST, passwordHash: hash, role: ROLE, isActive: true }
|
||||
})
|
||||
).then(a => { console.log('Created:', a.email, a.role);
|
||||
p.\$disconnect(); })
|
||||
.catch(e => { console.error('Error:', e.message);
|
||||
p.\$disconnect(); });
|
||||
"
|
||||
|
||||
From inside Docker:
|
||||
|
||||
docker exec rentaldrivego-dev-api-1 node -e "
|
||||
const { PrismaClient } =
|
||||
require('/app/packages/database/generated');
|
||||
const bcrypt = require('/app/node_modules/bcryptjs');
|
||||
const p = new PrismaClient();
|
||||
bcrypt.hash('yourpassword123', 12).then(hash =>
|
||||
p.adminUser.create({ data: { email: 'newadmin@example.com',
|
||||
firstName: 'First', lastName: 'Last', passwordHash: hash, role:
|
||||
'SUPER_ADMIN', isActive: true } })
|
||||
).then(a => { console.log('Created:', a.email, a.role);
|
||||
p.\$disconnect(); })
|
||||
.catch(e => { console.error('Error:', e.message);
|
||||
p.\$disconnect(); });
|
||||
"
|
||||
|
||||
Replace email, password, firstName, lastName, and role as needed.
|
||||
Available roles: SUPER_ADMIN, ADMIN, SUPPORT, FINANCE, VIEWER.
|
||||
@@ -0,0 +1,699 @@
|
||||
# Contrat de Location de Véhicule
|
||||
|
||||
**Contrat n° :** [●]
|
||||
**Date de signature :** [●]
|
||||
**Lieu de signature :** [Ville, Maroc]
|
||||
|
||||
---
|
||||
|
||||
## 1. Parties au contrat
|
||||
|
||||
Entre les soussignés :
|
||||
|
||||
### Le Loueur / Agence
|
||||
|
||||
**Nom commercial :** [●]
|
||||
**Raison sociale :** [●]
|
||||
**Forme juridique :** [●]
|
||||
**RC n° :** [●]
|
||||
**ICE n° :** [●]
|
||||
**IF n° :** [●]
|
||||
**Agrément / Autorisation d’exercice n° :** [●]
|
||||
**Date de délivrance de l’agrément :** [●]
|
||||
**Autorité délivrante :** [●]
|
||||
**Adresse :** [●]
|
||||
**Téléphone :** [●]
|
||||
**Email :** [●]
|
||||
**Représenté par :** [Nom, prénom, fonction]
|
||||
|
||||
Ci-après désigné **« le Loueur »**,
|
||||
|
||||
Et :
|
||||
|
||||
### Le Locataire / Conducteur principal
|
||||
|
||||
**Nom :** [●]
|
||||
**Prénom :** [●]
|
||||
**Date de naissance :** [●]
|
||||
**Nationalité :** [●]
|
||||
**CIN / Passeport n° :** [●]
|
||||
**Adresse complète :** [●]
|
||||
**Téléphone :** [●]
|
||||
**Email :** [●]
|
||||
|
||||
Ci-après désigné **« le Locataire »**.
|
||||
|
||||
Le Loueur et le Locataire sont ensemble désignés **« les Parties »**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Responsable légal et conformité réglementaire du Loueur
|
||||
|
||||
Le Loueur déclare exercer l’activité de location de véhicules sans chauffeur conformément à la réglementation marocaine applicable.
|
||||
|
||||
Le Loueur reconnaît que son activité doit respecter les textes mentionnés dans la section **Références légales et réglementaires applicables** du présent document.
|
||||
|
||||
La personne responsable de l’activité est :
|
||||
|
||||
**Nom et prénom :** [●]
|
||||
**Qualité :** [Dirigeant / Actionnaire / Salarié]
|
||||
**Pièce d’identité n° :** [●]
|
||||
**Qualification / diplôme / expérience :** [●]
|
||||
**Téléphone :** [●]
|
||||
**Email :** [●]
|
||||
|
||||
Cette personne est responsable du suivi des contrats, de l’entretien des véhicules, de leur conformité aux normes de sécurité routière et du respect des obligations administratives applicables.
|
||||
|
||||
Le Loueur déclare notamment :
|
||||
|
||||
- disposer des autorisations administratives requises pour l’exercice de l’activité ;
|
||||
- être régulièrement immatriculé auprès des administrations compétentes ;
|
||||
- disposer d’un siège social ou local professionnel déclaré ;
|
||||
- respecter les obligations sociales, fiscales et administratives applicables ;
|
||||
- exploiter une flotte conforme aux seuils et conditions réglementaires applicables ;
|
||||
- maintenir les véhicules loués en état conforme aux normes de sécurité routière.
|
||||
|
||||
---
|
||||
|
||||
## 3. Permis de conduire
|
||||
|
||||
Le Locataire déclare être titulaire d’un permis de conduire valide :
|
||||
|
||||
**Numéro du permis :** [●]
|
||||
**Catégorie :** [B / autre]
|
||||
**Date de délivrance :** [●]
|
||||
**Date d’expiration :** [●]
|
||||
**Pays de délivrance :** [●]
|
||||
**Permis international, le cas échéant :** [Oui / Non, n° ●]
|
||||
|
||||
Le Locataire garantit que son permis est valide pendant toute la durée de location et qu’il n’est frappé d’aucune suspension, annulation ou restriction incompatible avec la conduite du véhicule loué.
|
||||
|
||||
---
|
||||
|
||||
## 4. Conducteur(s) autorisé(s)
|
||||
|
||||
Le véhicule ne peut être conduit que par le Locataire et, le cas échéant, par les conducteurs additionnels expressément mentionnés ci-dessous :
|
||||
|
||||
### Conducteur additionnel 1
|
||||
|
||||
**Nom et prénom :** [●]
|
||||
**CIN / Passeport :** [●]
|
||||
**Permis n° :** [●]
|
||||
**Catégorie :** [●]
|
||||
**Date de délivrance :** [●]
|
||||
**Téléphone :** [●]
|
||||
|
||||
Tout conducteur non déclaré est interdit. En cas d’accident, vol, infraction ou dommage causé par un conducteur non autorisé, le Locataire assume l’entière responsabilité des conséquences financières, civiles, pénales et assurantielles.
|
||||
|
||||
---
|
||||
|
||||
## 5. Véhicule loué
|
||||
|
||||
Le Loueur met à disposition du Locataire le véhicule suivant :
|
||||
|
||||
**Marque :** [●]
|
||||
**Modèle :** [●]
|
||||
**Année :** [●]
|
||||
**Couleur :** [●]
|
||||
**Immatriculation :** [●]
|
||||
**Numéro de châssis :** [●]
|
||||
**Propriétaire du véhicule :** [Loueur / Autre agence / Société de financement / Autre]
|
||||
**Justificatif d’exploitation si le véhicule appartient à un tiers :** [Contrat / Autorisation / Mandat / Autre]
|
||||
**Type de motorisation :** [Thermique / Hybride / Électrique]
|
||||
**Date de première mise en circulation :** [●]
|
||||
**Âge du véhicule à la date de location :** [●]
|
||||
**Véhicule conforme aux limites réglementaires d’exploitation :** [Oui / Non]
|
||||
**Kilométrage au départ :** [●] km
|
||||
**Niveau de carburant au départ :** [●]
|
||||
**Carte grise :** [Oui / Non]
|
||||
**Assurance :** [Oui / Non]
|
||||
**Visite technique :** [Oui / Non / Non applicable]
|
||||
|
||||
Un état des lieux contradictoire au départ et au retour est annexé au présent contrat et en fait partie intégrante.
|
||||
|
||||
Le Loueur déclare que le véhicule mis à disposition est conforme aux conditions réglementaires d’exploitation applicables à sa catégorie, notamment en matière d’âge, d’immatriculation, d’assurance, d’entretien et de sécurité routière.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 6. Entretien et conformité du véhicule
|
||||
|
||||
Le Loueur déclare que le véhicule est régulièrement entretenu, assuré, immatriculé et conforme aux normes de sécurité routière applicables.
|
||||
|
||||
Le Locataire reconnaît avoir reçu un véhicule en état apparent de fonctionnement, sous réserve des observations mentionnées dans l’état des lieux de départ.
|
||||
|
||||
En cas d’anomalie mécanique, voyant d’alerte, bruit anormal, crevaison, panne ou incident susceptible d’affecter la sécurité, le Locataire doit cesser l’utilisation du véhicule dès que les conditions de sécurité le permettent et informer immédiatement le Loueur.
|
||||
|
||||
---
|
||||
|
||||
## 7. Durée de location
|
||||
|
||||
La location commence le :
|
||||
|
||||
**Date :** [●]
|
||||
**Heure :** [●]
|
||||
**Lieu de prise en charge :** [●]
|
||||
|
||||
La location prend fin le :
|
||||
|
||||
**Date :** [●]
|
||||
**Heure :** [●]
|
||||
**Lieu de restitution :** [●]
|
||||
|
||||
Toute prolongation doit être acceptée préalablement par écrit par le Loueur. À défaut, le véhicule sera considéré comme conservé sans autorisation, et le Loueur pourra facturer les jours supplémentaires, pénalités de retard et frais éventuels.
|
||||
|
||||
---
|
||||
|
||||
## 8. Prix de location et modalités de paiement
|
||||
|
||||
Le prix de location est fixé comme suit :
|
||||
|
||||
| Désignation | Montant |
|
||||
|---|---:|
|
||||
| Tarif journalier HT | [●] MAD |
|
||||
| Nombre de jours | [●] |
|
||||
| Sous-total HT | [●] MAD |
|
||||
| TVA applicable | [●] % |
|
||||
| Montant TVA | [●] MAD |
|
||||
| Total TTC | [●] MAD |
|
||||
| Conducteur additionnel | [●] MAD |
|
||||
| Livraison / récupération | [●] MAD |
|
||||
| Siège enfant | [●] MAD |
|
||||
| GPS / accessoires | [●] MAD |
|
||||
| Autres frais | [●] MAD |
|
||||
|
||||
**Montant total à payer TTC : [●] MAD**
|
||||
|
||||
**Mode de paiement :** [Espèces / carte bancaire / virement / chèque / autre]
|
||||
**Date de paiement :** [●]
|
||||
**Référence de paiement :** [●]
|
||||
|
||||
Le Locataire reconnaît avoir reçu une information claire sur le prix total TTC avant la signature du contrat.
|
||||
|
||||
---
|
||||
|
||||
## 9. Dépôt de garantie / caution
|
||||
|
||||
Le Locataire verse au Loueur un dépôt de garantie de :
|
||||
|
||||
**Montant : [●] MAD**
|
||||
|
||||
**Forme de la caution :** [Préautorisation bancaire / espèces / chèque / autre]
|
||||
**Date de constitution :** [●]
|
||||
**Conditions de libération :** [●]
|
||||
|
||||
Le dépôt de garantie garantit notamment :
|
||||
|
||||
- les dommages non couverts par l’assurance ;
|
||||
- la franchise d’assurance ;
|
||||
- les kilomètres supplémentaires ;
|
||||
- le carburant manquant ;
|
||||
- les frais de nettoyage exceptionnel ;
|
||||
- les contraventions, amendes, péages ou frais administratifs ;
|
||||
- les retards de restitution ;
|
||||
- la perte de documents, clés, accessoires ou équipements.
|
||||
|
||||
La caution sera restituée après vérification de l’état du véhicule, du kilométrage, du carburant, des éventuelles infractions et des sommes restant dues.
|
||||
|
||||
---
|
||||
|
||||
## 10. Assurance
|
||||
|
||||
Le véhicule est assuré auprès de :
|
||||
|
||||
**Compagnie d’assurance :** [●]
|
||||
**Numéro de police :** [●]
|
||||
**Type de couverture :** [Responsabilité civile / Tous risques / autre]
|
||||
**Franchise applicable :** [●] MAD
|
||||
**Assistance :** [Oui / Non]
|
||||
**Numéro d’assistance :** [●]
|
||||
|
||||
Le Locataire reconnaît avoir été informé des garanties, exclusions et franchises applicables.
|
||||
|
||||
Sont notamment exclus de la couverture, sauf stipulation contraire de l’assureur :
|
||||
|
||||
- conduite par une personne non autorisée ;
|
||||
- conduite sans permis valide ;
|
||||
- conduite sous l’emprise d’alcool, stupéfiants ou substances interdites ;
|
||||
- usage du véhicule hors des voies autorisées ;
|
||||
- participation à des courses, essais ou compétitions ;
|
||||
- négligence grave ;
|
||||
- fausse déclaration ;
|
||||
- absence de déclaration d’accident dans les délais ;
|
||||
- vol avec clés laissées dans le véhicule ;
|
||||
- transport illicite de personnes ou de marchandises.
|
||||
|
||||
---
|
||||
|
||||
## 11. Kilométrage
|
||||
|
||||
**Kilométrage inclus :** [●] km par jour / [●] km pour toute la durée de location.
|
||||
**Kilométrage au départ :** [●] km.
|
||||
**Kilométrage au retour :** [●] km.
|
||||
|
||||
Tout kilomètre supplémentaire sera facturé au tarif de :
|
||||
|
||||
**[●] MAD TTC par kilomètre supplémentaire.**
|
||||
|
||||
Le kilométrage fait foi sur la base du compteur du véhicule, constaté dans l’état des lieux de départ et de retour.
|
||||
|
||||
---
|
||||
|
||||
## 12. Utilisation du véhicule
|
||||
|
||||
Le Locataire s’engage à utiliser le véhicule avec prudence, conformément à sa destination normale et à la législation marocaine.
|
||||
|
||||
Il est interdit notamment :
|
||||
|
||||
- de sous-louer le véhicule ;
|
||||
- de transporter des marchandises dangereuses ou illicites ;
|
||||
- d’utiliser le véhicule pour des activités illégales ;
|
||||
- de participer à des compétitions ou essais sportifs ;
|
||||
- de tracter un autre véhicule sans autorisation écrite ;
|
||||
- de circuler hors du territoire marocain sans autorisation écrite du Loueur ;
|
||||
- de modifier le véhicule ou ses équipements ;
|
||||
- de fumer dans le véhicule si l’agence l’interdit ;
|
||||
- de transporter un nombre de passagers supérieur à celui autorisé.
|
||||
|
||||
Le Locataire est responsable des infractions au Code de la route commises pendant la période de location.
|
||||
|
||||
---
|
||||
|
||||
## 13. Restitution du véhicule
|
||||
|
||||
Le Locataire doit restituer le véhicule à la date, à l’heure et au lieu prévus au contrat, dans l’état où il lui a été remis, sous réserve de l’usure normale.
|
||||
|
||||
Le véhicule doit être restitué avec :
|
||||
|
||||
- le même niveau de carburant qu’au départ ;
|
||||
- les papiers du véhicule ;
|
||||
- les clés ;
|
||||
- les accessoires et équipements remis ;
|
||||
- un état de propreté normal ;
|
||||
- aucun dommage nouveau non déclaré.
|
||||
|
||||
En cas de retard, le Loueur pourra facturer :
|
||||
|
||||
- [●] MAD par heure de retard ; ou
|
||||
- une journée supplémentaire au tarif contractuel au-delà de [●] heures de retard.
|
||||
|
||||
En cas de carburant manquant, les frais seront facturés selon le coût réel augmenté de frais de service de [●] MAD.
|
||||
|
||||
En cas de salissure excessive, le Loueur pourra facturer des frais de nettoyage de [●] MAD à [●] MAD selon l’état du véhicule.
|
||||
|
||||
---
|
||||
|
||||
## 14. État des lieux départ et retour
|
||||
|
||||
Les Parties établissent un état des lieux au départ et au retour. Celui-ci peut être accompagné de photographies datées du véhicule, du compteur, du carburant, de la carrosserie, de l’intérieur, des pneus, des vitres et des équipements.
|
||||
|
||||
Les éléments à contrôler comprennent notamment :
|
||||
|
||||
- Kilométrage compteur ;
|
||||
- Niveau de carburant ;
|
||||
- Carrosserie ;
|
||||
- Pare-chocs ;
|
||||
- Rétroviseurs ;
|
||||
- Phares et feux ;
|
||||
- Pare-brise et vitres ;
|
||||
- Pneus et jantes ;
|
||||
- Intérieur et sièges ;
|
||||
- Tableau de bord ;
|
||||
- Roue de secours ;
|
||||
- Outillage ;
|
||||
- Documents du véhicule ;
|
||||
- Clés et accessoires.
|
||||
|
||||
Tout dommage constaté au retour et non mentionné dans l’état des lieux de départ pourra être facturé au Locataire, sous réserve des garanties d’assurance applicables.
|
||||
|
||||
---
|
||||
|
||||
## 15. Accident, panne, vol ou sinistre
|
||||
|
||||
En cas d’accident, panne, vol, tentative de vol, incendie ou tout autre sinistre, le Locataire doit immédiatement :
|
||||
|
||||
- informer le Loueur par téléphone et par écrit ;
|
||||
- prévenir les autorités compétentes si nécessaire ;
|
||||
- établir un constat amiable en cas d’accident ;
|
||||
- obtenir un procès-verbal ou document officiel en cas de vol, blessure, délit ou dommage important ;
|
||||
- ne pas abandonner le véhicule sans autorisation du Loueur ;
|
||||
- ne pas reconnaître de responsabilité sans accord préalable du Loueur ou de l’assureur.
|
||||
|
||||
Le Locataire doit transmettre au Loueur tous les documents utiles dans un délai maximum de **48 heures** à compter du sinistre.
|
||||
|
||||
En cas de non-respect de cette procédure, le Locataire pourra être tenu responsable des conséquences financières, notamment si l’assureur refuse sa garantie.
|
||||
|
||||
---
|
||||
|
||||
## 16. Responsabilité du Locataire
|
||||
|
||||
Le Locataire est responsable :
|
||||
|
||||
- des dommages causés au véhicule pendant la période de location ;
|
||||
- des dommages causés aux tiers dans les limites prévues par la loi et l’assurance ;
|
||||
- des amendes, contraventions, frais de fourrière, péages et frais administratifs ;
|
||||
- des frais liés à une mauvaise utilisation du véhicule ;
|
||||
- des pertes de clés, documents ou accessoires ;
|
||||
- des dommages non couverts par l’assurance ;
|
||||
- de la franchise prévue au contrat d’assurance.
|
||||
|
||||
Le Locataire reste responsable jusqu’à la restitution effective du véhicule au Loueur et la signature de l’état des lieux de retour.
|
||||
|
||||
---
|
||||
|
||||
## 17. Infractions, amendes et frais administratifs
|
||||
|
||||
Toutes les infractions commises pendant la durée de location sont à la charge du Locataire.
|
||||
|
||||
Le Loueur pourra communiquer l’identité du Locataire aux autorités compétentes et facturer au Locataire :
|
||||
|
||||
- le montant des amendes ;
|
||||
- les frais de dossier ;
|
||||
- les frais de fourrière ;
|
||||
- les frais de notification ;
|
||||
- tout coût lié au traitement administratif de l’infraction.
|
||||
|
||||
**Frais administratifs par infraction : [●] MAD TTC.**
|
||||
|
||||
---
|
||||
|
||||
## 18. Annulation, non-présentation et résiliation
|
||||
|
||||
En cas d’annulation par le Locataire :
|
||||
|
||||
- plus de [●] heures avant le départ : [conditions de remboursement] ;
|
||||
- moins de [●] heures avant le départ : [conditions] ;
|
||||
- non-présentation : [conditions].
|
||||
|
||||
Le Loueur peut résilier immédiatement le contrat, sans indemnité pour le Locataire, en cas de :
|
||||
|
||||
- fausse déclaration ;
|
||||
- permis invalide ;
|
||||
- défaut de paiement ;
|
||||
- utilisation interdite du véhicule ;
|
||||
- conduite dangereuse ;
|
||||
- non-respect grave du présent contrat ;
|
||||
- suspicion légitime de fraude ou d’usage illicite.
|
||||
|
||||
Dans ce cas, le Locataire doit restituer immédiatement le véhicule.
|
||||
|
||||
---
|
||||
|
||||
## 19. Indisponibilité administrative, réglementaire ou technique
|
||||
|
||||
En cas d’indisponibilité du véhicule ou d’impossibilité d’exécuter la location pour une raison administrative, réglementaire, technique ou de sécurité, le Loueur pourra proposer au Locataire un véhicule équivalent, sous réserve de disponibilité.
|
||||
|
||||
À défaut de véhicule équivalent accepté par le Locataire, les sommes payées au titre de la période non exécutée seront remboursées, sans préjudice des droits légalement reconnus aux Parties.
|
||||
|
||||
---
|
||||
|
||||
## 20. Données personnelles
|
||||
|
||||
Le Locataire autorise le Loueur à collecter et conserver les données nécessaires à l’exécution du présent contrat, notamment son identité, ses coordonnées, son permis de conduire, les informations de paiement, les documents liés au véhicule, les états des lieux et les données relatives aux éventuels incidents.
|
||||
|
||||
Ces données sont utilisées pour :
|
||||
|
||||
- l’exécution du contrat ;
|
||||
- la facturation ;
|
||||
- la gestion des sinistres ;
|
||||
- la gestion des infractions ;
|
||||
- les obligations comptables, fiscales et légales ;
|
||||
- la défense des droits du Loueur en cas de litige.
|
||||
|
||||
Le Loueur s’engage à protéger les données personnelles du Locataire, à limiter leur accès aux personnes habilitées et à les conserver uniquement pendant la durée nécessaire aux finalités prévues et aux obligations légales applicables.
|
||||
|
||||
---
|
||||
|
||||
## 21. Documents annexés
|
||||
|
||||
Les documents suivants sont annexés au présent contrat :
|
||||
|
||||
- Copie CIN ou passeport du Locataire ;
|
||||
- Copie du permis de conduire ;
|
||||
- Copie permis international, le cas échéant ;
|
||||
- État des lieux de départ ;
|
||||
- État des lieux de retour ;
|
||||
- Photos du véhicule au départ ;
|
||||
- Photos du véhicule au retour ;
|
||||
- Copie de la carte grise ;
|
||||
- Copie de l’attestation d’assurance ;
|
||||
- Justificatif autorisant l’exploitation du véhicule par le Loueur, si le véhicule n’appartient pas directement au Loueur ;
|
||||
- Reçu de paiement ;
|
||||
- Justificatif de dépôt de garantie ;
|
||||
- Conditions générales de location, le cas échéant.
|
||||
- Liste des références légales et réglementaires applicables ;
|
||||
|
||||
Les annexes font partie intégrante du présent contrat.
|
||||
|
||||
---
|
||||
|
||||
## 22. Loi applicable et juridiction compétente
|
||||
|
||||
Le présent contrat est soumis au droit marocain.
|
||||
|
||||
En cas de litige relatif à sa validité, son interprétation, son exécution ou sa résiliation, les Parties s’efforceront de trouver une solution amiable.
|
||||
|
||||
À défaut d’accord amiable, le litige sera porté devant le tribunal compétent du ressort de :
|
||||
|
||||
**[Ville du siège de l’agence / tribunal compétent]**
|
||||
|
||||
---
|
||||
|
||||
## 23. Déclaration finale
|
||||
|
||||
Le Locataire déclare :
|
||||
|
||||
- avoir lu et compris le présent contrat ;
|
||||
- avoir reçu toutes les informations utiles avant signature ;
|
||||
- avoir vérifié l’état du véhicule au départ ;
|
||||
- être titulaire d’un permis valide ;
|
||||
- s’engager à respecter les conditions du présent contrat ;
|
||||
- accepter les prix, caution, franchises, pénalités et frais indiqués.
|
||||
|
||||
**Fait à :** [●]
|
||||
**Le :** [●]
|
||||
**En deux exemplaires originaux.**
|
||||
|
||||
Chaque page du présent contrat doit être paraphée par les deux Parties.
|
||||
|
||||
### Signature du Loueur
|
||||
|
||||
**Nom :** [●]
|
||||
**Signature et cachet :**
|
||||
|
||||
<br><br>
|
||||
|
||||
### Signature du Locataire
|
||||
|
||||
**Nom :** [●]
|
||||
**Signature :**
|
||||
|
||||
<br><br>
|
||||
|
||||
---
|
||||
|
||||
# Annexe 1 — État des lieux de départ
|
||||
|
||||
**Contrat n° :** [●]
|
||||
**Date :** [●]
|
||||
**Heure :** [●]
|
||||
**Lieu :** [●]
|
||||
|
||||
**Véhicule :** [Marque / Modèle]
|
||||
**Immatriculation :** [●]
|
||||
**Kilométrage départ :** [●] km
|
||||
**Carburant départ :** [●]
|
||||
|
||||
| Élément | État au départ | Observations |
|
||||
|---|---|---|
|
||||
| Carrosserie avant | Bon / Rayé / Endommagé | [●] |
|
||||
| Carrosserie arrière | Bon / Rayé / Endommagé | [●] |
|
||||
| Côté droit | Bon / Rayé / Endommagé | [●] |
|
||||
| Côté gauche | Bon / Rayé / Endommagé | [●] |
|
||||
| Pare-brise | Bon / Impact / Fissure | [●] |
|
||||
| Vitres | Bon / Endommagé | [●] |
|
||||
| Pneus | Bon / Usé / Endommagé | [●] |
|
||||
| Jantes | Bon / Rayé / Endommagé | [●] |
|
||||
| Intérieur | Bon / Taché / Endommagé | [●] |
|
||||
| Sièges | Bon / Taché / Déchiré | [●] |
|
||||
| Tableau de bord | Bon / Endommagé | [●] |
|
||||
| Climatisation | Fonctionne / Ne fonctionne pas | [●] |
|
||||
| Feux | Fonctionnent / Défaut | [●] |
|
||||
| Roue de secours | Présente / Absente | [●] |
|
||||
| Outillage | Présent / Absent | [●] |
|
||||
| Carte grise | Présente / Absente | [●] |
|
||||
| Assurance | Présente / Absente | [●] |
|
||||
| Clés | [Nombre] | [●] |
|
||||
|
||||
**Photos prises au départ :** Oui / Non
|
||||
**Nombre de photos :** [●]
|
||||
|
||||
**Signature du Loueur :**
|
||||
|
||||
<br><br>
|
||||
|
||||
**Signature du Locataire :**
|
||||
|
||||
<br><br>
|
||||
|
||||
---
|
||||
|
||||
# Annexe 2 — État des lieux de retour
|
||||
|
||||
**Contrat n° :** [●]
|
||||
**Date :** [●]
|
||||
**Heure :** [●]
|
||||
**Lieu :** [●]
|
||||
|
||||
**Kilométrage retour :** [●] km
|
||||
**Kilométrage parcouru :** [●] km
|
||||
**Kilométrage inclus :** [●] km
|
||||
**Kilométrage supplémentaire :** [●] km
|
||||
**Montant km supplémentaires :** [●] MAD
|
||||
|
||||
**Carburant retour :** [●]
|
||||
**Carburant manquant :** Oui / Non
|
||||
**Montant carburant :** [●] MAD
|
||||
|
||||
| Élément | État au retour | Nouveau dommage ? | Observations |
|
||||
|---|---|---|---|
|
||||
| Carrosserie avant | Bon / Rayé / Endommagé | Oui / Non | [●] |
|
||||
| Carrosserie arrière | Bon / Rayé / Endommagé | Oui / Non | [●] |
|
||||
| Côté droit | Bon / Rayé / Endommagé | Oui / Non | [●] |
|
||||
| Côté gauche | Bon / Rayé / Endommagé | Oui / Non | [●] |
|
||||
| Pare-brise | Bon / Impact / Fissure | Oui / Non | [●] |
|
||||
| Vitres | Bon / Endommagé | Oui / Non | [●] |
|
||||
| Pneus | Bon / Usé / Endommagé | Oui / Non | [●] |
|
||||
| Jantes | Bon / Rayé / Endommagé | Oui / Non | [●] |
|
||||
| Intérieur | Bon / Taché / Endommagé | Oui / Non | [●] |
|
||||
| Sièges | Bon / Taché / Déchiré | Oui / Non | [●] |
|
||||
| Documents | Complets / Manquants | Oui / Non | [●] |
|
||||
| Clés | Restituées / Manquantes | Oui / Non | [●] |
|
||||
|
||||
## Frais constatés au retour
|
||||
|
||||
| Frais | Montant |
|
||||
|---|---:|
|
||||
| Dommages | [●] MAD |
|
||||
| Franchise assurance | [●] MAD |
|
||||
| Kilométrage supplémentaire | [●] MAD |
|
||||
| Carburant | [●] MAD |
|
||||
| Nettoyage | [●] MAD |
|
||||
| Retard | [●] MAD |
|
||||
| Autres frais | [●] MAD |
|
||||
|
||||
**Total à retenir sur la caution : [●] MAD**
|
||||
**Solde de caution à restituer : [●] MAD**
|
||||
|
||||
**Photos prises au retour :** Oui / Non
|
||||
**Nombre de photos :** [●]
|
||||
|
||||
**Signature du Loueur :**
|
||||
|
||||
<br><br>
|
||||
|
||||
**Signature du Locataire :**
|
||||
|
||||
<br><br>
|
||||
|
||||
---
|
||||
|
||||
# Checklist pratique avant signature
|
||||
|
||||
- Ne laisser aucun champ `[●]` vide dans la version signée.
|
||||
- Faire parapher chaque page par les deux Parties.
|
||||
- Joindre la copie CIN ou passeport du Locataire.
|
||||
- Joindre la copie du permis de conduire.
|
||||
- Photographier le compteur et le niveau de carburant au départ et au retour.
|
||||
- Photographier toutes les faces du véhicule au départ et au retour.
|
||||
- Écrire clairement le montant de la caution.
|
||||
- Mentionner la compagnie d’assurance, le numéro de police et la franchise.
|
||||
- Conserver une preuve de paiement.
|
||||
- Conserver les états des lieux signés.
|
||||
|
||||
---
|
||||
|
||||
# Note importante
|
||||
|
||||
Ce modèle est un document pratique destiné à structurer une location de voiture au Maroc. Il doit être adapté à l’activité réelle du Loueur, à ses conditions d’assurance, à son statut fiscal et aux règles applicables. Pour un usage commercial régulier, une validation par un juriste ou avocat marocain est recommandée.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Références légales et réglementaires applicables
|
||||
|
||||
Les références suivantes sont indiquées à titre de base juridique générale pour l’activité de location de véhicules sans chauffeur au Maroc. Elles doivent être vérifiées et actualisées selon les textes officiels en vigueur, notamment le Bulletin Officiel et les circulaires ou cahiers des charges applicables.
|
||||
|
||||
## Textes principaux relatifs à la location de véhicules
|
||||
|
||||
| Référence | Objet | Utilité pour le présent contrat |
|
||||
|---|---|---|
|
||||
| **Dahir n° 1-63-260 du 12 novembre 1963** | Transports par véhicules automobiles sur route | Cadre général applicable au transport routier et aux activités connexes. |
|
||||
| **Décret n° 2.69.351 du 4 avril 1970** | Conditions d’exploitation des voitures louées sans chauffeur | Texte spécifique encadrant l’exploitation des véhicules loués sans chauffeur. |
|
||||
| **Cahier des charges relatif à la location de voitures sans chauffeur, tel qu’en vigueur** | Conditions administratives, techniques et professionnelles de l’activité | Référence pratique pour l’agrément, la flotte, le responsable d’activité, l’âge des véhicules, les obligations administratives et les sanctions. |
|
||||
|
||||
## Circulation, sécurité routière et véhicules
|
||||
|
||||
| Référence | Objet | Utilité pour le présent contrat |
|
||||
|---|---|---|
|
||||
| **Loi n° 52-05 portant Code de la route** | Règles de circulation, infractions, sécurité routière, véhicules et conducteurs | Encadre l’usage du véhicule, les infractions, la responsabilité du conducteur et les obligations de sécurité. |
|
||||
| **Dahir n° 1-10-07 du 11 février 2010** | Promulgation de la Loi n° 52-05 portant Code de la route | Texte de promulgation du Code de la route. |
|
||||
|
||||
## Assurance, responsabilité et obligations contractuelles
|
||||
|
||||
| Référence | Objet | Utilité pour le présent contrat |
|
||||
|---|---|---|
|
||||
| **Loi n° 17-99 portant Code des assurances** | Assurance automobile, garanties, exclusions, franchises et responsabilité assurantielle | Encadre les assurances liées au véhicule loué, les garanties, les exclusions et les franchises. |
|
||||
| **Dahir des Obligations et des Contrats du 12 août 1913** | Règles générales des contrats, obligations, responsabilité civile, consentement et inexécution | Base générale du contrat de location et de la responsabilité des Parties. |
|
||||
|
||||
## Droit commercial et structure de l’agence
|
||||
|
||||
| Référence | Objet | Utilité pour le présent contrat |
|
||||
|---|---|---|
|
||||
| **Loi n° 15-95 formant Code de commerce** | Activité commerciale, commerçants, obligations commerciales, registre de commerce | Applicable à l’activité commerciale du Loueur. |
|
||||
| **Loi n° 5-96** | Sociétés à responsabilité limitée, sociétés en nom collectif, sociétés en commandite simple, sociétés en commandite par actions et sociétés en participation | Applicable si le Loueur est constitué sous l’une de ces formes, notamment SARL. |
|
||||
| **Loi n° 17-95 relative aux sociétés anonymes** | Sociétés anonymes | Applicable si le Loueur est constitué sous forme de société anonyme. |
|
||||
| **Loi n° 69-21 relative aux délais de paiement** | Délais de paiement entre professionnels | Applicable notamment aux locations ou relations commerciales B2B. |
|
||||
|
||||
## Protection du consommateur, données personnelles et signature électronique
|
||||
|
||||
| Référence | Objet | Utilité pour le présent contrat |
|
||||
|---|---|---|
|
||||
| **Loi n° 31-08 édictant des mesures de protection du consommateur** | Information du consommateur, transparence des prix, clauses abusives, droits du client | Applicable lorsque le Locataire agit comme consommateur. |
|
||||
| **Loi n° 09-08 relative à la protection des personnes physiques à l’égard du traitement des données à caractère personnel** | Collecte, traitement, conservation et protection des données personnelles | Applicable aux données du Locataire : CIN, passeport, permis, coordonnées, signatures, documents et photos. |
|
||||
| **Loi n° 53-05 relative à l’échange électronique de données juridiques** | Documents électroniques, signature électronique et valeur juridique des échanges numériques | Applicable si le contrat est signé, transmis ou conservé électroniquement. |
|
||||
|
||||
## Obligations sociales, fiscales et administratives
|
||||
|
||||
| Référence | Objet | Utilité pour le présent contrat |
|
||||
|---|---|---|
|
||||
| **Dahir n° 1-72-184 relatif au régime de sécurité sociale** | Régime CNSS et obligations sociales | Pertinent pour la conformité sociale du Loueur lorsqu’il emploie du personnel. |
|
||||
| **Loi n° 47-06 relative à la fiscalité des collectivités locales** | Fiscalité locale, taxe professionnelle et obligations locales | Pertinente pour les obligations fiscales locales de l’agence. |
|
||||
| **Code Général des Impôts** | TVA, impôt sur les sociétés, impôt sur le revenu, facturation et obligations fiscales | Pertinent pour la facturation, les déclarations fiscales et le traitement de la TVA. |
|
||||
|
||||
## Clause de prudence juridique
|
||||
|
||||
Le présent contrat doit être interprété et appliqué conformément aux textes marocains en vigueur à la date de signature.
|
||||
|
||||
Lorsque le présent contrat mentionne le **cahier des charges relatif à la location de voitures sans chauffeur**, cette référence vise le cahier des charges applicable tel qu’en vigueur, y compris ses modifications, circulaires d’application ou textes complémentaires.
|
||||
|
||||
En cas de contradiction entre le présent contrat et une disposition légale ou réglementaire impérative, la disposition légale ou réglementaire prévaut. Les autres clauses du contrat demeurent applicables dans la mesure permise par la loi.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Checklist interne de conformité du Loueur
|
||||
|
||||
Cette checklist est destinée au Loueur et ne remplace pas les documents administratifs obligatoires.
|
||||
|
||||
- Agrément / autorisation d’exercice disponible.
|
||||
- Responsable réglementaire désigné.
|
||||
- Casier judiciaire conforme, si exigé.
|
||||
- Société inscrite à la CNSS, si applicable.
|
||||
- Siège social ou local professionnel justifié.
|
||||
- Capital social conforme au seuil applicable.
|
||||
- Flotte conforme au seuil réglementaire applicable.
|
||||
- Âge des véhicules conforme selon motorisation.
|
||||
- Documents de propriété ou d’exploitation des véhicules disponibles.
|
||||
- Assurance et visite technique valides pour chaque véhicule.
|
||||
- Procédure de déclaration en cas de suspension ou cessation d’activité.
|
||||
@@ -0,0 +1,694 @@
|
||||
# Review Management Process After Rental Completion
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
The review management process helps the rental company collect customer feedback after each completed rental, improve service quality, recover unhappy customers, and increase positive public reviews.
|
||||
|
||||
The goal is not only to collect high ratings. The goal is to identify service problems, fix weak operations, and encourage satisfied customers to share their experience publicly.
|
||||
|
||||
---
|
||||
|
||||
## 2. When the Review Process Starts
|
||||
|
||||
The review process starts only after the rental is fully closed.
|
||||
|
||||
A rental is considered closed when:
|
||||
|
||||
- The vehicle has been returned
|
||||
- The return inspection is completed
|
||||
- The final invoice has been sent
|
||||
- The deposit has been released or adjusted
|
||||
- Any damage, billing, or dispute case is closed or clearly documented
|
||||
- The customer has received the final receipt
|
||||
|
||||
Do not send a review request before the final billing is clear. Customers should not be asked for a review while they are still waiting for a deposit update, refund, or charge explanation.
|
||||
|
||||
---
|
||||
|
||||
## 3. Main Review Process Flow
|
||||
|
||||
### Step 1: Close the Rental
|
||||
|
||||
Before sending a review request, staff must confirm:
|
||||
|
||||
- Vehicle returned
|
||||
- Final charges completed
|
||||
- Receipt sent
|
||||
- Deposit status updated
|
||||
- Any customer issue notes added
|
||||
- Vehicle status updated
|
||||
|
||||
Once complete, the booking is marked as:
|
||||
|
||||
**Rental Closed**
|
||||
|
||||
This status triggers the review process.
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Check Review Eligibility
|
||||
|
||||
Before sending a review request, the system or staff must confirm that the customer is eligible.
|
||||
|
||||
A review request should be sent only if:
|
||||
|
||||
- Rental status is **Closed**
|
||||
- Final invoice has been sent
|
||||
- No open dispute exists
|
||||
- No active complaint exists
|
||||
- No major damage case is open
|
||||
- Customer has not opted out of messages
|
||||
- Customer has a valid phone number or email address
|
||||
|
||||
A review request should be paused if:
|
||||
|
||||
- Customer has an open damage dispute
|
||||
- Customer has an unpaid balance
|
||||
- Customer has an active complaint
|
||||
- Customer was charged for major damage
|
||||
- Rental is under insurance investigation
|
||||
- Customer requested no further messages
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Send Review Request
|
||||
|
||||
Send the review request within:
|
||||
|
||||
**2 to 6 hours after rental closure**
|
||||
|
||||
This timing is soon enough that the customer remembers the experience, but not so immediate that it feels automated and careless.
|
||||
|
||||
The message should include:
|
||||
|
||||
- Customer name
|
||||
- Thank-you message
|
||||
- Short review request
|
||||
- Review link
|
||||
- Support contact in case there was a problem
|
||||
|
||||
---
|
||||
|
||||
## 4. Review Channels
|
||||
|
||||
The company should define where customer reviews are collected.
|
||||
|
||||
Recommended review channels:
|
||||
|
||||
- Google Business Profile
|
||||
- Trustpilot
|
||||
- Facebook
|
||||
- Yelp, if relevant
|
||||
- Internal company review form
|
||||
- App store review, if using a mobile app
|
||||
|
||||
Recommended simple setup:
|
||||
|
||||
- Satisfied customers are sent to a public review link
|
||||
- Unsatisfied customers are routed to internal support first
|
||||
|
||||
The company should not fake reviews, pressure customers, or hide legitimate negative feedback. The process should encourage honest reviews and give unhappy customers a clear path to resolution.
|
||||
|
||||
---
|
||||
|
||||
## 5. Customer Rating Filter
|
||||
|
||||
Before sending customers to a public review page, the company may use a short internal satisfaction question.
|
||||
|
||||
Example question:
|
||||
|
||||
**How was your rental experience?**
|
||||
|
||||
Options:
|
||||
|
||||
- Excellent
|
||||
- Good
|
||||
- Okay
|
||||
- Bad
|
||||
- Very bad
|
||||
|
||||
Routing rules:
|
||||
|
||||
| Customer Response | Action |
|
||||
|---|---|
|
||||
| Excellent | Send public review link |
|
||||
| Good | Send public review link |
|
||||
| Okay | Ask for internal feedback |
|
||||
| Bad | Create support case |
|
||||
| Very bad | Create urgent support case |
|
||||
|
||||
This helps the company recover unhappy customers before the issue becomes a public complaint.
|
||||
|
||||
---
|
||||
|
||||
## 6. Review Request Timing
|
||||
|
||||
| Time After Rental Closure | Action |
|
||||
|---|---|
|
||||
| 2 to 6 hours | Send first review request |
|
||||
| 48 hours | Send reminder if no response |
|
||||
| 7 days | Send final reminder |
|
||||
| After 7 days | Stop messaging |
|
||||
|
||||
Maximum number of review messages:
|
||||
|
||||
**3 total**
|
||||
|
||||
Do not keep messaging customers after the final reminder.
|
||||
|
||||
---
|
||||
|
||||
## 7. Review Message Rules
|
||||
|
||||
A good review request should be:
|
||||
|
||||
- Short
|
||||
- Polite
|
||||
- Personalized
|
||||
- Easy to act on
|
||||
- Sent at the right time
|
||||
- Focused on one clear link
|
||||
|
||||
Avoid review messages that are:
|
||||
|
||||
- Too long
|
||||
- Begging for 5 stars
|
||||
- Sent too many times
|
||||
- Sent before billing is closed
|
||||
- Sent during a dispute
|
||||
- Written like spam
|
||||
|
||||
Do not say:
|
||||
|
||||
> Please give us 5 stars.
|
||||
|
||||
Better wording:
|
||||
|
||||
> Your feedback helps us improve and helps other customers choose us.
|
||||
|
||||
---
|
||||
|
||||
## 8. Review Request Templates
|
||||
|
||||
### SMS Template
|
||||
|
||||
Hi {{customer_name}}, thank you for renting with {{company_name}}. We hope everything went smoothly. Please take 30 seconds to review your experience: {{review_link}}
|
||||
|
||||
If anything was not right, reply here and our team will help.
|
||||
|
||||
---
|
||||
|
||||
### Email Template
|
||||
|
||||
**Subject:** How was your rental experience?
|
||||
|
||||
Hi {{customer_name}},
|
||||
|
||||
Thank you for renting with {{company_name}}.
|
||||
|
||||
We hope your experience was smooth from pickup to return. Please take a moment to review us here:
|
||||
|
||||
{{review_link}}
|
||||
|
||||
If something was not right, reply to this email and our team will review it.
|
||||
|
||||
Thank you,
|
||||
{{company_name}}
|
||||
|
||||
---
|
||||
|
||||
### Reminder Template
|
||||
|
||||
Hi {{customer_name}}, just a quick reminder. Your feedback helps us improve our rental service. You can review your experience here: {{review_link}}
|
||||
|
||||
---
|
||||
|
||||
## 9. Handling Positive Reviews
|
||||
|
||||
When a customer leaves a positive review, staff should:
|
||||
|
||||
- Thank the customer publicly
|
||||
- Keep the reply short
|
||||
- Mention the customer’s experience if appropriate
|
||||
- Avoid copying the same reply every time
|
||||
- Tag the booking as positive feedback
|
||||
|
||||
Example public reply:
|
||||
|
||||
> Thank you for your review. We are glad your rental experience went smoothly and appreciate you choosing us.
|
||||
|
||||
If the review mentions a staff member, the manager should be notified.
|
||||
|
||||
Positive reviews should be tracked by:
|
||||
|
||||
- Branch
|
||||
- Vehicle
|
||||
- Staff member
|
||||
- Booking channel
|
||||
- Rental type
|
||||
|
||||
---
|
||||
|
||||
## 10. Handling Negative Reviews
|
||||
|
||||
Negative reviews must be handled quickly and calmly.
|
||||
|
||||
Target response time:
|
||||
|
||||
**Within 24 hours**
|
||||
|
||||
Internal steps:
|
||||
|
||||
1. Create a complaint case.
|
||||
2. Link the complaint to the rental agreement.
|
||||
3. Review pickup photos.
|
||||
4. Review return photos.
|
||||
5. Review invoice and charges.
|
||||
6. Review staff notes.
|
||||
7. Contact the customer privately.
|
||||
8. Offer a fair solution if the company made a mistake.
|
||||
9. Reply publicly with a professional response.
|
||||
10. Record the final outcome.
|
||||
|
||||
Public reply rules:
|
||||
|
||||
- Stay calm
|
||||
- Acknowledge the concern
|
||||
- Do not argue
|
||||
- Do not expose customer details
|
||||
- Invite the customer to private support
|
||||
- Show that the company takes feedback seriously
|
||||
|
||||
Example public reply:
|
||||
|
||||
> We are sorry to hear your experience did not meet expectations. We take this seriously and would like to review the rental details with you. Please contact our support team at {{contact_info}} so we can investigate and help resolve this.
|
||||
|
||||
---
|
||||
|
||||
## 11. Complaint Severity Levels
|
||||
|
||||
### Level 1: Minor Issue
|
||||
|
||||
Examples:
|
||||
|
||||
- Long wait time
|
||||
- Car not perfectly clean
|
||||
- Staff communication issue
|
||||
- Confusing instructions
|
||||
|
||||
Action:
|
||||
|
||||
- Apologize
|
||||
- Record feedback
|
||||
- Offer small goodwill if appropriate
|
||||
- Inform branch manager
|
||||
|
||||
---
|
||||
|
||||
### Level 2: Serious Issue
|
||||
|
||||
Examples:
|
||||
|
||||
- Incorrect billing
|
||||
- Deposit delay
|
||||
- Vehicle mechanical problem
|
||||
- Wrong car category
|
||||
- Poor staff behavior
|
||||
|
||||
Action:
|
||||
|
||||
- Manager review required
|
||||
- Contact customer within 24 hours
|
||||
- Investigate records
|
||||
- Offer correction or compensation if valid
|
||||
|
||||
---
|
||||
|
||||
### Level 3: Critical Issue
|
||||
|
||||
Examples:
|
||||
|
||||
- Safety issue
|
||||
- Accident handling failure
|
||||
- Major billing dispute
|
||||
- Legal threat
|
||||
- Fraud accusation
|
||||
- Discrimination complaint
|
||||
|
||||
Action:
|
||||
|
||||
- Immediate manager escalation
|
||||
- Preserve all records
|
||||
- Stop automated review reminders
|
||||
- Senior manager or owner handles case
|
||||
- Legal or insurance review if needed
|
||||
|
||||
---
|
||||
|
||||
## 12. Internal Feedback Form
|
||||
|
||||
The company may use an internal form before sending customers to public review platforms.
|
||||
|
||||
Recommended questions:
|
||||
|
||||
1. How was the booking process?
|
||||
2. How was the pickup experience?
|
||||
3. Was the car clean?
|
||||
4. Was the car in good condition?
|
||||
5. Was the return process easy?
|
||||
6. Was pricing clear?
|
||||
7. Would you rent from us again?
|
||||
8. What should we improve?
|
||||
|
||||
Recommended score scale:
|
||||
|
||||
| Score | Meaning |
|
||||
|---|---|
|
||||
| 1 | Very bad |
|
||||
| 2 | Bad |
|
||||
| 3 | Okay |
|
||||
| 4 | Good |
|
||||
| 5 | Excellent |
|
||||
|
||||
---
|
||||
|
||||
## 13. Review Data to Track
|
||||
|
||||
The company should track:
|
||||
|
||||
- Number of review requests sent
|
||||
- Number of reviews received
|
||||
- Review response rate
|
||||
- Average review score
|
||||
- Number of positive reviews
|
||||
- Number of negative reviews
|
||||
- Main complaint categories
|
||||
- Branch performance
|
||||
- Staff performance
|
||||
- Vehicle-related complaints
|
||||
- Billing-related complaints
|
||||
- Deposit-related complaints
|
||||
- Pickup delay complaints
|
||||
- Return delay complaints
|
||||
|
||||
A monthly review report should show:
|
||||
|
||||
- Top 5 problems
|
||||
- Top 5 positive mentions
|
||||
- Best-performing branch
|
||||
- Worst-performing branch
|
||||
- Repeat complaint patterns
|
||||
- Corrective actions taken
|
||||
|
||||
---
|
||||
|
||||
## 14. Feedback Categories
|
||||
|
||||
Every review or complaint should be tagged under one main category:
|
||||
|
||||
- Booking
|
||||
- Pickup
|
||||
- Vehicle cleanliness
|
||||
- Vehicle condition
|
||||
- Staff service
|
||||
- Pricing
|
||||
- Deposit
|
||||
- Insurance
|
||||
- Damage claim
|
||||
- Return process
|
||||
- Communication
|
||||
- Roadside assistance
|
||||
- Billing
|
||||
- Other
|
||||
|
||||
This makes patterns easier to identify and fix.
|
||||
|
||||
Examples:
|
||||
|
||||
- Many pickup complaints = staffing or preparation issue
|
||||
- Many deposit complaints = unclear deposit communication
|
||||
- Many cleanliness complaints = weak cleaning control
|
||||
- Many billing complaints = unclear pricing or invoice process
|
||||
|
||||
---
|
||||
|
||||
## 15. Staff Responsibilities
|
||||
|
||||
### Rental Agent
|
||||
|
||||
Responsible for:
|
||||
|
||||
- Closing rental properly
|
||||
- Confirming customer contact details
|
||||
- Adding notes about any issue
|
||||
- Marking rental ready for review request
|
||||
|
||||
---
|
||||
|
||||
### Customer Support
|
||||
|
||||
Responsible for:
|
||||
|
||||
- Monitoring reviews
|
||||
- Replying to simple feedback
|
||||
- Creating complaint cases
|
||||
- Contacting unhappy customers
|
||||
|
||||
---
|
||||
|
||||
### Branch Manager
|
||||
|
||||
Responsible for:
|
||||
|
||||
- Reviewing negative feedback
|
||||
- Approving compensation
|
||||
- Coaching staff
|
||||
- Fixing branch-level problems
|
||||
|
||||
---
|
||||
|
||||
### Owner or Senior Manager
|
||||
|
||||
Responsible for:
|
||||
|
||||
- Reviewing monthly review report
|
||||
- Handling serious complaints
|
||||
- Updating company policy
|
||||
- Tracking reputation score
|
||||
|
||||
---
|
||||
|
||||
## 16. Review Response Standards
|
||||
|
||||
### Positive Review Response
|
||||
|
||||
Target response time:
|
||||
|
||||
**Within 3 business days**
|
||||
|
||||
Tone:
|
||||
|
||||
- Thankful
|
||||
- Short
|
||||
- Professional
|
||||
|
||||
---
|
||||
|
||||
### Negative Review Response
|
||||
|
||||
Target response time:
|
||||
|
||||
**Within 24 hours**
|
||||
|
||||
Tone:
|
||||
|
||||
- Calm
|
||||
- Responsible
|
||||
- Not defensive
|
||||
- No private customer details
|
||||
|
||||
---
|
||||
|
||||
### False or Abusive Review
|
||||
|
||||
If a review appears fake, abusive, or unrelated:
|
||||
|
||||
1. Take a screenshot.
|
||||
2. Check if the reviewer matches a customer.
|
||||
3. Report the review to the platform if it violates rules.
|
||||
4. Reply professionally if needed.
|
||||
5. Do not insult the reviewer.
|
||||
|
||||
Example reply:
|
||||
|
||||
> We cannot locate a rental under this name, but we take feedback seriously. Please contact us at {{contact_info}} so we can review this properly.
|
||||
|
||||
---
|
||||
|
||||
## 17. Compensation Rules
|
||||
|
||||
Compensation should be controlled and approved based on severity.
|
||||
|
||||
Possible compensation options:
|
||||
|
||||
- Apology only
|
||||
- Discount on next rental
|
||||
- Partial refund
|
||||
- Cleaning fee refund
|
||||
- Upgrade on next rental
|
||||
- Delivery fee refund
|
||||
- Deposit correction
|
||||
- Full refund, only in serious cases
|
||||
|
||||
Manager approval should be required for:
|
||||
|
||||
- Refunds above a set amount
|
||||
- Free rental days
|
||||
- Damage fee removal
|
||||
- Public complaint compensation
|
||||
- Legal threat situations
|
||||
|
||||
Compensation should be fair, documented, and linked to the actual issue.
|
||||
|
||||
---
|
||||
|
||||
## 18. Automation Rules
|
||||
|
||||
The review system should be automated but controlled.
|
||||
|
||||
### Automatic Review Request Trigger
|
||||
|
||||
Send a review request when:
|
||||
|
||||
- Rental status = Closed
|
||||
- Final invoice sent
|
||||
- No open dispute
|
||||
- No active complaint
|
||||
- Customer has valid phone or email
|
||||
- Customer has not opted out
|
||||
|
||||
---
|
||||
|
||||
### Automatic Reminder Trigger
|
||||
|
||||
Send a reminder when:
|
||||
|
||||
- No review received after 48 hours
|
||||
- No complaint opened
|
||||
- Customer has not opted out
|
||||
|
||||
---
|
||||
|
||||
### Stop Automation When
|
||||
|
||||
Stop review automation when:
|
||||
|
||||
- Customer replies with complaint
|
||||
- Review has already been submitted
|
||||
- Dispute is opened
|
||||
- Customer opts out
|
||||
- Staff manually pauses request
|
||||
|
||||
---
|
||||
|
||||
## 19. Simple Review Workflows
|
||||
|
||||
### Normal Happy Customer
|
||||
|
||||
1. Rental closed
|
||||
2. Review request sent
|
||||
3. Customer leaves review
|
||||
4. Company replies
|
||||
5. Review recorded
|
||||
6. No further action
|
||||
|
||||
---
|
||||
|
||||
### No Response
|
||||
|
||||
1. Rental closed
|
||||
2. First request sent
|
||||
3. No response after 48 hours
|
||||
4. Reminder sent
|
||||
5. No response after 7 days
|
||||
6. Final reminder sent
|
||||
7. Stop
|
||||
|
||||
---
|
||||
|
||||
### Unhappy Customer
|
||||
|
||||
1. Rental closed
|
||||
2. Customer gives low internal rating or bad public review
|
||||
3. Complaint case created
|
||||
4. Manager reviews rental
|
||||
5. Customer contacted
|
||||
6. Issue resolved or documented
|
||||
7. Public reply posted
|
||||
8. Root cause logged
|
||||
|
||||
---
|
||||
|
||||
### Dispute Customer
|
||||
|
||||
1. Rental closed with dispute
|
||||
2. Review request paused
|
||||
3. Dispute handled first
|
||||
4. Manager decides whether review request should be sent after resolution
|
||||
|
||||
---
|
||||
|
||||
## 20. Key Rules
|
||||
|
||||
The review process should follow these rules:
|
||||
|
||||
- Never send a review request before the final invoice.
|
||||
- Never send a review request during an open dispute.
|
||||
- Send the first request within 2 to 6 hours after closure.
|
||||
- Send no more than 3 total review messages.
|
||||
- Respond to negative reviews within 24 hours.
|
||||
- Track every complaint by category.
|
||||
- Use feedback to fix operations.
|
||||
- Do not argue publicly.
|
||||
- Do not ask directly for fake or forced 5-star reviews.
|
||||
- Stop messaging customers who opt out.
|
||||
|
||||
---
|
||||
|
||||
## 21. Minimum Setup for a Small Rental Company
|
||||
|
||||
A small rental company can manage the process with:
|
||||
|
||||
- Rental management system
|
||||
- Google review link
|
||||
- SMS or email tool
|
||||
- Complaint spreadsheet or CRM
|
||||
- Monthly review report
|
||||
- Staff member assigned to monitor reviews daily
|
||||
|
||||
Minimum tracking fields:
|
||||
|
||||
| Field | Example |
|
||||
|---|---|
|
||||
| Booking ID | R-1029 |
|
||||
| Customer Name | John Smith |
|
||||
| Return Date | 2026-05-25 |
|
||||
| Review Request Sent | Yes |
|
||||
| Review Score | 5 |
|
||||
| Complaint? | No |
|
||||
| Category | Pickup |
|
||||
| Staff Owner | Sarah |
|
||||
| Status | Closed |
|
||||
|
||||
---
|
||||
|
||||
## 22. Best Operating Rule
|
||||
|
||||
The review process should be simple:
|
||||
|
||||
**Ask every eligible customer.
|
||||
Pause disputed customers.
|
||||
Respond fast.
|
||||
Fix repeated problems.**
|
||||
|
||||
A review system that only collects praise is vanity. A review system that identifies problems and fixes them is management.
|
||||
@@ -0,0 +1,778 @@
|
||||
# Database Schema and Table Relationships
|
||||
|
||||
This document explains the current Prisma schema, how the data is partitioned, and how the main tables relate to each other.
|
||||
|
||||
Source of truth:
|
||||
|
||||
- `packages/database/prisma/schema.prisma`
|
||||
|
||||
## Design Principles
|
||||
|
||||
The schema is built around a multi-tenant SaaS model with three distinct concerns:
|
||||
|
||||
1. company operations for running a rental business
|
||||
2. renter-facing booking and review flows
|
||||
3. platform-level SaaS billing and administration
|
||||
|
||||
Important design choices:
|
||||
|
||||
- `Company` is the tenant root for nearly every business table.
|
||||
- operational data and platform billing data are kept separate.
|
||||
- reservation-time snapshots are stored explicitly so later changes to policies or prices do not rewrite history.
|
||||
- some user concepts intentionally exist twice:
|
||||
- `Renter` is a cross-company identity
|
||||
- `Customer` is a company-owned CRM record
|
||||
- status changes are modeled with enums rather than free-text fields where the workflow matters.
|
||||
|
||||
## High-Level Domain Map
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
Company ||--o| Subscription : has
|
||||
Company ||--o| BrandSettings : has
|
||||
Company ||--o| ContractSettings : has
|
||||
Company ||--o| AccountingSettings : has
|
||||
Company ||--o{ Employee : employs
|
||||
Company ||--o{ Vehicle : owns
|
||||
Company ||--o{ Offer : publishes
|
||||
Company ||--o{ Customer : manages
|
||||
Company ||--o{ Reservation : receives
|
||||
Company ||--o{ RentalPayment : collects
|
||||
Company ||--o{ InsurancePolicy : defines
|
||||
Company ||--o{ PricingRule : defines
|
||||
Company ||--o{ BillingAccount : bills
|
||||
Company ||--o{ BillingInvoice : invoices
|
||||
Company ||--o{ SubscriptionInvoice : tracks
|
||||
Company ||--o{ Notification : receives
|
||||
Company ||--o{ Complaint : tracks
|
||||
|
||||
Vehicle ||--o{ Reservation : booked_in
|
||||
Vehicle ||--o{ MaintenanceLog : has
|
||||
Vehicle ||--o{ VehicleCalendarBlock : blocked_by
|
||||
Offer ||--o{ OfferVehicle : applies_to
|
||||
Vehicle ||--o{ OfferVehicle : offered_in
|
||||
|
||||
Customer ||--o{ Reservation : books
|
||||
Renter ||--o{ Reservation : owns
|
||||
Reservation ||--o{ RentalPayment : paid_by
|
||||
Reservation ||--o{ ReservationInsurance : includes
|
||||
Reservation ||--o{ AdditionalDriver : adds
|
||||
Reservation ||--o{ DamageInspection : inspected_by
|
||||
Reservation ||--o{ DamageReport : reported_by
|
||||
Reservation ||--o{ ReservationPhoto : photographed_by
|
||||
Reservation ||--o| Review : reviewed_as
|
||||
Reservation ||--o{ Complaint : disputed_in
|
||||
|
||||
InsurancePolicy ||--o{ ReservationInsurance : snapshotted_into
|
||||
Renter ||--o{ Review : writes
|
||||
Review ||--o{ Complaint : escalates_to
|
||||
|
||||
AdminUser ||--o{ AdminPermission : has
|
||||
AdminUser ||--o{ AuditLog : writes
|
||||
```
|
||||
|
||||
## Core Tenant Root
|
||||
|
||||
### `Company`
|
||||
|
||||
`Company` is the anchor row for tenant isolation.
|
||||
|
||||
Key fields:
|
||||
|
||||
- identity: `id`, `name`, `slug`, `email`, `phone`
|
||||
- public/integration: `apiKey`
|
||||
- SaaS state: `status`, `subscriptionPaymentRef`
|
||||
- address: `address` JSON
|
||||
|
||||
Key relations:
|
||||
|
||||
- 1:1 with `Subscription`
|
||||
- 1:1 with `BrandSettings`
|
||||
- 1:1 with `ContractSettings`
|
||||
- 1:1 with `AccountingSettings`
|
||||
- 1:N with `Employee`, `Vehicle`, `Offer`, `Customer`, `Reservation`, `RentalPayment`
|
||||
- 1:N with billing tables such as `BillingAccount`, `BillingInvoice`, `SubscriptionInvoice`
|
||||
- 1:N with `InsurancePolicy`, `PricingRule`, `Notification`, `Complaint`
|
||||
|
||||
Important uniqueness:
|
||||
|
||||
- `slug`
|
||||
- `email`
|
||||
- `apiKey`
|
||||
|
||||
## SaaS Subscription and Billing
|
||||
|
||||
There are two billing layers in the schema:
|
||||
|
||||
1. legacy provider-oriented subscription records
|
||||
2. richer billing-account and billing-invoice records for platform finance operations
|
||||
|
||||
### `Subscription`
|
||||
|
||||
One row per company.
|
||||
|
||||
Purpose:
|
||||
|
||||
- current plan
|
||||
- billing period
|
||||
- lifecycle state
|
||||
- trial dates
|
||||
- renewal windows
|
||||
- suspension and cancellation timing
|
||||
|
||||
Relations:
|
||||
|
||||
- belongs to one `Company`
|
||||
- has many `SubscriptionInvoice`
|
||||
- has many `SubscriptionEvent`
|
||||
- can be referenced by many `BillingInvoice`
|
||||
- can be referenced by many `BillingEvent`
|
||||
|
||||
### `SubscriptionEvent`
|
||||
|
||||
Append-only event log for the subscription lifecycle.
|
||||
|
||||
Examples of what it represents:
|
||||
|
||||
- trial started
|
||||
- checkout created
|
||||
- payment received
|
||||
- plan changed
|
||||
- grace period extended
|
||||
- subscription suspended
|
||||
|
||||
### `SubscriptionInvoice`
|
||||
|
||||
Legacy or provider-facing subscription payment records.
|
||||
|
||||
Purpose:
|
||||
|
||||
- capture AmanPay or PayPal payment references
|
||||
- track amount, currency, due date, paid date, and failure/void state
|
||||
- optionally bridge to the richer `BillingInvoice` model via `billingInvoiceId`
|
||||
|
||||
### `PaymentAttempt`
|
||||
|
||||
Legacy payment-attempt rows tied to `SubscriptionInvoice`.
|
||||
|
||||
This is narrower than the newer billing payment tables and exists for provider-level retry/failure tracking.
|
||||
|
||||
### `BillingAccount`
|
||||
|
||||
The primary finance entity for a company at the platform level.
|
||||
|
||||
Purpose:
|
||||
|
||||
- billing identity and address
|
||||
- invoice terms
|
||||
- default currency
|
||||
- tax flags
|
||||
- default payment method
|
||||
- dunning pause state
|
||||
- external provider customer ID
|
||||
|
||||
Relations:
|
||||
|
||||
- belongs to one `Company`
|
||||
- has many `BillingPaymentMethod`
|
||||
- has many `BillingInvoice`
|
||||
- has many `BillingPaymentIntent`
|
||||
- has many `BillingPaymentAttempt`
|
||||
- has many `BillingCreditBalance`, `BillingCreditLedgerEntry`, `BillingCreditNote`, `BillingRefund`, `BillingEvent`
|
||||
|
||||
### `BillingPaymentMethod`
|
||||
|
||||
Stored billing method metadata for the company account.
|
||||
|
||||
Examples:
|
||||
|
||||
- card brand/last4/expiration
|
||||
- ACH/bank/manual invoice style billing methods
|
||||
|
||||
One billing account can have many payment methods; one may be selected as default.
|
||||
|
||||
### `BillingInvoice`
|
||||
|
||||
The main platform invoice table.
|
||||
|
||||
Purpose:
|
||||
|
||||
- invoice numbering and sequencing
|
||||
- totals and tax math
|
||||
- status transitions
|
||||
- billing snapshot fields such as billing name and email
|
||||
- finance/admin metadata
|
||||
|
||||
Relations:
|
||||
|
||||
- belongs to one `BillingAccount`
|
||||
- belongs to one `Company`
|
||||
- optionally belongs to one `Subscription`
|
||||
- optionally has a linked legacy `SubscriptionInvoice`
|
||||
- has many `BillingInvoiceLineItem`
|
||||
- has many `BillingPaymentIntent`
|
||||
- has many `BillingPaymentAttempt`
|
||||
- has many `BillingTaxRecord`
|
||||
- has many `BillingCreditNote`
|
||||
- has many `BillingRefund`
|
||||
- has many `BillingEvent`
|
||||
|
||||
### Supporting billing tables
|
||||
|
||||
- `BillingInvoiceLineItem`: immutable invoice lines with type, quantity, unit amount, and optional service period.
|
||||
- `BillingPaymentIntent`: payment-intent level state for a billing invoice.
|
||||
- `BillingPaymentAttempt`: actual collection attempts against an invoice.
|
||||
- `BillingCreditBalance`: running credit balance per billing account and currency.
|
||||
- `BillingCreditLedgerEntry`: credit ledger movement history.
|
||||
- `BillingCreditNote`: credit documents issued against an invoice.
|
||||
- `BillingRefund`: refund records for billing invoices.
|
||||
- `BillingTaxRecord`: tax breakdown rows for an invoice.
|
||||
- `BillingEvent`: append-only finance/subscription/company event log.
|
||||
|
||||
## Branding and Company Configuration
|
||||
|
||||
### `BrandSettings`
|
||||
|
||||
One-to-one with `Company`.
|
||||
|
||||
Purpose:
|
||||
|
||||
- public display name, tagline, logo, favicon, hero image
|
||||
- theme colors
|
||||
- subdomain and custom domain
|
||||
- public contact and social metadata
|
||||
- default locale and currency
|
||||
- company-owned payment provider settings for renter payments
|
||||
- marketplace visibility and rating
|
||||
- homepage/menu JSON configuration
|
||||
|
||||
Important uniqueness:
|
||||
|
||||
- `companyId`
|
||||
- `subdomain`
|
||||
- `customDomain`
|
||||
|
||||
### `ContractSettings`
|
||||
|
||||
One-to-one with `Company`.
|
||||
|
||||
Purpose:
|
||||
|
||||
- rental contract legal copy
|
||||
- invoice/footer text
|
||||
- fuel and late-fee policy
|
||||
- tax settings
|
||||
- numbering prefixes and running sequences
|
||||
- additional-driver charging policy
|
||||
|
||||
This table is used when generating operational rental documents.
|
||||
|
||||
### `AccountingSettings`
|
||||
|
||||
One-to-one with `Company`.
|
||||
|
||||
Purpose:
|
||||
|
||||
- reporting period
|
||||
- fiscal-year start month
|
||||
- accounting contact
|
||||
- automatic report settings
|
||||
- preferred export format
|
||||
|
||||
### `PricingRule`
|
||||
|
||||
Company-owned pricing rule definitions applied to reservations.
|
||||
|
||||
Purpose:
|
||||
|
||||
- define surcharge or discount logic
|
||||
- express the rule type, condition, and adjustment
|
||||
|
||||
These rules are copied into reservation snapshots through `Reservation.pricingRulesApplied` and `Reservation.pricingRulesTotal`.
|
||||
|
||||
### `InsurancePolicy`
|
||||
|
||||
Company-defined upsell or mandatory protection products.
|
||||
|
||||
Purpose:
|
||||
|
||||
- define policy name and type
|
||||
- define flat, percent, or per-day charges
|
||||
- mark required or optional coverage
|
||||
|
||||
These are also snapshotted into reservations.
|
||||
|
||||
## Staff, Fleet, and Commercial Catalog
|
||||
|
||||
### `Employee`
|
||||
|
||||
Company staff records.
|
||||
|
||||
Purpose:
|
||||
|
||||
- operational user identity
|
||||
- role assignment
|
||||
- preferred language
|
||||
- password-reset support
|
||||
- activation state
|
||||
|
||||
Implementation note:
|
||||
|
||||
- `clerkUserId` still exists as a required unique field in the schema, but Clerk is no longer an active auth dependency in this project.
|
||||
- the field is currently populated with local/generated identifiers so existing schema constraints remain satisfied.
|
||||
|
||||
Relations:
|
||||
|
||||
- belongs to one `Company`
|
||||
- has many `Notification`
|
||||
- has many `NotificationPreference`
|
||||
|
||||
Important uniqueness:
|
||||
|
||||
- `clerkUserId`
|
||||
|
||||
### `Vehicle`
|
||||
|
||||
The core fleet entity.
|
||||
|
||||
Purpose:
|
||||
|
||||
- static vehicle information
|
||||
- public listing content
|
||||
- daily rate and status
|
||||
- pickup/dropoff location rules
|
||||
|
||||
Relations:
|
||||
|
||||
- belongs to one `Company`
|
||||
- has many `Reservation`
|
||||
- has many `MaintenanceLog`
|
||||
- has many `VehicleCalendarBlock`
|
||||
- participates in many-to-many offers through `OfferVehicle`
|
||||
|
||||
Soft deletion is implemented operationally by setting:
|
||||
|
||||
- `status = OUT_OF_SERVICE`
|
||||
- `isPublished = false`
|
||||
|
||||
### `MaintenanceLog`
|
||||
|
||||
One-to-many from `Vehicle`.
|
||||
|
||||
Purpose:
|
||||
|
||||
- maintenance type and description
|
||||
- cost, mileage, performed date
|
||||
- next due date or mileage
|
||||
|
||||
### `VehicleCalendarBlock`
|
||||
|
||||
One-to-many from `Vehicle`.
|
||||
|
||||
Purpose:
|
||||
|
||||
- manual or maintenance blocks on the reservation calendar
|
||||
- date-range unavailability independent of reservations
|
||||
|
||||
### `Offer`
|
||||
|
||||
Company-defined promotional offers.
|
||||
|
||||
Purpose:
|
||||
|
||||
- percentage/fixed/free-day/special-rate discount logic
|
||||
- validity window
|
||||
- promo code
|
||||
- public and featured visibility
|
||||
- redemption caps
|
||||
|
||||
Relations:
|
||||
|
||||
- belongs to one `Company`
|
||||
- has many `Reservation`
|
||||
- many-to-many with `Vehicle` through `OfferVehicle`
|
||||
|
||||
### `OfferVehicle`
|
||||
|
||||
Join table between `Offer` and `Vehicle`.
|
||||
|
||||
Composite primary key:
|
||||
|
||||
- `(offerId, vehicleId)`
|
||||
|
||||
## Renter Identity, Company CRM, and Reservation Aggregate
|
||||
|
||||
The schema intentionally separates global renter identity from company-local customer records.
|
||||
|
||||
### `Renter`
|
||||
|
||||
Cross-company end-user identity.
|
||||
|
||||
Purpose:
|
||||
|
||||
- login identity
|
||||
- app preferences and verification state
|
||||
- push token
|
||||
- reservation and review linkage across companies
|
||||
|
||||
Relations:
|
||||
|
||||
- has many `Reservation`
|
||||
- has many `Review`
|
||||
- has many `Notification`
|
||||
- has many `NotificationPreference`
|
||||
- has many saved companies through `RenterSavedCompany`
|
||||
|
||||
### `RenterSavedCompany`
|
||||
|
||||
Join table that stores renter bookmarks.
|
||||
|
||||
Composite primary key:
|
||||
|
||||
- `(renterId, companyId)`
|
||||
|
||||
### `Customer`
|
||||
|
||||
Company-owned CRM record for a person renting from that company.
|
||||
|
||||
Purpose:
|
||||
|
||||
- renter contact and profile data as understood by that company
|
||||
- notes and flag state
|
||||
- driver-license metadata
|
||||
- approval and compliance state
|
||||
- company-specific reservation history
|
||||
|
||||
Relations:
|
||||
|
||||
- belongs to one `Company`
|
||||
- has many `Reservation`
|
||||
- has many `Complaint`
|
||||
|
||||
Important uniqueness:
|
||||
|
||||
- `(companyId, email)`
|
||||
|
||||
### `Reservation`
|
||||
|
||||
This is the central transactional aggregate for rental operations.
|
||||
|
||||
Core relations:
|
||||
|
||||
- belongs to one `Company`
|
||||
- belongs to one `Vehicle`
|
||||
- belongs to one `Customer`
|
||||
- optionally belongs to one `Renter`
|
||||
- optionally belongs to one `Offer`
|
||||
|
||||
Core business fields:
|
||||
|
||||
- booking source
|
||||
- date range
|
||||
- pickup/return locations
|
||||
- pricing fields: `dailyRate`, `discountAmount`, `totalDays`, `totalAmount`, `depositAmount`
|
||||
- payment summary fields: `paymentStatus`, `paidAmount`
|
||||
- lifecycle fields: `status`, `checkedInAt`, `checkedOutAt`
|
||||
- cancellation fields
|
||||
- contract and invoice numbering
|
||||
- review token and review reminder timestamps
|
||||
- extras and pricing-rule snapshots
|
||||
|
||||
The reservation is the parent of most rental workflow records.
|
||||
|
||||
### Reservation child tables
|
||||
|
||||
- `RentalPayment`: individual payment rows for the reservation.
|
||||
- `ReservationInsurance`: reservation-time snapshots of selected insurance products.
|
||||
- `AdditionalDriver`: reservation-time records for extra drivers and approval status.
|
||||
- `DamageReport`: pickup/dropoff summary damage records with photos and signatures.
|
||||
- `DamageInspection`: structured inspection record per reservation and inspection type.
|
||||
- `DamagePoint`: individual annotated damage points tied to one inspection.
|
||||
- `ReservationPhoto`: ad hoc pickup/dropoff photo attachments.
|
||||
- `Review`: at most one review per reservation.
|
||||
- `Complaint`: operational or post-rental dispute record, optionally linked to reservation, review, and customer.
|
||||
|
||||
This snapshot pattern is important. The reservation stores what was actually sold and reviewed at the time of booking, not just pointers to mutable company configuration.
|
||||
|
||||
## Reservation-Adjacent Tables in Detail
|
||||
|
||||
### `RentalPayment`
|
||||
|
||||
Operational payment rows for rentals, not SaaS billing.
|
||||
|
||||
Purpose:
|
||||
|
||||
- amount, currency, type, status
|
||||
- AmanPay or PayPal provider references
|
||||
- paid timestamp
|
||||
|
||||
Relations:
|
||||
|
||||
- belongs to one `Company`
|
||||
- belongs to one `Reservation`
|
||||
|
||||
### `ReservationInsurance`
|
||||
|
||||
Snapshot bridge between `Reservation` and `InsurancePolicy`.
|
||||
|
||||
It copies:
|
||||
|
||||
- policy name
|
||||
- policy type
|
||||
- charge type
|
||||
- charge value
|
||||
- total charge
|
||||
|
||||
That prevents later edits to the base insurance policy from mutating historical reservation pricing.
|
||||
|
||||
### `AdditionalDriver`
|
||||
|
||||
Reservation child rows for additional drivers.
|
||||
|
||||
Purpose:
|
||||
|
||||
- identity and license details
|
||||
- charge model
|
||||
- approval requirement and approval outcome
|
||||
- expiry flags
|
||||
|
||||
### `DamageReport`
|
||||
|
||||
One row per reservation and report type.
|
||||
|
||||
Composite uniqueness:
|
||||
|
||||
- `(reservationId, type)`
|
||||
|
||||
Stores:
|
||||
|
||||
- summary damages JSON
|
||||
- photo URLs
|
||||
- fuel level
|
||||
- mileage
|
||||
- inspection metadata
|
||||
|
||||
### `DamageInspection`
|
||||
|
||||
Structured pickup/dropoff inspection entity.
|
||||
|
||||
Composite uniqueness:
|
||||
|
||||
- `(reservationId, type)`
|
||||
|
||||
Stores:
|
||||
|
||||
- fuel and mileage
|
||||
- general condition notes
|
||||
- customer agreement state
|
||||
- inspector identity
|
||||
|
||||
### `DamagePoint`
|
||||
|
||||
Granular diagram point rows tied to `DamageInspection`.
|
||||
|
||||
Stores:
|
||||
|
||||
- diagram view
|
||||
- x/y coordinates
|
||||
- damage type and severity
|
||||
- free-text description
|
||||
- whether the damage is pre-existing
|
||||
|
||||
### `ReservationPhoto`
|
||||
|
||||
Simple photo attachments tied to a reservation with type `PICKUP` or `DROPOFF`.
|
||||
|
||||
### `Review`
|
||||
|
||||
One review per reservation.
|
||||
|
||||
Important uniqueness:
|
||||
|
||||
- `reservationId`
|
||||
|
||||
Purpose:
|
||||
|
||||
- overall, vehicle, and service ratings
|
||||
- comment
|
||||
- moderation/publication flags
|
||||
- company reply data
|
||||
- optional feedback category
|
||||
|
||||
Relations:
|
||||
|
||||
- optionally linked to `Renter`
|
||||
- can have many `Complaint`
|
||||
|
||||
### `Complaint`
|
||||
|
||||
Company-scoped issue-tracking table.
|
||||
|
||||
Can be linked to:
|
||||
|
||||
- a reservation
|
||||
- a review
|
||||
- a customer
|
||||
|
||||
This supports both operational complaints and post-review escalations.
|
||||
|
||||
## Notifications and Messaging
|
||||
|
||||
### `Notification`
|
||||
|
||||
A notification can target one of three audiences:
|
||||
|
||||
- company-level
|
||||
- employee-level
|
||||
- renter-level
|
||||
|
||||
Purpose:
|
||||
|
||||
- delivery type
|
||||
- title/body/data payload
|
||||
- channel
|
||||
- locale
|
||||
- send/read/failure state
|
||||
- provider message ID
|
||||
|
||||
### `NotificationTemplate`
|
||||
|
||||
Reusable message content keyed by:
|
||||
|
||||
- template key
|
||||
- channel
|
||||
- locale
|
||||
- version
|
||||
|
||||
### `NotificationPreference`
|
||||
|
||||
Per-recipient opt-in/out rows.
|
||||
|
||||
The uniqueness rules are intentionally split:
|
||||
|
||||
- unique per `(employeeId, notificationType, channel)`
|
||||
- unique per `(renterId, notificationType, channel)`
|
||||
|
||||
## Admin and Platform Governance
|
||||
|
||||
### `AdminUser`
|
||||
|
||||
Platform operator account.
|
||||
|
||||
Purpose:
|
||||
|
||||
- role
|
||||
- password hash
|
||||
- 2FA state
|
||||
- login tracking
|
||||
- password reset support
|
||||
|
||||
Relations:
|
||||
|
||||
- has many `AdminPermission`
|
||||
- has many `AuditLog`
|
||||
|
||||
### `AdminPermission`
|
||||
|
||||
Resource/action override rows for an admin user.
|
||||
|
||||
Important uniqueness:
|
||||
|
||||
- `(adminUserId, resource)`
|
||||
|
||||
### `AuditLog`
|
||||
|
||||
Immutable log of admin-side actions.
|
||||
|
||||
Stores:
|
||||
|
||||
- actor
|
||||
- action and resource
|
||||
- optional company and resource IDs
|
||||
- before/after JSON
|
||||
- IP, user agent, and note
|
||||
|
||||
## Pricing and Platform Content Configuration
|
||||
|
||||
### `PricingConfig`
|
||||
|
||||
Platform-managed plan pricing rows.
|
||||
|
||||
Important uniqueness:
|
||||
|
||||
- `(plan, billingPeriod)`
|
||||
|
||||
### `PlanFeature`
|
||||
|
||||
Platform-managed feature labels per plan, ordered by `sortOrder`.
|
||||
|
||||
### `PricingPromotion`
|
||||
|
||||
Platform-managed promotion codes for SaaS pricing.
|
||||
|
||||
Purpose:
|
||||
|
||||
- code and description
|
||||
- discount type/value
|
||||
- applicable plans/periods
|
||||
- usage limits
|
||||
- validity window
|
||||
|
||||
## Relationship Summary by Cardinality
|
||||
|
||||
### One-to-one
|
||||
|
||||
- `Company` -> `Subscription`
|
||||
- `Company` -> `BrandSettings`
|
||||
- `Company` -> `ContractSettings`
|
||||
- `Company` -> `AccountingSettings`
|
||||
|
||||
### One-to-many
|
||||
|
||||
- `Company` -> `Employee`, `Vehicle`, `Offer`, `Customer`, `Reservation`, `RentalPayment`
|
||||
- `Company` -> `BillingAccount`, `BillingInvoice`, `SubscriptionInvoice`, `InsurancePolicy`, `PricingRule`, `Notification`, `Complaint`
|
||||
- `Vehicle` -> `Reservation`, `MaintenanceLog`, `VehicleCalendarBlock`
|
||||
- `Reservation` -> `RentalPayment`, `ReservationInsurance`, `AdditionalDriver`, `DamageReport`, `DamageInspection`, `ReservationPhoto`, `Complaint`
|
||||
- `BillingAccount` -> `BillingPaymentMethod`, `BillingInvoice`, `BillingPaymentIntent`, `BillingPaymentAttempt`, `BillingCreditBalance`, `BillingCreditLedgerEntry`, `BillingCreditNote`, `BillingRefund`, `BillingEvent`
|
||||
|
||||
### Many-to-many through join tables
|
||||
|
||||
- `Offer` <-> `Vehicle` through `OfferVehicle`
|
||||
- `Renter` <-> `Company` through `RenterSavedCompany`
|
||||
|
||||
## Indexing and Uniqueness Rules Worth Remembering
|
||||
|
||||
Operationally important uniqueness constraints:
|
||||
|
||||
- `Company.slug`
|
||||
- `Company.email`
|
||||
- `Company.apiKey`
|
||||
- `BrandSettings.subdomain`
|
||||
- `BrandSettings.customDomain`
|
||||
- `Employee.clerkUserId`
|
||||
- `Customer(companyId, email)`
|
||||
- `Reservation.contractNumber`
|
||||
- `Reservation.invoiceNumber`
|
||||
- `Reservation.reviewToken`
|
||||
- `Review.reservationId`
|
||||
- `OfferVehicle(offerId, vehicleId)`
|
||||
- `RenterSavedCompany(renterId, companyId)`
|
||||
- `ReservationInsurance(reservationId, insurancePolicyId)`
|
||||
- `DamageReport(reservationId, type)`
|
||||
- `DamageInspection(reservationId, type)`
|
||||
- `PricingConfig(plan, billingPeriod)`
|
||||
- `NotificationPreference` employee and renter uniqueness pairs
|
||||
|
||||
## Practical Reading Guide
|
||||
|
||||
If you need to understand the schema quickly, read it in this order:
|
||||
|
||||
1. `Company`
|
||||
2. `Subscription`, `BillingAccount`, `BillingInvoice`
|
||||
3. `Employee`, `Vehicle`, `Offer`
|
||||
4. `Customer`, `Renter`
|
||||
5. `Reservation` and its child tables
|
||||
6. `Notification*`
|
||||
7. `Admin*`, `AuditLog`, `Pricing*`
|
||||
|
||||
That order matches how the application is structured in the API: tenant first, operations second, finance/admin last.
|
||||
@@ -0,0 +1,695 @@
|
||||
# Simple and Robust Car Rental Process
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines a simple, robust operating process for a rental car company. It covers the full rental lifecycle from booking to vehicle return.
|
||||
|
||||
The process is designed to be easy for staff to follow, fast for customers, and strong enough to protect the company from payment issues, damage disputes, missing vehicles, and unclear responsibilities.
|
||||
|
||||
## Core Process
|
||||
|
||||
The rental operation should be built around four control points:
|
||||
|
||||
1. Booking
|
||||
2. Vehicle Preparation
|
||||
3. Pickup
|
||||
4. Return
|
||||
|
||||
Everything in the process should support one of these goals:
|
||||
|
||||
- Confirm the customer is eligible to rent.
|
||||
- Confirm the vehicle is available and ready.
|
||||
- Secure payment and deposit before release.
|
||||
- Document vehicle condition at pickup and return.
|
||||
- Close the rental clearly and fairly.
|
||||
|
||||
---
|
||||
|
||||
# 1. Booking
|
||||
|
||||
## Goal
|
||||
|
||||
The goal of booking is to confirm who the customer is, what they need, and whether the company can safely rent to them.
|
||||
|
||||
## Required Customer Information
|
||||
|
||||
Collect the following information for every booking:
|
||||
|
||||
- Full customer name
|
||||
- Phone number
|
||||
- Email address
|
||||
- Driver's license details
|
||||
- Pickup date and time
|
||||
- Return date and time
|
||||
- Pickup location
|
||||
- Return location
|
||||
- Vehicle class
|
||||
- Payment method
|
||||
- Insurance choice
|
||||
- Special requests, if any
|
||||
|
||||
Special requests may include:
|
||||
|
||||
- Child seat
|
||||
- GPS device
|
||||
- Additional driver
|
||||
- One-way rental
|
||||
- Airport pickup
|
||||
- After-hours return
|
||||
- Electric vehicle request
|
||||
|
||||
## Booking Checks
|
||||
|
||||
Before confirming a booking, staff or the system must check:
|
||||
|
||||
- Vehicle availability
|
||||
- Driver age eligibility
|
||||
- Driver's license validity
|
||||
- Deposit requirement
|
||||
- Payment method validity
|
||||
- Previous unpaid balance
|
||||
- Customer blacklist or restriction status
|
||||
|
||||
If any check fails, the booking must not be confirmed until the issue is reviewed by a manager.
|
||||
|
||||
## Booking Confirmation
|
||||
|
||||
After approval, send the customer a confirmation by email or SMS.
|
||||
|
||||
The confirmation must include:
|
||||
|
||||
- Booking number
|
||||
- Pickup date, time, and location
|
||||
- Return date, time, and location
|
||||
- Vehicle class
|
||||
- Estimated total price
|
||||
- Deposit amount
|
||||
- Required documents
|
||||
- Fuel or charging rule
|
||||
- Late return rule
|
||||
- Cancellation rule
|
||||
- Company contact information
|
||||
|
||||
## Booking Checklist
|
||||
|
||||
- [ ] Customer details collected
|
||||
- [ ] License details collected
|
||||
- [ ] Vehicle availability confirmed
|
||||
- [ ] Price confirmed
|
||||
- [ ] Deposit amount confirmed
|
||||
- [ ] Insurance choice recorded
|
||||
- [ ] Special requests recorded
|
||||
- [ ] Confirmation sent to customer
|
||||
|
||||
---
|
||||
|
||||
# 2. Vehicle Preparation
|
||||
|
||||
## Goal
|
||||
|
||||
The goal of vehicle preparation is to make sure the car is ready before the customer arrives.
|
||||
|
||||
## Vehicle Assignment
|
||||
|
||||
Assign a specific vehicle to the booking before pickup whenever possible.
|
||||
|
||||
The assigned vehicle must match:
|
||||
|
||||
- Booked vehicle class
|
||||
- Required seating capacity
|
||||
- Transmission preference, if applicable
|
||||
- Fuel or electric vehicle preference, if applicable
|
||||
- Special requests, if applicable
|
||||
|
||||
If the exact vehicle is unavailable, staff may assign an equal or higher-class vehicle according to company policy.
|
||||
|
||||
## Vehicle Readiness Checklist
|
||||
|
||||
Before pickup, staff must check:
|
||||
|
||||
- [ ] Vehicle is clean inside
|
||||
- [ ] Vehicle is clean outside
|
||||
- [ ] Mileage recorded
|
||||
- [ ] Fuel or battery level recorded
|
||||
- [ ] Tires checked visually
|
||||
- [ ] No dashboard warning lights
|
||||
- [ ] Lights working
|
||||
- [ ] Registration document available
|
||||
- [ ] Insurance document available
|
||||
- [ ] Keys available
|
||||
- [ ] Requested accessories installed or included
|
||||
- [ ] Pickup photos taken
|
||||
- [ ] Existing damage recorded
|
||||
|
||||
## Vehicle Statuses
|
||||
|
||||
Use simple vehicle statuses only:
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| Available | Vehicle can be booked. |
|
||||
| Reserved | Vehicle is assigned to a future booking. |
|
||||
| Ready | Vehicle is prepared for pickup. |
|
||||
| On Rent | Vehicle is currently with a customer. |
|
||||
| Returned | Vehicle has been returned but not yet processed. |
|
||||
| Needs Cleaning | Vehicle requires cleaning before reuse. |
|
||||
| Needs Maintenance | Vehicle requires mechanical service. |
|
||||
| Damage Review | Vehicle has possible damage that must be reviewed. |
|
||||
| Blocked | Vehicle cannot be rented. |
|
||||
|
||||
## Preparation Checklist
|
||||
|
||||
- [ ] Booking reviewed
|
||||
- [ ] Vehicle assigned
|
||||
- [ ] Vehicle cleaned
|
||||
- [ ] Mileage recorded
|
||||
- [ ] Fuel or battery recorded
|
||||
- [ ] Vehicle photos taken
|
||||
- [ ] Documents inside vehicle
|
||||
- [ ] Keys ready
|
||||
- [ ] Accessories ready
|
||||
- [ ] Vehicle status changed to Ready
|
||||
|
||||
---
|
||||
|
||||
# 3. Pickup
|
||||
|
||||
## Goal
|
||||
|
||||
The goal of pickup is to verify the customer, secure payment, document the vehicle, and release the car quickly.
|
||||
|
||||
A standard pickup should normally take 10 to 15 minutes.
|
||||
|
||||
## Required Customer Documents
|
||||
|
||||
The customer must provide:
|
||||
|
||||
- Valid driver's license
|
||||
- Government ID, if required
|
||||
- Accepted payment card
|
||||
- Proof of insurance, if using personal insurance
|
||||
- Booking confirmation, if needed
|
||||
|
||||
The name on the driver's license should match the booking and payment method unless manager approval is given.
|
||||
|
||||
## Pickup Steps
|
||||
|
||||
1. Find the booking in the system.
|
||||
2. Verify the customer's identity.
|
||||
3. Verify the driver's license.
|
||||
4. Confirm return date, time, and location.
|
||||
5. Confirm vehicle class and assigned vehicle.
|
||||
6. Process rental payment.
|
||||
7. Authorize or collect deposit.
|
||||
8. Confirm insurance choice.
|
||||
9. Walk around the vehicle with the customer.
|
||||
10. Take pickup photos.
|
||||
11. Record mileage.
|
||||
12. Record fuel or battery level.
|
||||
13. Record existing damage.
|
||||
14. Have the customer sign the rental agreement.
|
||||
15. Give the customer the keys and emergency contact information.
|
||||
16. Change vehicle status to On Rent.
|
||||
|
||||
## Pickup Inspection
|
||||
|
||||
The pickup inspection must include:
|
||||
|
||||
- Front of vehicle
|
||||
- Rear of vehicle
|
||||
- Left side
|
||||
- Right side
|
||||
- Roof, if visible
|
||||
- Windshield
|
||||
- Windows
|
||||
- Mirrors
|
||||
- Tires
|
||||
- Wheels
|
||||
- Interior seats
|
||||
- Dashboard
|
||||
- Trunk
|
||||
- Fuel or charging area
|
||||
|
||||
Photos must be uploaded to the rental record before the vehicle is released.
|
||||
|
||||
## Rental Agreement Requirements
|
||||
|
||||
The rental agreement must include:
|
||||
|
||||
- Customer name
|
||||
- Authorized drivers
|
||||
- Vehicle make, model, color, and plate number
|
||||
- Pickup date and time
|
||||
- Expected return date and time
|
||||
- Pickup and return locations
|
||||
- Starting mileage
|
||||
- Starting fuel or battery level
|
||||
- Rental rate
|
||||
- Deposit amount
|
||||
- Insurance choice
|
||||
- Mileage limit, if applicable
|
||||
- Fuel or charging policy
|
||||
- Late return policy
|
||||
- Damage policy
|
||||
- Cleaning policy
|
||||
- Smoking policy
|
||||
- Toll and fine responsibility
|
||||
- Accident reporting process
|
||||
- Customer signature
|
||||
- Staff signature
|
||||
|
||||
## Pickup Checklist
|
||||
|
||||
- [ ] Booking found
|
||||
- [ ] Customer ID verified
|
||||
- [ ] Driver's license verified
|
||||
- [ ] Return details confirmed
|
||||
- [ ] Payment completed
|
||||
- [ ] Deposit authorized or collected
|
||||
- [ ] Insurance choice confirmed
|
||||
- [ ] Vehicle inspected with customer
|
||||
- [ ] Pickup photos uploaded
|
||||
- [ ] Mileage recorded
|
||||
- [ ] Fuel or battery level recorded
|
||||
- [ ] Existing damage recorded
|
||||
- [ ] Agreement signed
|
||||
- [ ] Keys given
|
||||
- [ ] Emergency contact provided
|
||||
- [ ] Vehicle status changed to On Rent
|
||||
|
||||
---
|
||||
|
||||
# 4. During the Rental
|
||||
|
||||
## Goal
|
||||
|
||||
The goal during the rental period is to handle changes, incidents, and support requests without confusion.
|
||||
|
||||
## Customer Support
|
||||
|
||||
The customer must be able to contact the company for:
|
||||
|
||||
- Rental extension
|
||||
- Accident
|
||||
- Breakdown
|
||||
- Flat tire
|
||||
- Lost key
|
||||
- Vehicle issue
|
||||
- Return location question
|
||||
- Payment issue
|
||||
|
||||
## Rental Extension Process
|
||||
|
||||
Before approving an extension, staff must:
|
||||
|
||||
1. Check vehicle availability.
|
||||
2. Confirm the new return date and time.
|
||||
3. Calculate additional charges.
|
||||
4. Process additional payment or authorization.
|
||||
5. Update the rental agreement.
|
||||
6. Send written confirmation to the customer.
|
||||
|
||||
Verbal-only extensions are not allowed.
|
||||
|
||||
## Accident Process
|
||||
|
||||
If the customer reports an accident, staff must instruct the customer to:
|
||||
|
||||
- Stop safely.
|
||||
- Call emergency services if needed.
|
||||
- Take photos of all vehicles and damage.
|
||||
- Collect other driver details, if applicable.
|
||||
- Get a police report if required.
|
||||
- Contact the rental company immediately.
|
||||
- Avoid driving the vehicle if it is unsafe.
|
||||
|
||||
Company staff must:
|
||||
|
||||
- Open an incident record.
|
||||
- Collect photos and documents.
|
||||
- Contact the insurer if needed.
|
||||
- Arrange towing if needed.
|
||||
- Arrange a replacement vehicle if approved.
|
||||
- Change vehicle status if the vehicle is no longer rentable.
|
||||
|
||||
## Breakdown Process
|
||||
|
||||
If the vehicle breaks down, staff must:
|
||||
|
||||
1. Confirm the customer's location.
|
||||
2. Confirm customer safety.
|
||||
3. Ask about warning lights or symptoms.
|
||||
4. Contact roadside assistance.
|
||||
5. Arrange towing if required.
|
||||
6. Arrange a replacement vehicle if approved.
|
||||
7. Record the incident.
|
||||
8. Mark the vehicle as Needs Maintenance or Blocked.
|
||||
|
||||
---
|
||||
|
||||
# 5. Return
|
||||
|
||||
## Goal
|
||||
|
||||
The goal of the return process is to close the rental fairly, document the condition of the vehicle, and prepare the car for the next customer.
|
||||
|
||||
## Return Steps
|
||||
|
||||
1. Find the rental agreement.
|
||||
2. Record actual return time.
|
||||
3. Record return mileage.
|
||||
4. Record fuel or battery level.
|
||||
5. Inspect the exterior.
|
||||
6. Inspect the interior.
|
||||
7. Compare condition with pickup photos.
|
||||
8. Check keys and accessories.
|
||||
9. Check for customer belongings.
|
||||
10. Take return photos.
|
||||
11. Calculate final charges.
|
||||
12. Release or adjust the deposit.
|
||||
13. Send final receipt.
|
||||
14. Change vehicle status.
|
||||
|
||||
## Return Inspection
|
||||
|
||||
The return inspection must include:
|
||||
|
||||
- Exterior damage
|
||||
- Interior damage
|
||||
- Glass condition
|
||||
- Tire condition
|
||||
- Wheel condition
|
||||
- Fuel or battery level
|
||||
- Mileage
|
||||
- Smoking odor
|
||||
- Pet hair
|
||||
- Stains
|
||||
- Trash
|
||||
- Missing accessories
|
||||
- Warning lights
|
||||
- Keys and key fobs
|
||||
|
||||
The return inspection should be completed with the customer present whenever possible.
|
||||
|
||||
## Damage Found at Return
|
||||
|
||||
If new damage is found, staff must:
|
||||
|
||||
1. Compare pickup photos with return photos.
|
||||
2. Show the customer the damage.
|
||||
3. Take clear photos of the damage.
|
||||
4. Record the damage location and description.
|
||||
5. Ask the customer to sign the damage report.
|
||||
6. Escalate to a manager if the customer disputes the damage.
|
||||
7. Keep the vehicle in Damage Review status until resolved.
|
||||
|
||||
If the customer refuses to sign, staff must write:
|
||||
|
||||
> Customer refused to sign damage acknowledgment.
|
||||
|
||||
The staff member must still complete the damage report and upload evidence.
|
||||
|
||||
## Final Charges
|
||||
|
||||
Final billing may include:
|
||||
|
||||
- Base rental charge
|
||||
- Extra rental time
|
||||
- Late return fee
|
||||
- Extra mileage fee
|
||||
- Fuel refill fee
|
||||
- EV recharge fee
|
||||
- Cleaning fee
|
||||
- Smoking fee
|
||||
- Damage charge
|
||||
- Lost key charge
|
||||
- Missing accessory charge
|
||||
- Toll charges
|
||||
- Traffic fines
|
||||
- Administration fees
|
||||
|
||||
All charges must be supported by the rental agreement, system records, or photo evidence.
|
||||
|
||||
## Deposit Handling
|
||||
|
||||
If there are no additional charges:
|
||||
|
||||
- Close the rental.
|
||||
- Release the deposit hold.
|
||||
- Send the final receipt.
|
||||
|
||||
If there are additional charges:
|
||||
|
||||
- Deduct approved charges.
|
||||
- Send an itemized invoice.
|
||||
- Attach evidence if needed.
|
||||
- Release any remaining deposit.
|
||||
|
||||
## Return Checklist
|
||||
|
||||
- [ ] Rental agreement found
|
||||
- [ ] Return time recorded
|
||||
- [ ] Mileage recorded
|
||||
- [ ] Fuel or battery level checked
|
||||
- [ ] Exterior inspected
|
||||
- [ ] Interior inspected
|
||||
- [ ] Pickup photos reviewed
|
||||
- [ ] Return photos taken
|
||||
- [ ] Keys returned
|
||||
- [ ] Accessories returned
|
||||
- [ ] Lost property checked
|
||||
- [ ] Final charges calculated
|
||||
- [ ] Deposit released or adjusted
|
||||
- [ ] Receipt sent
|
||||
- [ ] Vehicle status updated
|
||||
|
||||
---
|
||||
|
||||
# 6. Post-Return Processing
|
||||
|
||||
## Lost Property Check
|
||||
|
||||
Staff must check:
|
||||
|
||||
- Glove box
|
||||
- Center console
|
||||
- Door pockets
|
||||
- Under seats
|
||||
- Trunk
|
||||
- Seat pockets
|
||||
- Charging cable area
|
||||
|
||||
Any found item must be logged with:
|
||||
|
||||
- Date found
|
||||
- Vehicle plate number
|
||||
- Rental agreement number
|
||||
- Item description
|
||||
- Staff name
|
||||
- Storage location
|
||||
- Customer notification status
|
||||
|
||||
## Cleaning
|
||||
|
||||
After return, assign the vehicle to the correct cleaning level:
|
||||
|
||||
- Light cleaning
|
||||
- Standard cleaning
|
||||
- Deep cleaning
|
||||
- Smoke treatment
|
||||
- Pet hair removal
|
||||
- Stain removal
|
||||
|
||||
If extra cleaning is required, staff must take photos before cleaning.
|
||||
|
||||
## Maintenance Check
|
||||
|
||||
Staff must check for:
|
||||
|
||||
- Dashboard warning lights
|
||||
- Tire pressure issues
|
||||
- Fluid leaks
|
||||
- Brake issues
|
||||
- Unusual sounds
|
||||
- Service due alerts
|
||||
- EV charging issues
|
||||
|
||||
After cleaning and maintenance review, update the vehicle status to one of the following:
|
||||
|
||||
- Available
|
||||
- Needs Cleaning
|
||||
- Needs Maintenance
|
||||
- Damage Review
|
||||
- Blocked
|
||||
|
||||
---
|
||||
|
||||
# 7. Simple Policy Set
|
||||
|
||||
The company must define clear written policies for:
|
||||
|
||||
- Cancellation
|
||||
- No-show
|
||||
- Late pickup
|
||||
- Late return
|
||||
- Fuel return
|
||||
- EV battery return
|
||||
- Mileage limit
|
||||
- Extra mileage
|
||||
- Insurance
|
||||
- Damage
|
||||
- Smoking
|
||||
- Pets
|
||||
- Cleaning
|
||||
- Lost keys
|
||||
- Tolls
|
||||
- Traffic fines
|
||||
- Unauthorized drivers
|
||||
- Accidents
|
||||
- Breakdowns
|
||||
- Deposit release timing
|
||||
|
||||
Each policy should be written in plain language and shown to the customer before pickup.
|
||||
|
||||
---
|
||||
|
||||
# 8. Staff Roles
|
||||
|
||||
## Rental Agent
|
||||
|
||||
The rental agent is responsible for:
|
||||
|
||||
- Processing bookings
|
||||
- Verifying customer documents
|
||||
- Explaining rental terms
|
||||
- Processing payments
|
||||
- Completing pickup inspections
|
||||
- Completing return inspections
|
||||
- Updating vehicle status
|
||||
|
||||
## Fleet Staff
|
||||
|
||||
Fleet staff are responsible for:
|
||||
|
||||
- Cleaning vehicles
|
||||
- Fueling or charging vehicles
|
||||
- Moving vehicles
|
||||
- Checking readiness
|
||||
- Reporting damage or maintenance issues
|
||||
|
||||
## Manager
|
||||
|
||||
The manager is responsible for:
|
||||
|
||||
- Approving exceptions
|
||||
- Handling disputes
|
||||
- Reviewing damage claims
|
||||
- Approving high-risk rentals
|
||||
- Monitoring daily operations
|
||||
- Reviewing overdue vehicles
|
||||
|
||||
---
|
||||
|
||||
# 9. System Requirements
|
||||
|
||||
The rental company should use one central rental system for:
|
||||
|
||||
- Bookings
|
||||
- Customer records
|
||||
- Vehicle records
|
||||
- Availability
|
||||
- Payments
|
||||
- Deposits
|
||||
- Rental agreements
|
||||
- Pickup photos
|
||||
- Return photos
|
||||
- Damage records
|
||||
- Invoices
|
||||
- Vehicle statuses
|
||||
|
||||
## Core System Rule
|
||||
|
||||
Each rental must have:
|
||||
|
||||
- One booking
|
||||
- One customer record
|
||||
- One assigned vehicle
|
||||
- One rental agreement
|
||||
- One final invoice
|
||||
|
||||
If staff need to check several different places to understand one rental, the system is too complicated.
|
||||
|
||||
---
|
||||
|
||||
# 10. Non-Negotiable Controls
|
||||
|
||||
The following controls must never be skipped:
|
||||
|
||||
- Valid driver's license check
|
||||
- Payment before vehicle release
|
||||
- Deposit before vehicle release
|
||||
- Signed rental agreement
|
||||
- Pickup photos
|
||||
- Return photos
|
||||
- Mileage record at pickup and return
|
||||
- Fuel or battery record at pickup and return
|
||||
- Written insurance choice
|
||||
- Written extension approval
|
||||
- Damage evidence before charging customer
|
||||
|
||||
These controls protect the company from avoidable losses and disputes.
|
||||
|
||||
---
|
||||
|
||||
# 11. Standard Operating Flow
|
||||
|
||||
## Before Pickup
|
||||
|
||||
1. Customer books vehicle.
|
||||
2. System confirms availability.
|
||||
3. Staff assigns vehicle.
|
||||
4. Vehicle is cleaned and checked.
|
||||
5. Customer receives pickup reminder.
|
||||
|
||||
## At Pickup
|
||||
|
||||
1. Staff verifies customer.
|
||||
2. Staff verifies license.
|
||||
3. Payment and deposit are processed.
|
||||
4. Vehicle is inspected.
|
||||
5. Agreement is signed.
|
||||
6. Keys are released.
|
||||
7. Vehicle status becomes On Rent.
|
||||
|
||||
## During Rental
|
||||
|
||||
1. Company supports customer if needed.
|
||||
2. Extensions are handled in writing.
|
||||
3. Accidents and breakdowns are recorded.
|
||||
4. Overdue rentals are monitored.
|
||||
|
||||
## At Return
|
||||
|
||||
1. Vehicle is returned.
|
||||
2. Mileage and fuel or battery are recorded.
|
||||
3. Vehicle is inspected.
|
||||
4. Final charges are calculated.
|
||||
5. Deposit is released or adjusted.
|
||||
6. Receipt is sent.
|
||||
7. Vehicle is cleaned and reset.
|
||||
|
||||
---
|
||||
|
||||
# 12. Operating Principle
|
||||
|
||||
The company should design the process so a new employee can follow it using checklists, not memory.
|
||||
|
||||
The process should rely on:
|
||||
|
||||
- Clear vehicle statuses
|
||||
- Short checklists
|
||||
- Photo evidence
|
||||
- Written confirmations
|
||||
- Manager approval for exceptions
|
||||
|
||||
A simple rental process is not a weak process. It is a controlled process with fewer places for mistakes to hide.
|
||||
@@ -0,0 +1,392 @@
|
||||
# Rental Car Pricing Management Plan
|
||||
|
||||
## Objective
|
||||
|
||||
Build a rental car pricing system that allows the business to manage vehicle prices using two pricing methods:
|
||||
|
||||
1. **Manual Fixed Pricing**: The user sets daily and/or weekly prices for each car throughout the year.
|
||||
2. **Automatic Dynamic Pricing**: The user sets minimum and maximum daily prices, and the system automatically adjusts prices based on search activity, demand, availability, seasonality, and booking behavior.
|
||||
|
||||
The system should support both methods so the business can choose between full control and automated optimization.
|
||||
|
||||
---
|
||||
|
||||
## Method 1: Manual Fixed Pricing
|
||||
|
||||
### Description
|
||||
|
||||
Manual fixed pricing allows the user to set exact prices for each vehicle. Prices can be configured by day, week, date range, season, holiday, or special event.
|
||||
|
||||
This method is useful when the business wants predictable pricing and full control.
|
||||
|
||||
### Core Features
|
||||
|
||||
#### 1. Car-Level Pricing
|
||||
|
||||
Each vehicle should have its own pricing settings.
|
||||
|
||||
| Car | Daily Price | Weekly Price |
|
||||
|---|---:|---:|
|
||||
| Toyota Corolla | $45 | $280 |
|
||||
| Hyundai Elantra | $50 | $310 |
|
||||
| Ford Mustang | $95 | $600 |
|
||||
| Chevrolet Suburban | $120 | $750 |
|
||||
|
||||
#### 2. Date-Based Pricing
|
||||
|
||||
The user should be able to set different prices for different date ranges.
|
||||
|
||||
| Date Range | Daily Price | Weekly Price |
|
||||
|---|---:|---:|
|
||||
| Jan 1 - Mar 31 | $45 | $280 |
|
||||
| Apr 1 - Aug 31 | $60 | $380 |
|
||||
| Sep 1 - Dec 15 | $50 | $320 |
|
||||
| Dec 16 - Dec 31 | $75 | $475 |
|
||||
|
||||
#### 3. Seasonal Pricing
|
||||
|
||||
The system should support pricing rules for:
|
||||
|
||||
- Low season
|
||||
- High season
|
||||
- Holidays
|
||||
- Weekends
|
||||
- Special events
|
||||
- Summer travel season
|
||||
- End-of-year demand
|
||||
|
||||
#### 4. Daily and Weekly Price Options
|
||||
|
||||
The user should be able to set:
|
||||
|
||||
- Daily rental price
|
||||
- Weekly rental price
|
||||
- Weekend price
|
||||
- Holiday price
|
||||
- Monthly price, if needed
|
||||
- Discounted long-term rental price
|
||||
|
||||
#### 5. Bulk Price Updates
|
||||
|
||||
The system should allow the user to update many prices at once.
|
||||
|
||||
Examples:
|
||||
|
||||
- Increase all SUV prices by 15% for summer
|
||||
- Set all economy cars to $40/day for February
|
||||
- Apply holiday pricing to all cars from December 20 to January 5
|
||||
|
||||
### Benefits
|
||||
|
||||
Manual pricing is simple, predictable, and gives the user full control.
|
||||
|
||||
### Risks
|
||||
|
||||
Manual pricing can become outdated quickly. If demand rises and prices stay low, the business loses revenue. If demand drops and prices stay high, cars may remain unused.
|
||||
|
||||
---
|
||||
|
||||
## Method 2: Automatic Dynamic Pricing
|
||||
|
||||
### Description
|
||||
|
||||
Automatic dynamic pricing allows the user to set a minimum and maximum daily price for each car. The system then calculates the best price within that range based on demand and business rules.
|
||||
|
||||
The system should never price below the minimum or above the maximum unless the user manually overrides it.
|
||||
|
||||
### Core Features
|
||||
|
||||
#### 1. Minimum and Maximum Price Rules
|
||||
|
||||
Each car should have a minimum and maximum daily price.
|
||||
|
||||
| Car | Minimum Daily Price | Maximum Daily Price |
|
||||
|---|---:|---:|
|
||||
| Toyota Corolla | $35 | $75 |
|
||||
| Hyundai Elantra | $40 | $85 |
|
||||
| Ford Mustang | $75 | $160 |
|
||||
| Chevrolet Suburban | $90 | $220 |
|
||||
|
||||
#### 2. Demand-Based Pricing
|
||||
|
||||
The system should increase prices when demand is high and lower prices when demand is weak.
|
||||
|
||||
Demand signals may include:
|
||||
|
||||
- Number of searches for a car type
|
||||
- Number of views on a specific vehicle
|
||||
- Number of booking attempts
|
||||
- Number of confirmed reservations
|
||||
- Number of available cars
|
||||
- Time remaining before rental date
|
||||
- Canceled bookings
|
||||
- Competitor pricing, if available
|
||||
- Local events or holidays
|
||||
|
||||
#### 3. Availability-Based Pricing
|
||||
|
||||
The system should adjust prices based on available inventory.
|
||||
|
||||
| Availability | Pricing Action |
|
||||
|---|---|
|
||||
| Many cars available | Lower price to increase bookings |
|
||||
| Medium availability | Keep price near normal |
|
||||
| Few cars available | Increase price |
|
||||
| One car left in category | Increase toward maximum price |
|
||||
|
||||
#### 4. Search-Based Pricing
|
||||
|
||||
If many users search for a specific car, category, or date range, the system should treat that as demand.
|
||||
|
||||
Example:
|
||||
|
||||
If many users search for SUVs during July 4 weekend, SUV prices should increase automatically within the allowed price range.
|
||||
|
||||
Searches alone should not trigger aggressive increases. The system should compare searches with booking behavior.
|
||||
|
||||
#### 5. Booking Conversion Logic
|
||||
|
||||
The system should compare searches to actual bookings.
|
||||
|
||||
| Search Activity | Booking Activity | Pricing Action |
|
||||
|---|---|---|
|
||||
| High searches | High bookings | Raise price |
|
||||
| High searches | Low bookings | Hold price or reduce slightly |
|
||||
| Low searches | Low bookings | Reduce price |
|
||||
| Low searches | High bookings | Raise price carefully |
|
||||
|
||||
#### 6. Time-Based Pricing
|
||||
|
||||
The system should adjust prices based on how close the rental date is.
|
||||
|
||||
| Time Before Rental | Pricing Logic |
|
||||
|---|---|
|
||||
| 60+ days before | Keep price moderate |
|
||||
| 30-60 days before | Adjust based on demand |
|
||||
| 7-30 days before | Increase if availability is low |
|
||||
| 1-7 days before | Discount if many cars remain, increase if few cars remain |
|
||||
|
||||
#### 7. Category-Based Pricing
|
||||
|
||||
Automatic pricing should work at both the vehicle level and category level.
|
||||
|
||||
Categories may include:
|
||||
|
||||
- Economy
|
||||
- Compact
|
||||
- Sedan
|
||||
- SUV
|
||||
- Luxury
|
||||
- Sports car
|
||||
- Van
|
||||
- Truck
|
||||
|
||||
If SUV demand is high, SUV prices may increase while economy car prices remain unchanged.
|
||||
|
||||
#### 8. Manual Override
|
||||
|
||||
The user should always be able to override automatic pricing.
|
||||
|
||||
Manual override options should include:
|
||||
|
||||
- Lock price for a specific car
|
||||
- Lock price for a date range
|
||||
- Disable automatic pricing for selected vehicles
|
||||
- Approve price changes manually before they go live
|
||||
- Set maximum daily price movement, such as no more than 10% per day
|
||||
|
||||
#### 9. Pricing Safety Rules
|
||||
|
||||
To prevent bad automated pricing, the system should include safety rules:
|
||||
|
||||
- Do not change prices more than once per day unless needed
|
||||
- Do not increase prices above the user’s maximum price
|
||||
- Do not decrease prices below the user’s minimum price
|
||||
- Do not raise prices aggressively based only on searches
|
||||
- Do not discount high-demand dates too early
|
||||
- Notify the user when prices change significantly
|
||||
- Keep a pricing history for review
|
||||
|
||||
#### 10. Pricing Formula Example
|
||||
|
||||
A simple automatic pricing formula:
|
||||
|
||||
```text
|
||||
Automatic Price = Base Price
|
||||
+ Demand Adjustment
|
||||
+ Availability Adjustment
|
||||
+ Seasonality Adjustment
|
||||
+ Time Adjustment
|
||||
```
|
||||
|
||||
Then the system applies price limits:
|
||||
|
||||
```text
|
||||
If calculated price < minimum price:
|
||||
final price = minimum price
|
||||
|
||||
If calculated price > maximum price:
|
||||
final price = maximum price
|
||||
|
||||
Otherwise:
|
||||
final price = calculated price
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
| Item | Amount |
|
||||
|---|---:|
|
||||
| Base price | $60 |
|
||||
| Demand adjustment | +$10 |
|
||||
| Availability adjustment | +$15 |
|
||||
| Holiday adjustment | +$20 |
|
||||
| Time adjustment | +$5 |
|
||||
| Calculated price | $110 |
|
||||
|
||||
If the user’s maximum price is $100, the final price should be **$100**.
|
||||
|
||||
---
|
||||
|
||||
## Recommended System Design
|
||||
|
||||
### Pricing Dashboard
|
||||
|
||||
The pricing dashboard should allow the user to:
|
||||
|
||||
- View all cars
|
||||
- See current prices
|
||||
- Set manual prices
|
||||
- Set minimum and maximum prices
|
||||
- Turn automatic pricing on or off
|
||||
- View pricing history
|
||||
- Review demand trends
|
||||
- Approve or reject suggested price changes
|
||||
|
||||
### Car Pricing Page
|
||||
|
||||
Each car should have a pricing page with:
|
||||
|
||||
- Default daily price
|
||||
- Default weekly price
|
||||
- Minimum daily price
|
||||
- Maximum daily price
|
||||
- Seasonal price rules
|
||||
- Manual pricing calendar
|
||||
- Automatic pricing status
|
||||
- Price change history
|
||||
|
||||
### Calendar Pricing View
|
||||
|
||||
The system should include a calendar view where users can see and edit prices by date.
|
||||
|
||||
| Date | Price | Pricing Type |
|
||||
|---|---:|---|
|
||||
| June 1 | $55 | Manual |
|
||||
| June 2 | $58 | Automatic |
|
||||
| June 3 | $60 | Automatic |
|
||||
| July 4 | $95 | Holiday Rule |
|
||||
|
||||
### Reports and Analytics
|
||||
|
||||
The system should generate reports showing:
|
||||
|
||||
- Revenue per car
|
||||
- Revenue per category
|
||||
- Occupancy rate
|
||||
- Average daily rate
|
||||
- Search demand
|
||||
- Booking conversion rate
|
||||
- Lost bookings
|
||||
- Price change history
|
||||
- Best-performing price ranges
|
||||
|
||||
---
|
||||
|
||||
## Suggested Pricing Workflow
|
||||
|
||||
### Step 1: Add Cars
|
||||
|
||||
The user adds each car to the system with details such as:
|
||||
|
||||
- Make
|
||||
- Model
|
||||
- Year
|
||||
- Category
|
||||
- Location
|
||||
- Availability
|
||||
- Default daily price
|
||||
- Default weekly price
|
||||
|
||||
### Step 2: Choose Pricing Method
|
||||
|
||||
For each car, the user chooses:
|
||||
|
||||
- Manual fixed pricing
|
||||
- Automatic dynamic pricing
|
||||
|
||||
### Step 3: Set Pricing Rules
|
||||
|
||||
For manual pricing, the user sets exact daily and weekly prices.
|
||||
|
||||
For automatic pricing, the user sets:
|
||||
|
||||
- Minimum daily price
|
||||
- Maximum daily price
|
||||
- Base price
|
||||
- Weekly discount rules
|
||||
- Seasonal rules
|
||||
- Safety limits
|
||||
|
||||
### Step 4: Monitor Demand
|
||||
|
||||
The system tracks:
|
||||
|
||||
- Searches
|
||||
- Views
|
||||
- Bookings
|
||||
- Cancellations
|
||||
- Availability
|
||||
- Date demand
|
||||
- Category demand
|
||||
|
||||
### Step 5: Adjust Prices
|
||||
|
||||
Manual prices remain fixed unless the user changes them.
|
||||
|
||||
Automatic prices adjust based on system rules.
|
||||
|
||||
### Step 6: Review Performance
|
||||
|
||||
The user reviews pricing performance and updates rules when needed.
|
||||
|
||||
---
|
||||
|
||||
## Best Practice Recommendation
|
||||
|
||||
The strongest approach is to support both pricing methods at the same time.
|
||||
|
||||
Some vehicles should use manual pricing, especially rare, luxury, sports, or specialty cars where strict control matters.
|
||||
|
||||
Other vehicles should use automatic pricing, especially common vehicles where demand changes frequently.
|
||||
|
||||
| Vehicle Type | Best Pricing Method |
|
||||
|---|---|
|
||||
| Economy cars | Automatic pricing |
|
||||
| Sedans | Automatic pricing |
|
||||
| SUVs | Automatic pricing |
|
||||
| Luxury cars | Manual or automatic with tight limits |
|
||||
| Sports cars | Manual pricing |
|
||||
| Vans | Automatic pricing with seasonal rules |
|
||||
| Specialty cars | Manual pricing |
|
||||
|
||||
---
|
||||
|
||||
## Final Recommendation
|
||||
|
||||
The rental car pricing system should include both manual and automatic pricing.
|
||||
|
||||
Manual pricing gives the user full control over daily and weekly rates throughout the year.
|
||||
|
||||
Automatic pricing allows the user to set minimum and maximum limits while the system adjusts prices based on demand, searches, bookings, availability, seasonality, and time before rental.
|
||||
|
||||
The best system is a hybrid model: the user controls the boundaries, and the system optimizes prices inside those boundaries.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
@@ -1,579 +0,0 @@
|
||||
# School Tuition Payment Management Plan
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
The school needs a clear tuition management system that calculates tuition, tracks payments, manages refunds, records remaining balances, and handles extra charges and event charges.
|
||||
|
||||
The existing fee calculation logic should be preserved where valid, but it should be expanded and corrected so the system can handle complete student billing, not only basic tuition and refunds.
|
||||
|
||||
## 2. Current Tuition Logic
|
||||
|
||||
The current tuition service calculates tuition using three main fee categories:
|
||||
|
||||
- First regular student fee
|
||||
- Additional regular student fee
|
||||
- Youth student fee
|
||||
|
||||
Students are sorted by grade before tuition is calculated.
|
||||
|
||||
### Grade Rules
|
||||
|
||||
- Kindergarten is treated as grade level 0.
|
||||
- Grades 1 through 9 are treated as regular students.
|
||||
- Grades above 9 are treated as youth students.
|
||||
- Grade “Y” is treated as youth.
|
||||
- Unknown or malformed grades are treated as invalid or fallback grade level.
|
||||
|
||||
### Tuition Fee Rules
|
||||
|
||||
For students in grades K through 9:
|
||||
|
||||
- The first regular student is charged the first student fee.
|
||||
- Each additional regular student is charged the second student fee.
|
||||
|
||||
For students above grade 9 or youth category:
|
||||
|
||||
- Each youth student is charged the youth fee.
|
||||
|
||||
### Tuition Formula
|
||||
|
||||
```text
|
||||
Total Tuition = Regular Student Fees + Youth Student Fees
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
First regular student fee: $350
|
||||
Second regular student fee: $200
|
||||
Youth fee: $180
|
||||
```
|
||||
|
||||
Family has:
|
||||
|
||||
- Student 1 in Grade 2
|
||||
- Student 2 in Grade 5
|
||||
- Student 3 in Youth
|
||||
|
||||
```text
|
||||
Total Tuition = $350 + $200 + $180 = $730
|
||||
```
|
||||
|
||||
## 3. Required Tuition Calculation Improvements
|
||||
|
||||
The tuition calculation should return a detailed breakdown, not only a single total.
|
||||
|
||||
Each student billing record should include:
|
||||
|
||||
- Student ID
|
||||
- Student name
|
||||
- Grade
|
||||
- Grade level
|
||||
- Enrollment status
|
||||
- Admission status
|
||||
- Fee category
|
||||
- Tuition fee assigned
|
||||
- Discount applied
|
||||
- Extra charges
|
||||
- Event charges
|
||||
- Payments applied
|
||||
- Refund amount, if any
|
||||
- Remaining balance
|
||||
|
||||
The system should generate both:
|
||||
|
||||
1. Family-level total balance
|
||||
2. Student-level billing breakdown
|
||||
|
||||
This prevents confusion when a parent has multiple children enrolled.
|
||||
|
||||
## 4. Tuition Calculation Process
|
||||
|
||||
The system should calculate tuition using the following steps:
|
||||
|
||||
1. Retrieve school year configuration.
|
||||
2. Retrieve fee configuration:
|
||||
- First student fee
|
||||
- Second student fee
|
||||
- Youth fee
|
||||
- Refund deadline
|
||||
- Weeks of study
|
||||
- Last school day
|
||||
3. Load all students connected to the parent or family account.
|
||||
4. Fetch each student’s grade or class section name.
|
||||
5. Normalize grade names.
|
||||
6. Sort students by grade level.
|
||||
7. Assign tuition fee to each eligible student.
|
||||
8. Apply discounts or scholarships.
|
||||
9. Add required fees.
|
||||
10. Add extra charges.
|
||||
11. Add event charges.
|
||||
12. Subtract payments and credits.
|
||||
13. Calculate remaining balance.
|
||||
|
||||
## 5. Student Eligibility for Tuition
|
||||
|
||||
Tuition should only be calculated for students who meet the billing criteria.
|
||||
|
||||
### Billable Students
|
||||
|
||||
A student should be billable if:
|
||||
|
||||
- Enrollment status is enrolled or payment pending
|
||||
- Admission status is accepted
|
||||
|
||||
### Withdrawn Students
|
||||
|
||||
A withdrawn student should not continue to receive new tuition charges unless the school policy requires partial tuition.
|
||||
|
||||
Withdrawn statuses may include:
|
||||
|
||||
- Withdrawn
|
||||
- Refund pending
|
||||
- Withdraw under review
|
||||
|
||||
Withdrawn students should be included in refund calculations when applicable.
|
||||
|
||||
## 6. Remaining Balance Calculation
|
||||
|
||||
The system should calculate the remaining balance at the family level and student level.
|
||||
|
||||
### Formula
|
||||
|
||||
```text
|
||||
Remaining Balance = Total Charges - Total Payments - Credits - Refunds Applied to Account
|
||||
```
|
||||
|
||||
Where:
|
||||
|
||||
```text
|
||||
Total Charges = Tuition + Required Fees + Extra Charges + Event Charges + Late Fees
|
||||
```
|
||||
|
||||
Credits may include:
|
||||
|
||||
- Scholarships
|
||||
- Discounts
|
||||
- Financial aid
|
||||
- Manual credits
|
||||
- Canceled event credits
|
||||
- Overpayment credits
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Tuition: $730
|
||||
Extra Charges: $100
|
||||
Event Charges: $50
|
||||
Total Charges: $880
|
||||
|
||||
Payments Made: $500
|
||||
Credits: $80
|
||||
|
||||
Remaining Balance = $880 - $500 - $80 = $300
|
||||
```
|
||||
|
||||
## 7. Refund Calculation
|
||||
|
||||
The existing refund calculation should be corrected and formalized.
|
||||
|
||||
### Refund Eligibility
|
||||
|
||||
A student may be eligible for a refund if:
|
||||
|
||||
- The student has a withdrawn-related enrollment status
|
||||
- A valid withdrawal date exists
|
||||
- The withdrawal date is on or before the refund deadline
|
||||
- The parent or guardian has made payments
|
||||
- The refund does not exceed total amount paid
|
||||
|
||||
### Refund Formula
|
||||
|
||||
```text
|
||||
Refund Amount = Student Tuition Fee ÷ Weeks of Study × Weeks Remaining
|
||||
```
|
||||
|
||||
The system should calculate weeks remaining from the withdrawal date to the last school day.
|
||||
|
||||
### Refund Cap
|
||||
|
||||
Total refund cannot exceed total amount paid by the parent or guardian for the school year.
|
||||
|
||||
### Refund Example
|
||||
|
||||
```text
|
||||
Student tuition fee: $350
|
||||
Weeks of study: 8
|
||||
Weeks remaining: 4
|
||||
|
||||
Refund Amount = $350 ÷ 8 × 4 = $175
|
||||
```
|
||||
|
||||
## 8. Refund Logic Correction Needed
|
||||
|
||||
The current refund service contains a logic issue.
|
||||
|
||||
The service calculates each student’s fee, but it does not store that fee on the student record before refund calculation.
|
||||
|
||||
The corrected logic should assign the calculated fee to each student:
|
||||
|
||||
```php
|
||||
$student['tuition_fee'] = $studentFee;
|
||||
```
|
||||
|
||||
Then the refund loop should use:
|
||||
|
||||
```php
|
||||
$studentFee = $student['tuition_fee'];
|
||||
```
|
||||
|
||||
Without this correction, refund calculations may be unreliable.
|
||||
|
||||
## 9. Extra Charges
|
||||
|
||||
Extra charges should be stored separately from tuition.
|
||||
|
||||
Examples of extra charges:
|
||||
|
||||
- Books
|
||||
- Uniforms
|
||||
- Transportation
|
||||
- Meals
|
||||
- Technology fee
|
||||
- Replacement ID card
|
||||
- Lost book fee
|
||||
- Damaged equipment fee
|
||||
- Late pickup fee
|
||||
- After-school care
|
||||
- Exam fee
|
||||
- Late payment fee
|
||||
|
||||
Each extra charge should include:
|
||||
|
||||
- Student ID
|
||||
- Parent ID
|
||||
- School year
|
||||
- Charge name
|
||||
- Charge description
|
||||
- Charge amount
|
||||
- Charge date
|
||||
- Due date
|
||||
- Status
|
||||
- Created by
|
||||
- Approval status
|
||||
|
||||
Extra charges should be included in the remaining balance but should not automatically affect tuition calculation.
|
||||
|
||||
## 10. Event Charges
|
||||
|
||||
Event charges should be tracked separately from regular extra charges.
|
||||
|
||||
Examples of event charges:
|
||||
|
||||
- Graduation
|
||||
- Field trip
|
||||
- School camp
|
||||
- Sports event
|
||||
- Competition
|
||||
- Cultural event
|
||||
- Workshop
|
||||
- School ceremony
|
||||
|
||||
Each event charge should include:
|
||||
|
||||
- Event ID
|
||||
- Event name
|
||||
- Student ID
|
||||
- Parent ID
|
||||
- Event date
|
||||
- Participation status
|
||||
- Charge amount
|
||||
- Payment status
|
||||
- Refund policy
|
||||
- Cancellation status
|
||||
|
||||
If an event is canceled, the system should either issue a refund or apply the charge as account credit.
|
||||
|
||||
## 11. Payment Tracking
|
||||
|
||||
Each payment should be recorded against the parent or family account and optionally allocated to specific charges.
|
||||
|
||||
Each payment record should include:
|
||||
|
||||
- Payment ID
|
||||
- Parent ID
|
||||
- Student ID, if applicable
|
||||
- School year
|
||||
- Amount paid
|
||||
- Payment method
|
||||
- Payment date
|
||||
- Receipt number
|
||||
- Payment status
|
||||
- Notes
|
||||
- Created by
|
||||
|
||||
Payment status may include:
|
||||
|
||||
- Pending
|
||||
- Paid
|
||||
- Failed
|
||||
- Refunded
|
||||
- Partially refunded
|
||||
- Canceled
|
||||
|
||||
## 12. Invoice Structure
|
||||
|
||||
The invoice should combine all billing items into one clear document.
|
||||
|
||||
Invoice sections should include:
|
||||
|
||||
1. Tuition charges
|
||||
2. Extra charges
|
||||
3. Event charges
|
||||
4. Discounts and credits
|
||||
5. Payments received
|
||||
6. Refunds
|
||||
7. Remaining balance
|
||||
|
||||
Each invoice should show:
|
||||
|
||||
- Invoice number
|
||||
- Parent or guardian name
|
||||
- Student names
|
||||
- School year
|
||||
- Issue date
|
||||
- Due date
|
||||
- Total charges
|
||||
- Total paid
|
||||
- Remaining balance
|
||||
- Payment instructions
|
||||
|
||||
## 13. Recommended Service Structure
|
||||
|
||||
The billing logic should be split into separate services instead of placing everything inside one fee calculation service.
|
||||
|
||||
### TuitionCalculationService
|
||||
|
||||
Responsible for:
|
||||
|
||||
- Sorting students by grade
|
||||
- Assigning tuition fee
|
||||
- Calculating family tuition total
|
||||
- Returning student-level tuition breakdown
|
||||
|
||||
### RefundCalculationService
|
||||
|
||||
Responsible for:
|
||||
|
||||
- Checking refund eligibility
|
||||
- Calculating proportional refund
|
||||
- Applying refund caps
|
||||
- Returning refund details
|
||||
|
||||
### BalanceCalculationService
|
||||
|
||||
Responsible for:
|
||||
|
||||
- Adding tuition, event charges, and extra charges
|
||||
- Subtracting payments and credits
|
||||
- Returning remaining balance
|
||||
|
||||
### ChargeService
|
||||
|
||||
Responsible for:
|
||||
|
||||
- Creating extra charges
|
||||
- Creating event charges
|
||||
- Canceling charges
|
||||
- Marking charges paid or unpaid
|
||||
|
||||
### InvoiceService
|
||||
|
||||
Responsible for:
|
||||
|
||||
- Creating invoices
|
||||
- Updating invoices
|
||||
- Generating account statements
|
||||
- Showing billing history
|
||||
|
||||
## 14. Required Reports
|
||||
|
||||
The system should generate the following reports:
|
||||
|
||||
- Total tuition billed
|
||||
- Total tuition collected
|
||||
- Remaining balances by parent
|
||||
- Remaining balances by student
|
||||
- Refunds pending
|
||||
- Refunds issued
|
||||
- Extra charges collected
|
||||
- Event charges collected
|
||||
- Overdue balances
|
||||
- Payment plan status
|
||||
- Family account summary
|
||||
|
||||
## 15. Controls and Validation
|
||||
|
||||
The system should prevent incorrect billing through validation rules.
|
||||
|
||||
Required validations:
|
||||
|
||||
- Do not calculate refund without withdrawal date.
|
||||
- Do not refund after refund deadline.
|
||||
- Do not refund more than amount paid.
|
||||
- Do not bill students who are not accepted.
|
||||
- Do not duplicate event charges.
|
||||
- Do not apply payment to the wrong school year.
|
||||
- Do not allow negative remaining balance unless recorded as credit.
|
||||
- Do not allow manual balance edits without audit log.
|
||||
- Do not delete payments; reverse them with adjustment records.
|
||||
|
||||
## 16. Recommended Database Tables
|
||||
|
||||
The system should include or update the following tables.
|
||||
|
||||
### tuition_configurations
|
||||
|
||||
Stores school-year billing settings.
|
||||
|
||||
Fields:
|
||||
|
||||
- school_year
|
||||
- first_student_fee
|
||||
- second_student_fee
|
||||
- youth_fee
|
||||
- refund_deadline
|
||||
- weeks_study
|
||||
- last_school_day
|
||||
|
||||
### student_tuition_records
|
||||
|
||||
Stores calculated tuition per student.
|
||||
|
||||
Fields:
|
||||
|
||||
- student_id
|
||||
- parent_id
|
||||
- school_year
|
||||
- grade
|
||||
- grade_level
|
||||
- fee_category
|
||||
- tuition_fee
|
||||
- discount_amount
|
||||
- final_tuition_amount
|
||||
|
||||
### charges
|
||||
|
||||
Stores extra and event charges.
|
||||
|
||||
Fields:
|
||||
|
||||
- charge_id
|
||||
- student_id
|
||||
- parent_id
|
||||
- school_year
|
||||
- charge_type
|
||||
- charge_name
|
||||
- amount
|
||||
- due_date
|
||||
- status
|
||||
|
||||
### payments
|
||||
|
||||
Stores parent payments.
|
||||
|
||||
Fields:
|
||||
|
||||
- payment_id
|
||||
- parent_id
|
||||
- school_year
|
||||
- amount
|
||||
- method
|
||||
- payment_date
|
||||
- status
|
||||
- receipt_number
|
||||
|
||||
### refunds
|
||||
|
||||
Stores refund records.
|
||||
|
||||
Fields:
|
||||
|
||||
- refund_id
|
||||
- parent_id
|
||||
- student_id
|
||||
- school_year
|
||||
- refund_amount
|
||||
- reason
|
||||
- withdrawal_date
|
||||
- approval_status
|
||||
- refund_status
|
||||
|
||||
### account_ledger
|
||||
|
||||
Stores every financial transaction.
|
||||
|
||||
Fields:
|
||||
|
||||
- ledger_id
|
||||
- parent_id
|
||||
- student_id
|
||||
- school_year
|
||||
- transaction_type
|
||||
- description
|
||||
- debit_amount
|
||||
- credit_amount
|
||||
- balance_after_transaction
|
||||
- created_by
|
||||
- created_at
|
||||
|
||||
## 17. Implementation Priority
|
||||
|
||||
### Phase 1: Fix Current Tuition and Refund Logic
|
||||
|
||||
- Store assigned tuition fee per student.
|
||||
- Fix refund calculation bug.
|
||||
- Return detailed student fee breakdown.
|
||||
- Add logging for each calculation.
|
||||
- Add tests for multiple-student families.
|
||||
|
||||
### Phase 2: Add Balance Calculation
|
||||
|
||||
- Combine tuition, extra charges, event charges, and payments.
|
||||
- Calculate remaining balance.
|
||||
- Add family account summary.
|
||||
|
||||
### Phase 3: Add Charges
|
||||
|
||||
- Create extra charge system.
|
||||
- Create event charge system.
|
||||
- Add charge statuses.
|
||||
- Prevent duplicate charges.
|
||||
|
||||
### Phase 4: Add Invoice and Statement System
|
||||
|
||||
- Generate invoices.
|
||||
- Generate monthly statements.
|
||||
- Show payment history.
|
||||
- Show remaining balance.
|
||||
|
||||
### Phase 5: Add Reports and Audit Logs
|
||||
|
||||
- Add finance reports.
|
||||
- Add refund reports.
|
||||
- Add audit trail.
|
||||
- Add manual adjustment tracking.
|
||||
|
||||
## 18. Final Recommendation
|
||||
|
||||
The existing service should not be thrown away, but it should not remain responsible for the entire billing system.
|
||||
|
||||
The school should keep the grade-based tuition rules, correct the refund bug, and expand the system into a proper billing module with separate tuition, refund, charge, payment, invoice, and balance services.
|
||||
|
||||
The final system should always answer five questions clearly:
|
||||
|
||||
1. What was the student charged?
|
||||
2. Why was the student charged?
|
||||
3. What has the parent paid?
|
||||
4. What has been refunded or credited?
|
||||
5. What balance remains?
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,200 +0,0 @@
|
||||
{
|
||||
"summary": {
|
||||
"assessment_date": "2026-06-04",
|
||||
"reviewer": "OpenAI GPT-5.5 Thinking",
|
||||
"application": "school_api",
|
||||
"framework_observed": "Laravel 12 (not CodeIgniter 4)",
|
||||
"total_findings": 12,
|
||||
"fixed_findings": 9,
|
||||
"open_findings": 3,
|
||||
"overall_risk_level": "Medium after static fixes; High until real secrets are rotated and dynamic testing/dependency audit are completed",
|
||||
"verification_commands": [
|
||||
"php -l changed PHP files: passed",
|
||||
"composer audit: blocked - composer command not found",
|
||||
"php artisan route:list: blocked - missing PHP mbstring extension (mb_split undefined)",
|
||||
"static secret scan: original app/JWT/SMTP/PayPal secrets removed from edited .env/test.sql"
|
||||
]
|
||||
},
|
||||
"findings": [
|
||||
{
|
||||
"id": "SEC-001",
|
||||
"severity": "Critical",
|
||||
"category": "Secrets / Environment",
|
||||
"location": ".env; test.sql",
|
||||
"status": "Fixed",
|
||||
"finding": "The archive shipped active-looking application, JWT, SMTP, database and PayPal credentials, plus production debug/local settings.",
|
||||
"risk": "Credential exposure can enable account takeover, forged JWTs, SMTP abuse, payment-provider abuse, and easier exploitation through debug traces.",
|
||||
"evidence": "Static secret scan found APP_KEY, JWT_SECRET, Gmail app password, DB root/root, and PayPal client/secret values.",
|
||||
"remediation": "Redacted shipped secrets to placeholders, changed production posture, enabled encrypted/secure session settings, and redacted PayPal credentials in test.sql.",
|
||||
"before": "APP_DEBUG=true; MAIL_DEFAULT_PASS contained a Gmail app password; JWT_SECRET contained a live-looking HMAC secret; test.sql included PayPal secrets.",
|
||||
"after": "APP_DEBUG=false; secrets use CHANGE_ME placeholders; SESSION_ENCRYPT=true; SESSION_SECURE_COOKIE=true; PayPal values redacted.",
|
||||
"verification": "Repeated static secret scan. Remaining matches are placeholders or framework config keys; no original Gmail/JWT/PayPal strings remained.",
|
||||
"result": "Fixed in source archive. Real production credentials must still be rotated outside this codebase."
|
||||
},
|
||||
{
|
||||
"id": "SEC-002",
|
||||
"severity": "High",
|
||||
"category": "CSRF / Session State Change",
|
||||
"location": "routes/web.php logout route",
|
||||
"status": "Fixed",
|
||||
"finding": "Browser-session logout used GET, a state-changing action vulnerable to CSRF-style forced logout and link/preload triggering.",
|
||||
"risk": "An attacker could cause authenticated users to lose sessions through an image/link/preload request. Not catastrophic, but still sloppy. The web needed one less foot-gun.",
|
||||
"evidence": "Route::get('logout', ...) was registered under the web middleware group.",
|
||||
"remediation": "Changed logout to POST so Laravel web CSRF protection applies.",
|
||||
"before": "Route::get('logout', [AuthSessionController::class, 'logout'])",
|
||||
"after": "Route::post('logout', [AuthSessionController::class, 'logout'])",
|
||||
"verification": "PHP lint passed for routes/web.php; diff confirms GET was removed for the logout route.",
|
||||
"result": "Fixed. Frontend logout calls must use POST."
|
||||
},
|
||||
{
|
||||
"id": "SEC-003",
|
||||
"severity": "High",
|
||||
"category": "Brute Force / Abuse Controls",
|
||||
"location": "routes/api.php public auth, registration, invite, contact routes",
|
||||
"status": "Fixed",
|
||||
"finding": "Several public mutation endpoints had no route-level throttle: login/register aliases, captcha, contact, and authorized-user password setup.",
|
||||
"risk": "Attackers could automate credential stuffing, registration abuse, contact spam, and invite/password token guessing at higher volume than necessary.",
|
||||
"evidence": "Public POST/GET routes were registered without throttle middleware.",
|
||||
"remediation": "Added route-level throttles: login 10/min, register 5/min, captcha 30/min, invite GET 20/min, password setup POST 10/min, contact 5/min.",
|
||||
"before": "Route::post('login', ...); Route::post('register', ...); Route::post('contact', ...);",
|
||||
"after": "Routes now include middleware('throttle:...') on public auth/contact/invite mutation paths.",
|
||||
"verification": "PHP lint passed for routes/api.php; route snippets confirm throttle middleware is attached.",
|
||||
"result": "Fixed statically. Live rate-limit behavior still needs HTTP retesting after deployment."
|
||||
},
|
||||
{
|
||||
"id": "SEC-004",
|
||||
"severity": "High",
|
||||
"category": "File Upload Security",
|
||||
"location": "app/Services/Events/EventManagementService.php",
|
||||
"status": "Fixed",
|
||||
"finding": "Event flyer upload moved files into public storage without explicit MIME allowlist or file-size enforcement in the service.",
|
||||
"risk": "Untrusted files could be stored in a web-accessible directory. Extension games are ancient, stupid, and still work when services trust uploads too much.",
|
||||
"evidence": "storeFlyer() used hashName() and move() without validating MIME or size inside the service.",
|
||||
"remediation": "Added service-level MIME allowlist for JPEG, PNG, WEBP, PDF; max 5MB; cryptographically random server-side filename using the validated MIME extension.",
|
||||
"before": "$name = $file->hashName(); $file->move($dir, $name);",
|
||||
"after": "MIME map validation, size check, random_bytes filename, controlled extension.",
|
||||
"verification": "PHP lint passed for EventManagementService.php; static diff confirms validation was added before move().",
|
||||
"result": "Fixed."
|
||||
},
|
||||
{
|
||||
"id": "SEC-005",
|
||||
"severity": "High",
|
||||
"category": "File Upload Security",
|
||||
"location": "app/Services/Expenses/ExpenseReceiptService.php",
|
||||
"status": "Fixed",
|
||||
"finding": "Receipt upload stored files without service-level validation of validity, MIME type, or size.",
|
||||
"risk": "Malformed or unexpected files could be accepted and later served/downloaded, increasing malware and content-sniffing risk.",
|
||||
"evidence": "storeReceipt() directly called $file->store('receipts') and returned basename.",
|
||||
"remediation": "Added isValid(), MIME allowlist, max 5MB, random server-side filename, and controlled extension.",
|
||||
"before": "$stored = $file->store('receipts');",
|
||||
"after": "Validated MIME and size, then storeAs() with bin2hex(random_bytes(16)).",
|
||||
"verification": "PHP lint passed for ExpenseReceiptService.php; static diff confirms checks and randomized naming.",
|
||||
"result": "Fixed."
|
||||
},
|
||||
{
|
||||
"id": "SEC-006",
|
||||
"severity": "Medium",
|
||||
"category": "File Upload Security",
|
||||
"location": "app/Services/BroadcastEmail/BroadcastEmailImageService.php",
|
||||
"status": "Fixed",
|
||||
"finding": "Broadcast email image upload used uniqid() for public filenames and lacked an explicit isValid() check.",
|
||||
"risk": "uniqid() is predictable enough to make public file discovery easier. Tiny risk, but still a classic example of software pretending timestamps are secrets.",
|
||||
"evidence": "$newName = uniqid('em_', true) . '.' . $ext; no isValid() guard.",
|
||||
"remediation": "Added isValid() handling and replaced uniqid() with bin2hex(random_bytes(16)).",
|
||||
"before": "uniqid('em_', true)",
|
||||
"after": "'em_' . bin2hex(random_bytes(16))",
|
||||
"verification": "PHP lint passed for BroadcastEmailImageService.php; diff confirms change.",
|
||||
"result": "Fixed."
|
||||
},
|
||||
{
|
||||
"id": "SEC-007",
|
||||
"severity": "Medium",
|
||||
"category": "File Upload Security",
|
||||
"location": "PrintRequestsController; PrintRequestsPortalService",
|
||||
"status": "Fixed",
|
||||
"finding": "Print request uploads had controller extension/MIME validation but service naming still trusted the client original extension.",
|
||||
"risk": "Client-controlled extensions can create misleading stored files and weaken downstream serving assumptions.",
|
||||
"evidence": "Portal service used getClientOriginalExtension() to build stored filenames.",
|
||||
"remediation": "Added safeExtensionForUpload() that maps detected MIME to controlled extensions; added mimetypes rules to controller validation.",
|
||||
"before": "$ext = $file->getClientOriginalExtension();",
|
||||
"after": "$ext = $this->safeExtensionForUpload($file); plus mimetypes validation.",
|
||||
"verification": "PHP lint passed for controller and service; diff confirms service no longer trusts client extensions.",
|
||||
"result": "Fixed."
|
||||
},
|
||||
{
|
||||
"id": "SEC-008",
|
||||
"severity": "Medium",
|
||||
"category": "HTTP Security Headers / Content Sniffing",
|
||||
"location": "bootstrap/app.php; app/Http/Middleware/SecurityHeaders.php; FileServeService.php",
|
||||
"status": "Fixed",
|
||||
"finding": "The app lacked a global security header middleware and some uploaded/private file responses were cacheable as public unless nosniff was manually requested.",
|
||||
"risk": "Missing headers increases clickjacking/content-sniffing exposure. Public caching of private uploaded files can leak sensitive school documents through shared caches.",
|
||||
"evidence": "No global header middleware; FileServeService used Cache-Control public, max-age=86400 and conditional nosniff.",
|
||||
"remediation": "Added global SecurityHeaders middleware; added nosniff, frame denial, referrer policy, permissions policy, HSTS over HTTPS; changed file serving to private max-age=300 and nosniff always.",
|
||||
"before": "Cache-Control: public, max-age=86400; conditional X-Content-Type-Options.",
|
||||
"after": "Cache-Control: private, max-age=300; X-Content-Type-Options: nosniff; global headers middleware.",
|
||||
"verification": "PHP lint passed for middleware, bootstrap, and FileServeService; static diff confirms middleware registration.",
|
||||
"result": "Fixed statically. Header behavior should be confirmed by HTTP response tests in deployed environment."
|
||||
},
|
||||
{
|
||||
"id": "SEC-009",
|
||||
"severity": "Medium",
|
||||
"category": "SQL Injection Hardening",
|
||||
"location": "app/Models/User.php",
|
||||
"status": "Fixed",
|
||||
"finding": "A school_year value was manually quoted and interpolated into a raw EXISTS SQL string.",
|
||||
"risk": "Manual quoting is fragile. It was not obviously exploitable because PDO quote was used, but parameterized query builder logic is safer and auditable.",
|
||||
"evidence": "$escYear = DB::getPdo()->quote($schoolYear); ... tc.school_year = {$escYear}",
|
||||
"remediation": "Replaced raw interpolation with whereExists(), whereColumn(), and bound where('tc.school_year', $schoolYear).",
|
||||
"before": "tc.school_year = {$escYear}",
|
||||
"after": "->where('tc.school_year', $schoolYear)",
|
||||
"verification": "PHP lint passed for User.php; static diff confirms interpolation removed.",
|
||||
"result": "Fixed."
|
||||
},
|
||||
{
|
||||
"id": "SEC-010",
|
||||
"severity": "Medium",
|
||||
"category": "Dependency Audit",
|
||||
"location": "composer.lock / environment",
|
||||
"status": "Open",
|
||||
"finding": "Dependency audit could not be completed in this sandbox because composer is not installed.",
|
||||
"risk": "Known vulnerable packages may remain. Laravel, JWT, PDF, and mail libraries should be audited before deployment because humans keep shipping libraries faster than they patch them.",
|
||||
"evidence": "composer audit returned: bash: composer: command not found.",
|
||||
"remediation": "Not fixed here. Run composer audit in CI and fail builds on high/critical advisories.",
|
||||
"before": "No verified dependency advisory baseline from this run.",
|
||||
"after": "Open remediation item documented.",
|
||||
"verification": "Attempted composer audit; blocked by missing composer binary.",
|
||||
"result": "Open."
|
||||
},
|
||||
{
|
||||
"id": "SEC-011",
|
||||
"severity": "Medium",
|
||||
"category": "Dynamic Security Testing",
|
||||
"location": "Running application / OWASP ZAP",
|
||||
"status": "Open",
|
||||
"finding": "OWASP ZAP and manual HTTP abuse testing were not executed because no running target with required PHP extensions/database was available in the sandbox.",
|
||||
"risk": "Static review can miss runtime authorization, CORS, CSRF token, redirect, and response-header issues.",
|
||||
"evidence": "php artisan route:list failed because the PHP runtime lacks mbstring: undefined function mb_split().",
|
||||
"remediation": "Install required PHP extensions, configure a test database, boot the app, then run authenticated and unauthenticated ZAP baseline plus manual endpoint abuse tests.",
|
||||
"before": "No dynamic scan baseline.",
|
||||
"after": "Open remediation item documented with blocker.",
|
||||
"verification": "Artisan route listing attempted; blocked by missing mbstring extension.",
|
||||
"result": "Open."
|
||||
},
|
||||
{
|
||||
"id": "SEC-012",
|
||||
"severity": "Low",
|
||||
"category": "Framework Scope Accuracy",
|
||||
"location": "security-verification-plan.md",
|
||||
"status": "Open",
|
||||
"finding": "The uploaded plan says CodeIgniter 4, but the application is Laravel 12.",
|
||||
"risk": "Wrong-framework checklists miss framework-specific controls. This is how audits become theater with better fonts.",
|
||||
"evidence": "composer.json name/type and dependencies identify laravel/framework ^12.0; routes, middleware, FormRequests, and artisan confirm Laravel structure.",
|
||||
"remediation": "Documented scope mismatch. Future audits should use a Laravel-specific checklist.",
|
||||
"before": "CodeIgniter 4 plan wording.",
|
||||
"after": "This report applies the plan categories to Laravel controls.",
|
||||
"verification": "Static framework identification completed from composer.json and project structure.",
|
||||
"result": "Open documentation correction."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
# Security Fix Report - school_api
|
||||
|
||||
## Executive Summary
|
||||
|
||||
- **Assessment Date:** 2026-06-04
|
||||
- **Reviewer:** OpenAI GPT-5.5 Thinking
|
||||
- **Application:** school_api
|
||||
- **Framework Observed:** Laravel 12 (not CodeIgniter 4)
|
||||
- **Total Findings:** 12
|
||||
- **Fixed Findings:** 9
|
||||
- **Open Findings:** 3
|
||||
- **Overall Risk Level:** Medium after static fixes; High until real secrets are rotated and dynamic testing/dependency audit are completed
|
||||
|
||||
The uploaded verification plan was applied by category, but the codebase is Laravel 12 rather than CodeIgniter 4. Fixed items are documented with before/after evidence and verification notes. Dynamic testing and dependency advisory scanning remain open because the sandbox lacks Composer and the PHP mbstring extension required to boot Artisan.
|
||||
|
||||
## Vulnerability Inventory
|
||||
|
||||
| ID | Severity | Category | Location | Status |
|
||||
|---|---:|---|---|---|
|
||||
| SEC-001 | Critical | Secrets / Environment | .env; test.sql | Fixed |
|
||||
| SEC-002 | High | CSRF / Session State Change | routes/web.php logout route | Fixed |
|
||||
| SEC-003 | High | Brute Force / Abuse Controls | routes/api.php public auth, registration, invite, contact routes | Fixed |
|
||||
| SEC-004 | High | File Upload Security | app/Services/Events/EventManagementService.php | Fixed |
|
||||
| SEC-005 | High | File Upload Security | app/Services/Expenses/ExpenseReceiptService.php | Fixed |
|
||||
| SEC-006 | Medium | File Upload Security | app/Services/BroadcastEmail/BroadcastEmailImageService.php | Fixed |
|
||||
| SEC-007 | Medium | File Upload Security | PrintRequestsController; PrintRequestsPortalService | Fixed |
|
||||
| SEC-008 | Medium | HTTP Security Headers / Content Sniffing | bootstrap/app.php; app/Http/Middleware/SecurityHeaders.php; FileServeService.php | Fixed |
|
||||
| SEC-009 | Medium | SQL Injection Hardening | app/Models/User.php | Fixed |
|
||||
| SEC-010 | Medium | Dependency Audit | composer.lock / environment | Open |
|
||||
| SEC-011 | Medium | Dynamic Security Testing | Running application / OWASP ZAP | Open |
|
||||
| SEC-012 | Low | Framework Scope Accuracy | security-verification-plan.md | Open |
|
||||
|
||||
## Detailed Remediation Record
|
||||
|
||||
### SEC-001 - Critical - Secrets / Environment
|
||||
|
||||
**Finding:** The archive shipped active-looking application, JWT, SMTP, database and PayPal credentials, plus production debug/local settings.
|
||||
|
||||
**Risk:** Credential exposure can enable account takeover, forged JWTs, SMTP abuse, payment-provider abuse, and easier exploitation through debug traces.
|
||||
|
||||
**Location:** .env; test.sql
|
||||
|
||||
**Evidence:** Static secret scan found APP_KEY, JWT_SECRET, Gmail app password, DB root/root, and PayPal client/secret values.
|
||||
|
||||
**Remediation:** Redacted shipped secrets to placeholders, changed production posture, enabled encrypted/secure session settings, and redacted PayPal credentials in test.sql.
|
||||
|
||||
**Before:** APP_DEBUG=true; MAIL_DEFAULT_PASS contained a Gmail app password; JWT_SECRET contained a live-looking HMAC secret; test.sql included PayPal secrets.
|
||||
|
||||
**After:** APP_DEBUG=false; secrets use CHANGE_ME placeholders; SESSION_ENCRYPT=true; SESSION_SECURE_COOKIE=true; PayPal values redacted.
|
||||
|
||||
**Verification:** Repeated static secret scan. Remaining matches are placeholders or framework config keys; no original Gmail/JWT/PayPal strings remained.
|
||||
|
||||
**Result:** Fixed in source archive. Real production credentials must still be rotated outside this codebase.
|
||||
|
||||
### SEC-002 - High - CSRF / Session State Change
|
||||
|
||||
**Finding:** Browser-session logout used GET, a state-changing action vulnerable to CSRF-style forced logout and link/preload triggering.
|
||||
|
||||
**Risk:** An attacker could cause authenticated users to lose sessions through an image/link/preload request. Not catastrophic, but still sloppy. The web needed one less foot-gun.
|
||||
|
||||
**Location:** routes/web.php logout route
|
||||
|
||||
**Evidence:** Route::get('logout', ...) was registered under the web middleware group.
|
||||
|
||||
**Remediation:** Changed logout to POST so Laravel web CSRF protection applies.
|
||||
|
||||
**Before:** Route::get('logout', [AuthSessionController::class, 'logout'])
|
||||
|
||||
**After:** Route::post('logout', [AuthSessionController::class, 'logout'])
|
||||
|
||||
**Verification:** PHP lint passed for routes/web.php; diff confirms GET was removed for the logout route.
|
||||
|
||||
**Result:** Fixed. Frontend logout calls must use POST.
|
||||
|
||||
### SEC-003 - High - Brute Force / Abuse Controls
|
||||
|
||||
**Finding:** Several public mutation endpoints had no route-level throttle: login/register aliases, captcha, contact, and authorized-user password setup.
|
||||
|
||||
**Risk:** Attackers could automate credential stuffing, registration abuse, contact spam, and invite/password token guessing at higher volume than necessary.
|
||||
|
||||
**Location:** routes/api.php public auth, registration, invite, contact routes
|
||||
|
||||
**Evidence:** Public POST/GET routes were registered without throttle middleware.
|
||||
|
||||
**Remediation:** Added route-level throttles: login 10/min, register 5/min, captcha 30/min, invite GET 20/min, password setup POST 10/min, contact 5/min.
|
||||
|
||||
**Before:** Route::post('login', ...); Route::post('register', ...); Route::post('contact', ...);
|
||||
|
||||
**After:** Routes now include middleware('throttle:...') on public auth/contact/invite mutation paths.
|
||||
|
||||
**Verification:** PHP lint passed for routes/api.php; route snippets confirm throttle middleware is attached.
|
||||
|
||||
**Result:** Fixed statically. Live rate-limit behavior still needs HTTP retesting after deployment.
|
||||
|
||||
### SEC-004 - High - File Upload Security
|
||||
|
||||
**Finding:** Event flyer upload moved files into public storage without explicit MIME allowlist or file-size enforcement in the service.
|
||||
|
||||
**Risk:** Untrusted files could be stored in a web-accessible directory. Extension games are ancient, stupid, and still work when services trust uploads too much.
|
||||
|
||||
**Location:** app/Services/Events/EventManagementService.php
|
||||
|
||||
**Evidence:** storeFlyer() used hashName() and move() without validating MIME or size inside the service.
|
||||
|
||||
**Remediation:** Added service-level MIME allowlist for JPEG, PNG, WEBP, PDF; max 5MB; cryptographically random server-side filename using the validated MIME extension.
|
||||
|
||||
**Before:** $name = $file->hashName(); $file->move($dir, $name);
|
||||
|
||||
**After:** MIME map validation, size check, random_bytes filename, controlled extension.
|
||||
|
||||
**Verification:** PHP lint passed for EventManagementService.php; static diff confirms validation was added before move().
|
||||
|
||||
**Result:** Fixed.
|
||||
|
||||
### SEC-005 - High - File Upload Security
|
||||
|
||||
**Finding:** Receipt upload stored files without service-level validation of validity, MIME type, or size.
|
||||
|
||||
**Risk:** Malformed or unexpected files could be accepted and later served/downloaded, increasing malware and content-sniffing risk.
|
||||
|
||||
**Location:** app/Services/Expenses/ExpenseReceiptService.php
|
||||
|
||||
**Evidence:** storeReceipt() directly called $file->store('receipts') and returned basename.
|
||||
|
||||
**Remediation:** Added isValid(), MIME allowlist, max 5MB, random server-side filename, and controlled extension.
|
||||
|
||||
**Before:** $stored = $file->store('receipts');
|
||||
|
||||
**After:** Validated MIME and size, then storeAs() with bin2hex(random_bytes(16)).
|
||||
|
||||
**Verification:** PHP lint passed for ExpenseReceiptService.php; static diff confirms checks and randomized naming.
|
||||
|
||||
**Result:** Fixed.
|
||||
|
||||
### SEC-006 - Medium - File Upload Security
|
||||
|
||||
**Finding:** Broadcast email image upload used uniqid() for public filenames and lacked an explicit isValid() check.
|
||||
|
||||
**Risk:** uniqid() is predictable enough to make public file discovery easier. Tiny risk, but still a classic example of software pretending timestamps are secrets.
|
||||
|
||||
**Location:** app/Services/BroadcastEmail/BroadcastEmailImageService.php
|
||||
|
||||
**Evidence:** $newName = uniqid('em_', true) . '.' . $ext; no isValid() guard.
|
||||
|
||||
**Remediation:** Added isValid() handling and replaced uniqid() with bin2hex(random_bytes(16)).
|
||||
|
||||
**Before:** uniqid('em_', true)
|
||||
|
||||
**After:** 'em_' . bin2hex(random_bytes(16))
|
||||
|
||||
**Verification:** PHP lint passed for BroadcastEmailImageService.php; diff confirms change.
|
||||
|
||||
**Result:** Fixed.
|
||||
|
||||
### SEC-007 - Medium - File Upload Security
|
||||
|
||||
**Finding:** Print request uploads had controller extension/MIME validation but service naming still trusted the client original extension.
|
||||
|
||||
**Risk:** Client-controlled extensions can create misleading stored files and weaken downstream serving assumptions.
|
||||
|
||||
**Location:** PrintRequestsController; PrintRequestsPortalService
|
||||
|
||||
**Evidence:** Portal service used getClientOriginalExtension() to build stored filenames.
|
||||
|
||||
**Remediation:** Added safeExtensionForUpload() that maps detected MIME to controlled extensions; added mimetypes rules to controller validation.
|
||||
|
||||
**Before:** $ext = $file->getClientOriginalExtension();
|
||||
|
||||
**After:** $ext = $this->safeExtensionForUpload($file); plus mimetypes validation.
|
||||
|
||||
**Verification:** PHP lint passed for controller and service; diff confirms service no longer trusts client extensions.
|
||||
|
||||
**Result:** Fixed.
|
||||
|
||||
### SEC-008 - Medium - HTTP Security Headers / Content Sniffing
|
||||
|
||||
**Finding:** The app lacked a global security header middleware and some uploaded/private file responses were cacheable as public unless nosniff was manually requested.
|
||||
|
||||
**Risk:** Missing headers increases clickjacking/content-sniffing exposure. Public caching of private uploaded files can leak sensitive school documents through shared caches.
|
||||
|
||||
**Location:** bootstrap/app.php; app/Http/Middleware/SecurityHeaders.php; FileServeService.php
|
||||
|
||||
**Evidence:** No global header middleware; FileServeService used Cache-Control public, max-age=86400 and conditional nosniff.
|
||||
|
||||
**Remediation:** Added global SecurityHeaders middleware; added nosniff, frame denial, referrer policy, permissions policy, HSTS over HTTPS; changed file serving to private max-age=300 and nosniff always.
|
||||
|
||||
**Before:** Cache-Control: public, max-age=86400; conditional X-Content-Type-Options.
|
||||
|
||||
**After:** Cache-Control: private, max-age=300; X-Content-Type-Options: nosniff; global headers middleware.
|
||||
|
||||
**Verification:** PHP lint passed for middleware, bootstrap, and FileServeService; static diff confirms middleware registration.
|
||||
|
||||
**Result:** Fixed statically. Header behavior should be confirmed by HTTP response tests in deployed environment.
|
||||
|
||||
### SEC-009 - Medium - SQL Injection Hardening
|
||||
|
||||
**Finding:** A school_year value was manually quoted and interpolated into a raw EXISTS SQL string.
|
||||
|
||||
**Risk:** Manual quoting is fragile. It was not obviously exploitable because PDO quote was used, but parameterized query builder logic is safer and auditable.
|
||||
|
||||
**Location:** app/Models/User.php
|
||||
|
||||
**Evidence:** $escYear = DB::getPdo()->quote($schoolYear); ... tc.school_year = {$escYear}
|
||||
|
||||
**Remediation:** Replaced raw interpolation with whereExists(), whereColumn(), and bound where('tc.school_year', $schoolYear).
|
||||
|
||||
**Before:** tc.school_year = {$escYear}
|
||||
|
||||
**After:** ->where('tc.school_year', $schoolYear)
|
||||
|
||||
**Verification:** PHP lint passed for User.php; static diff confirms interpolation removed.
|
||||
|
||||
**Result:** Fixed.
|
||||
|
||||
### SEC-010 - Medium - Dependency Audit
|
||||
|
||||
**Finding:** Dependency audit could not be completed in this sandbox because composer is not installed.
|
||||
|
||||
**Risk:** Known vulnerable packages may remain. Laravel, JWT, PDF, and mail libraries should be audited before deployment because humans keep shipping libraries faster than they patch them.
|
||||
|
||||
**Location:** composer.lock / environment
|
||||
|
||||
**Evidence:** composer audit returned: bash: composer: command not found.
|
||||
|
||||
**Remediation:** Not fixed here. Run composer audit in CI and fail builds on high/critical advisories.
|
||||
|
||||
**Before:** No verified dependency advisory baseline from this run.
|
||||
|
||||
**After:** Open remediation item documented.
|
||||
|
||||
**Verification:** Attempted composer audit; blocked by missing composer binary.
|
||||
|
||||
**Result:** Open.
|
||||
|
||||
### SEC-011 - Medium - Dynamic Security Testing
|
||||
|
||||
**Finding:** OWASP ZAP and manual HTTP abuse testing were not executed because no running target with required PHP extensions/database was available in the sandbox.
|
||||
|
||||
**Risk:** Static review can miss runtime authorization, CORS, CSRF token, redirect, and response-header issues.
|
||||
|
||||
**Location:** Running application / OWASP ZAP
|
||||
|
||||
**Evidence:** php artisan route:list failed because the PHP runtime lacks mbstring: undefined function mb_split().
|
||||
|
||||
**Remediation:** Install required PHP extensions, configure a test database, boot the app, then run authenticated and unauthenticated ZAP baseline plus manual endpoint abuse tests.
|
||||
|
||||
**Before:** No dynamic scan baseline.
|
||||
|
||||
**After:** Open remediation item documented with blocker.
|
||||
|
||||
**Verification:** Artisan route listing attempted; blocked by missing mbstring extension.
|
||||
|
||||
**Result:** Open.
|
||||
|
||||
### SEC-012 - Low - Framework Scope Accuracy
|
||||
|
||||
**Finding:** The uploaded plan says CodeIgniter 4, but the application is Laravel 12.
|
||||
|
||||
**Risk:** Wrong-framework checklists miss framework-specific controls. This is how audits become theater with better fonts.
|
||||
|
||||
**Location:** security-verification-plan.md
|
||||
|
||||
**Evidence:** composer.json name/type and dependencies identify laravel/framework ^12.0; routes, middleware, FormRequests, and artisan confirm Laravel structure.
|
||||
|
||||
**Remediation:** Documented scope mismatch. Future audits should use a Laravel-specific checklist.
|
||||
|
||||
**Before:** CodeIgniter 4 plan wording.
|
||||
|
||||
**After:** This report applies the plan categories to Laravel controls.
|
||||
|
||||
**Verification:** Static framework identification completed from composer.json and project structure.
|
||||
|
||||
**Result:** Open documentation correction.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
- php -l changed PHP files: passed
|
||||
- composer audit: blocked - composer command not found
|
||||
- php artisan route:list: blocked - missing PHP mbstring extension (mb_split undefined)
|
||||
- static secret scan: original app/JWT/SMTP/PayPal secrets removed from edited .env/test.sql
|
||||
|
||||
## Changed Source Files
|
||||
|
||||
- .env
|
||||
- test.sql
|
||||
- routes/web.php
|
||||
- routes/api.php
|
||||
- bootstrap/app.php
|
||||
- app/Http/Middleware/SecurityHeaders.php
|
||||
- app/Services/Files/FileServeService.php
|
||||
- app/Services/Events/EventManagementService.php
|
||||
- app/Services/Expenses/ExpenseReceiptService.php
|
||||
- app/Services/BroadcastEmail/BroadcastEmailImageService.php
|
||||
- app/Services/PrintRequests/PrintRequestsPortalService.php
|
||||
- app/Http/Controllers/Api/PrintRequests/PrintRequestsController.php
|
||||
- app/Models/User.php
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
- Rotate the exposed Gmail app password, JWT secret, Laravel APP_KEY, database password, and PayPal credentials. Redaction in the repository does not invalidate credentials already leaked in the original ZIP. That part requires action with the providers, not wishful thinking.
|
||||
- Regenerate APP_KEY and JWT_SECRET in each environment. Do not commit .env files with real values.
|
||||
- Run composer audit in CI and install required PHP extensions, including mbstring, before dynamic tests.
|
||||
- Re-test all changed public auth/contact/invite routes for expected 429 behavior and frontend compatibility.
|
||||
@@ -1,85 +0,0 @@
|
||||
# CodeIgniter 4 Security Verification and Remediation Plan
|
||||
|
||||
## Objective
|
||||
|
||||
Perform a comprehensive security assessment of the CodeIgniter 4 application, identify vulnerabilities, implement fixes, verify remediation, and produce a complete audit trail of all findings and corrections.
|
||||
|
||||
## Mandatory Requirement
|
||||
|
||||
Every vulnerability that is fixed must be documented immediately in the remediation report.
|
||||
|
||||
No fix may be marked complete without evidence of verification and retesting.
|
||||
|
||||
## Scope
|
||||
|
||||
- CodeIgniter 4 configuration
|
||||
- Controllers
|
||||
- Models
|
||||
- Views
|
||||
- Routes
|
||||
- Filters
|
||||
- Authentication and Authorization
|
||||
- API endpoints
|
||||
- Session handling
|
||||
- File uploads
|
||||
- Composer dependencies
|
||||
|
||||
## Steps
|
||||
|
||||
1. Environment and Debug Configuration Review
|
||||
2. CSRF Protection Verification
|
||||
3. XSS Protection Review
|
||||
4. SQL Injection Review
|
||||
5. Input Validation Review
|
||||
6. Authentication Security Review
|
||||
7. Authorization Review
|
||||
8. Route and Filter Review
|
||||
9. File Upload Security Review
|
||||
10. Session and Cookie Security Review
|
||||
11. Dependency Audit
|
||||
12. Secret Scanning
|
||||
13. Automated Security Scan (OWASP ZAP)
|
||||
14. Manual Abuse Testing
|
||||
15. Generate Security Fix Report
|
||||
|
||||
## Security Fix Report Requirements
|
||||
|
||||
### Executive Summary
|
||||
- Assessment date
|
||||
- Reviewer
|
||||
- Total findings
|
||||
- Fixed findings
|
||||
- Open findings
|
||||
- Overall risk level
|
||||
|
||||
### Vulnerability Inventory
|
||||
- ID
|
||||
- Severity
|
||||
- Category
|
||||
- Location
|
||||
- Status
|
||||
|
||||
### Detailed Remediation Record
|
||||
- Finding
|
||||
- Risk
|
||||
- Location
|
||||
- Evidence
|
||||
- Remediation
|
||||
- Before/After code
|
||||
- Verification
|
||||
- Result
|
||||
|
||||
### Deliverables
|
||||
- security-verification-plan.md
|
||||
- security-fix-report.md
|
||||
- security-fix-report.pdf
|
||||
- security-findings.json
|
||||
|
||||
## Success Criteria
|
||||
|
||||
An independent reviewer must be able to:
|
||||
- Reproduce findings
|
||||
- Verify fixes
|
||||
- Audit changes
|
||||
- Review remaining risks
|
||||
- Approve or reject deployment based on evidence
|
||||
@@ -1,362 +0,0 @@
|
||||
--- .env
|
||||
--- school_api_orig/school_api/.env 2026-06-04 07:50:20.000000000 +0000
|
||||
+++ school_api_work/school_api/.env 2026-06-04 17:35:04.127545553 +0000
|
||||
@@ -1,9 +1,9 @@
|
||||
APP_NAME=Alrahma_API
|
||||
-APP_ENV=local
|
||||
-APP_KEY=base64:RfIZKqgXC9seghl2Jqs2MgLh1X1Z7APRsnhUK8CgWx8=
|
||||
-APP_DEBUG=true
|
||||
+APP_ENV=production
|
||||
+APP_KEY=base64:CHANGE_ME_GENERATE_WITH_php_artisan_key_generate
|
||||
+APP_DEBUG=false
|
||||
APP_TIMEZONE=America/New_York
|
||||
-APP_URL=http://localhost:8080
|
||||
+APP_URL=https://example.com
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
@@ -22,18 +22,21 @@
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=school_api
|
||||
-DB_USERNAME=root
|
||||
-DB_PASSWORD=root
|
||||
-APP_URL=http://127.0.0.1:8000
|
||||
+DB_USERNAME=school_api_user
|
||||
+DB_PASSWORD=CHANGE_ME_DB_PASSWORD
|
||||
+APP_URL=https://example.com
|
||||
|
||||
# ----------------------------
|
||||
# SESSION
|
||||
# ----------------------------
|
||||
SESSION_DRIVER=file
|
||||
SESSION_LIFETIME=120
|
||||
-SESSION_ENCRYPT=false
|
||||
+SESSION_ENCRYPT=true
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
+SESSION_SECURE_COOKIE=true
|
||||
+SESSION_HTTP_ONLY=true
|
||||
+SESSION_SAME_SITE=strict
|
||||
|
||||
# ----------------------------
|
||||
# CACHE & QUEUE
|
||||
@@ -56,10 +59,10 @@
|
||||
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_USER=CHANGE_ME_SMTP_USER
|
||||
+MAIL_DEFAULT_PASS=CHANGE_ME_SMTP_PASSWORD
|
||||
MAIL_DEFAULT_ENCRYPTION=tls
|
||||
-MAIL_DEFAULT_FROM_EMAIL="alrahma.sunday.school@gmail.com"
|
||||
+MAIL_DEFAULT_FROM_EMAIL="no-reply@example.com"
|
||||
MAIL_DEFAULT_FROM_NAME="Alrahma API"
|
||||
MAIL_DEFAULT_REPLY_TO="alrahma.isgl@gmail.com"
|
||||
MAIL_DEFAULT_REPLY_TO_NAME="Al Rahma Sunday School"
|
||||
@@ -102,7 +105,7 @@
|
||||
# ----------------------------
|
||||
# JWT
|
||||
# ----------------------------
|
||||
-JWT_SECRET=Uj8rGnYcXMgeRc5qCIn9Wn03tYo1pCsBz1Biou8T9zWtaNxBYi3P4NP5vuFiXHmd
|
||||
+JWT_SECRET=CHANGE_ME_GENERATE_WITH_php_artisan_jwt_secret
|
||||
JWT_ALGO=HS256
|
||||
JWT_TTL=60
|
||||
JWT_REFRESH_TTL=20160
|
||||
--- routes/web.php
|
||||
--- school_api_orig/school_api/routes/web.php 2026-05-29 00:28:37.000000000 +0000
|
||||
+++ school_api_work/school_api/routes/web.php 2026-06-04 17:35:04.129001398 +0000
|
||||
@@ -61,7 +61,7 @@
|
||||
Route::middleware('web')->group(function () {
|
||||
Route::get('login', [AuthSessionController::class, 'loginMask'])->name('login');
|
||||
Route::post('user/login', [AuthSessionController::class, 'login'])->name('auth.session.login');
|
||||
- Route::get('logout', [AuthSessionController::class, 'logout'])->name('auth.session.logout');
|
||||
+ Route::post('logout', [AuthSessionController::class, 'logout'])->name('auth.session.logout');
|
||||
Route::get('select-role', [AuthSessionController::class, 'selectRole'])->name('auth.session.select-role');
|
||||
Route::post('set-role', [AuthSessionController::class, 'setRole'])->name('auth.session.set-role');
|
||||
|
||||
--- routes/api.php
|
||||
--- school_api_orig/school_api/routes/api.php 2026-06-04 17:05:37.000000000 +0000
|
||||
+++ school_api_work/school_api/routes/api.php 2026-06-04 17:35:04.130570862 +0000
|
||||
@@ -126,8 +126,8 @@
|
||||
use App\Http\Controllers\Api\System\AccessDeniedController;
|
||||
use App\Http\Controllers\Api\Certificates\CertificateController;
|
||||
// Public auth aliases without the `/v1` prefix.
|
||||
-Route::post('login', [AuthController::class, 'login']);
|
||||
-Route::post('register', [RegisterController::class, 'store']);
|
||||
+Route::post('login', [AuthController::class, 'login'])->middleware('throttle:10,1');
|
||||
+Route::post('register', [RegisterController::class, 'store'])->middleware('throttle:5,1');
|
||||
|
||||
/*
|
||||
| LanguageTool proxy for legacy and current clients.
|
||||
@@ -160,12 +160,15 @@
|
||||
});
|
||||
|
||||
Route::get('confirm_authorized_user', [AuthorizedUserInviteController::class, 'confirm'])
|
||||
+ ->middleware('throttle:20,1')
|
||||
->name('api.invite.confirm');
|
||||
Route::get('set_authorized_user_password/{authorizedUserId}', [AuthorizedUserInviteController::class, 'setPasswordForm'])
|
||||
->whereNumber('authorizedUserId')
|
||||
+ ->middleware('throttle:20,1')
|
||||
->name('api.invite.set-password-form');
|
||||
Route::post('set_authorized_user_password/{authorizedUserId}', [AuthorizedUserInviteController::class, 'savePassword'])
|
||||
->whereNumber('authorizedUserId')
|
||||
+ ->middleware('throttle:10,1')
|
||||
->name('api.invite.set-password');
|
||||
|
||||
/*
|
||||
@@ -184,13 +187,13 @@
|
||||
|
||||
Route::prefix('v1')->group(function () {
|
||||
// Public auth aliases without the `/auth` prefix.
|
||||
- Route::post('login', [AuthController::class, 'login']);
|
||||
- Route::post('register', [RegisterController::class, 'store']);
|
||||
+ Route::post('login', [AuthController::class, 'login'])->middleware('throttle:10,1');
|
||||
+ Route::post('register', [RegisterController::class, 'store'])->middleware('throttle:5,1');
|
||||
|
||||
Route::prefix('auth')->group(function () {
|
||||
- Route::get('register/captcha', [RegisterController::class, 'captcha']);
|
||||
- Route::post('register', [RegisterController::class, 'store']);
|
||||
- Route::post('login', [AuthController::class, 'login']);
|
||||
+ Route::get('register/captcha', [RegisterController::class, 'captcha'])->middleware('throttle:30,1');
|
||||
+ Route::post('register', [RegisterController::class, 'store'])->middleware('throttle:5,1');
|
||||
+ Route::post('login', [AuthController::class, 'login'])->middleware('throttle:10,1');
|
||||
Route::post('refresh', [AuthController::class, 'refresh'])->middleware('jwt.auth');
|
||||
Route::post('logout', [AuthController::class, 'logout'])->middleware('auth:api');
|
||||
Route::get('me', [AuthController::class, 'me'])->middleware('auth:api');
|
||||
@@ -208,7 +211,7 @@
|
||||
Route::post('contact', [PageController::class, 'submitContact']);
|
||||
});
|
||||
|
||||
- Route::post('contact', [SupportContactController::class, 'send']);
|
||||
+ Route::post('contact', [SupportContactController::class, 'send'])->middleware('throttle:5,1');
|
||||
|
||||
/*
|
||||
| Badge scan kiosk (public, throttled). Logs: authenticated staff.
|
||||
--- bootstrap/app.php
|
||||
--- school_api_orig/school_api/bootstrap/app.php 2026-05-29 03:38:30.000000000 +0000
|
||||
+++ school_api_work/school_api/bootstrap/app.php 2026-06-04 17:35:04.128252803 +0000
|
||||
@@ -15,6 +15,8 @@
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
+ $middleware->append(\App\Http\Middleware\SecurityHeaders::class);
|
||||
+
|
||||
$middleware->alias([
|
||||
// JWT auth
|
||||
'jwt.auth' => \PHPOpenSourceSaver\JWTAuth\Http\Middleware\Authenticate::class,
|
||||
--- app/Http/Middleware/SecurityHeaders.php
|
||||
--- app/Services/Files/FileServeService.php
|
||||
--- school_api_orig/school_api/app/Services/Files/FileServeService.php 2026-05-29 00:28:37.000000000 +0000
|
||||
+++ school_api_work/school_api/app/Services/Files/FileServeService.php 2026-06-04 17:35:04.131136361 +0000
|
||||
@@ -39,13 +39,10 @@
|
||||
'Content-Length' => (string) $meta['size'],
|
||||
'ETag' => $meta['etag'],
|
||||
'Last-Modified' => $meta['last_modified'],
|
||||
- 'Cache-Control' => 'public, max-age=86400',
|
||||
+ 'Cache-Control' => 'private, max-age=300',
|
||||
+ 'X-Content-Type-Options' => 'nosniff',
|
||||
];
|
||||
|
||||
- if ($nosniff) {
|
||||
- $headers['X-Content-Type-Options'] = 'nosniff';
|
||||
- }
|
||||
-
|
||||
return response(file_get_contents($meta['path']), 200, $headers);
|
||||
}
|
||||
|
||||
--- app/Services/Events/EventManagementService.php
|
||||
--- school_api_orig/school_api/app/Services/Events/EventManagementService.php 2026-05-29 00:28:37.000000000 +0000
|
||||
+++ school_api_work/school_api/app/Services/Events/EventManagementService.php 2026-06-04 17:35:04.131625457 +0000
|
||||
@@ -103,12 +103,28 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
+ $allowed = [
|
||||
+ 'image/jpeg' => 'jpg',
|
||||
+ 'image/png' => 'png',
|
||||
+ 'image/webp' => 'webp',
|
||||
+ 'application/pdf' => 'pdf',
|
||||
+ ];
|
||||
+
|
||||
+ $mime = strtolower((string) $file->getMimeType());
|
||||
+ if (! isset($allowed[$mime])) {
|
||||
+ throw new \InvalidArgumentException('Unsupported flyer file type.');
|
||||
+ }
|
||||
+
|
||||
+ if ((int) $file->getSize() > 5 * 1024 * 1024) {
|
||||
+ throw new \InvalidArgumentException('Flyer file too large. Max 5MB.');
|
||||
+ }
|
||||
+
|
||||
$dir = public_path('uploads/event_flyers');
|
||||
if (!File::exists($dir)) {
|
||||
File::makeDirectory($dir, 0755, true);
|
||||
}
|
||||
|
||||
- $name = $file->hashName();
|
||||
+ $name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
|
||||
$file->move($dir, $name);
|
||||
|
||||
return 'event_flyers/' . $name;
|
||||
--- app/Services/Expenses/ExpenseReceiptService.php
|
||||
--- school_api_orig/school_api/app/Services/Expenses/ExpenseReceiptService.php 2026-05-29 00:28:37.000000000 +0000
|
||||
+++ school_api_work/school_api/app/Services/Expenses/ExpenseReceiptService.php 2026-06-04 17:35:04.132035612 +0000
|
||||
@@ -14,8 +14,30 @@
|
||||
|
||||
public function storeReceipt(UploadedFile $file): string
|
||||
{
|
||||
- $stored = $file->store('receipts');
|
||||
- return basename($stored);
|
||||
+ if (! $file->isValid()) {
|
||||
+ throw new \InvalidArgumentException('Invalid receipt upload.');
|
||||
+ }
|
||||
+
|
||||
+ $allowed = [
|
||||
+ 'image/jpeg' => 'jpg',
|
||||
+ 'image/png' => 'png',
|
||||
+ 'image/webp' => 'webp',
|
||||
+ 'application/pdf' => 'pdf',
|
||||
+ ];
|
||||
+
|
||||
+ $mime = strtolower((string) $file->getMimeType());
|
||||
+ if (! isset($allowed[$mime])) {
|
||||
+ throw new \InvalidArgumentException('Unsupported receipt file type.');
|
||||
+ }
|
||||
+
|
||||
+ if ((int) $file->getSize() > 5 * 1024 * 1024) {
|
||||
+ throw new \InvalidArgumentException('Receipt file too large. Max 5MB.');
|
||||
+ }
|
||||
+
|
||||
+ $filename = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
|
||||
+ $file->storeAs('receipts', $filename);
|
||||
+
|
||||
+ return $filename;
|
||||
}
|
||||
|
||||
public function receiptUrl(?string $filename): ?string
|
||||
--- app/Services/BroadcastEmail/BroadcastEmailImageService.php
|
||||
--- school_api_orig/school_api/app/Services/BroadcastEmail/BroadcastEmailImageService.php 2026-05-29 00:28:37.000000000 +0000
|
||||
+++ school_api_work/school_api/app/Services/BroadcastEmail/BroadcastEmailImageService.php 2026-06-04 17:35:04.132596226 +0000
|
||||
@@ -17,6 +17,14 @@
|
||||
|
||||
public function store(UploadedFile $file): array
|
||||
{
|
||||
+ if (! $file->isValid()) {
|
||||
+ return [
|
||||
+ 'ok' => false,
|
||||
+ 'status' => 400,
|
||||
+ 'error' => 'Invalid upload',
|
||||
+ ];
|
||||
+ }
|
||||
+
|
||||
$mime = strtolower((string) $file->getMimeType());
|
||||
if (!isset($this->allowedTypes[$mime])) {
|
||||
return [
|
||||
@@ -40,7 +48,7 @@
|
||||
}
|
||||
|
||||
$ext = $this->allowedTypes[$mime];
|
||||
- $newName = uniqid('em_', true) . '.' . $ext;
|
||||
+ $newName = 'em_' . bin2hex(random_bytes(16)) . '.' . $ext;
|
||||
$file->move($targetDir, $newName);
|
||||
|
||||
return [
|
||||
--- app/Services/PrintRequests/PrintRequestsPortalService.php
|
||||
--- school_api_orig/school_api/app/Services/PrintRequests/PrintRequestsPortalService.php 2026-06-04 06:36:56.000000000 +0000
|
||||
+++ school_api_work/school_api/app/Services/PrintRequests/PrintRequestsPortalService.php 2026-06-04 17:35:04.133321294 +0000
|
||||
@@ -387,7 +387,7 @@
|
||||
public function storeTeacherUpload(array $data, UploadedFile $file, int $teacherId): PrintRequest
|
||||
{
|
||||
$this->ensureStorageDirectoryExists();
|
||||
- $ext = $file->getClientOriginalExtension();
|
||||
+ $ext = $this->safeExtensionForUpload($file);
|
||||
$stored = Str::uuid()->toString().($ext !== '' ? '.'.$ext : '');
|
||||
$file->move($this->storageDirectory(), $stored);
|
||||
|
||||
@@ -432,7 +432,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
- $ext = $newFile->getClientOriginalExtension();
|
||||
+ $ext = $this->safeExtensionForUpload($newFile);
|
||||
$stored = Str::uuid()->toString().($ext !== '' ? '.'.$ext : '');
|
||||
$newFile->move($this->storageDirectory(), $stored);
|
||||
$update['file_path'] = $stored;
|
||||
@@ -445,6 +445,29 @@
|
||||
return $request->fresh();
|
||||
}
|
||||
|
||||
+ private function safeExtensionForUpload(UploadedFile $file): string
|
||||
+ {
|
||||
+ $allowed = [
|
||||
+ 'application/pdf' => 'pdf',
|
||||
+ 'image/jpeg' => 'jpg',
|
||||
+ 'image/png' => 'png',
|
||||
+ 'text/plain' => 'txt',
|
||||
+ 'application/msword' => 'doc',
|
||||
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
||||
+ ];
|
||||
+
|
||||
+ $mime = strtolower((string) $file->getMimeType());
|
||||
+ if (! isset($allowed[$mime])) {
|
||||
+ throw new \InvalidArgumentException('Unsupported upload type.');
|
||||
+ }
|
||||
+
|
||||
+ if ((int) $file->getSize() > 5 * 1024 * 1024) {
|
||||
+ throw new \InvalidArgumentException('Uploaded file too large. Max 5MB.');
|
||||
+ }
|
||||
+
|
||||
+ return $allowed[$mime];
|
||||
+ }
|
||||
+
|
||||
public function applyAdminStatus(PrintRequest $request, string $newStatus, int $adminUserId): PrintRequest
|
||||
{
|
||||
$current = (string) $request->status;
|
||||
--- app/Http/Controllers/Api/PrintRequests/PrintRequestsController.php
|
||||
--- school_api_orig/school_api/app/Http/Controllers/Api/PrintRequests/PrintRequestsController.php 2026-05-29 00:28:37.000000000 +0000
|
||||
+++ school_api_work/school_api/app/Http/Controllers/Api/PrintRequests/PrintRequestsController.php 2026-06-04 17:35:04.133827253 +0000
|
||||
@@ -47,7 +47,7 @@
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
- 'file' => ['required', 'file', 'max:5120', 'mimes:pdf,jpg,jpeg,png,doc,docx,txt'],
|
||||
+ 'file' => ['required', 'file', 'max:5120', 'mimes:pdf,jpg,jpeg,png,doc,docx,txt', 'mimetypes:application/pdf,image/jpeg,image/png,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
||||
'page_selection' => ['nullable', 'string', 'regex:/^\s*\d+(?:\s*-\s*\d+)?(?:\s*,\s*\d+(?:\s*-\s*\d+)?)*\s*$/'],
|
||||
'num_copies' => ['required', 'integer', 'min:1'],
|
||||
'required_by' => ['required', 'date'],
|
||||
@@ -97,7 +97,7 @@
|
||||
];
|
||||
|
||||
if ($request->hasFile('file') && $request->file('file')->isValid()) {
|
||||
- $rules['file'] = ['required', 'file', 'max:5120', 'mimes:pdf,jpg,jpeg,png,doc,docx,txt'];
|
||||
+ $rules['file'] = ['required', 'file', 'max:5120', 'mimes:pdf,jpg,jpeg,png,doc,docx,txt', 'mimetypes:application/pdf,image/jpeg,image/png,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), $rules);
|
||||
--- app/Models/User.php
|
||||
--- test.sql
|
||||
--- school_api_orig/school_api/test.sql 2026-05-29 00:27:51.000000000 +0000
|
||||
+++ school_api_work/school_api/test.sql 2026-06-04 17:35:52.923658533 +0000
|
||||
@@ -479,15 +479,15 @@
|
||||
(29, 'total_semester2_days', '13'),
|
||||
(30, 'delete_email_notify', 'support@alrahmaisgl.org, melabidi@alrahmaisgl.org'),
|
||||
(31, 'fall_semester_start', '2025-09-21'),
|
||||
-(32, 'sandbox_secret_paypal', 'Ar0Br3ZhBukQwwdSCxIcSveiu70-Ekt-qqF0nSyYNr3eRAYQKcWTTlhCAKncZiYXmBoPeo4IceV6FjT'),
|
||||
+(32, 'sandbox_secret_paypal', 'CHANGE_ME_SANDBOX_SECRET_PAYPAL'),
|
||||
(33, 'sandbox_verifylink_paypal', 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature'),
|
||||
(34, 'sandbox_tokenurl_paypal', 'https://api-m.sandbox.paypal.com/v1/oauth2/token'),
|
||||
-(35, 'sandbox_clientid_paypal', 'AYsiOekZUvrjgx9C5c554ZeSQ4W6yd0ZX-OHE93_D0fWoa4YXrMmroEeLiAjjdCKkELH8EVZR_yGMLPS'),
|
||||
-(36, 'sandbox_webhookid_paypal', '0EU27043LC060650D'),
|
||||
+(35, 'sandbox_clientid_paypal', 'CHANGE_ME_SANDBOX_CLIENTID_PAYPAL'),
|
||||
+(36, 'sandbox_webhookid_paypal', 'CHANGE_ME_SANDBOX_WEBHOOKID_PAYPAL'),
|
||||
(37, 'production_verifylink_paypal', 'https://api-m.paypal.com/v1/notifications/verify-webhook-signature'),
|
||||
-(38, 'production_webhookid_paypal', '0H5876426L0092829'),
|
||||
-(39, 'production_secret_paypal', 'KKvmE-0cvwx8rEHnEcFaAvn1Rlc3cy07IGIjqGGPR8EqxABbmPodM_JrjRQq2SF2903A4q0DBjjUWai'),
|
||||
-(40, 'production_clientid_paypal', 'AQhCgmypaitgLgretMqNrh_7mEG5fAmwCECxCxevuLDQfsQVwECFyQOU50byzPzFrwd0cVVD8r2lujIB'),
|
||||
+(38, 'production_webhookid_paypal', 'CHANGE_ME_PRODUCTION_WEBHOOKID_PAYPAL'),
|
||||
+(39, 'production_secret_paypal', 'CHANGE_ME_PRODUCTION_SECRET_PAYPAL'),
|
||||
+(40, 'production_clientid_paypal', 'CHANGE_ME_PRODUCTION_CLIENTID_PAYPAL'),
|
||||
(41, 'production_tokenurl_paypal', 'https://api-m.paypal.com/v1/oauth2/token'),
|
||||
(42, 'comment_reviewer', 'administrator, principal'),
|
||||
(43, 'paypal_mode', 'sandbox'),
|
||||
@@ -0,0 +1,14 @@
|
||||
if (Schema::hasColumn('student_class', 'semester')) {
|
||||
$builder->where('sc.semester', $semester);
|
||||
}apps/api/src/index.ts -> apps/api/src/index.ts differ
|
||||
apps/api/src/middleware/requireApiKey.test.ts -> apps/api/src/middleware/requireApiKey.test.ts differ
|
||||
apps/api/src/middleware/requireApiKey.ts -> apps/api/src/middleware/requireApiKey.ts differ
|
||||
apps/api/src/middleware/requireCompanyAuth.test.ts -> apps/api/src/middleware/requireCompanyAuth.test.ts differ
|
||||
apps/api/src/middleware/requireRenterAuth.test.ts -> apps/api/src/middleware/requireRenterAuth.test.ts differ
|
||||
apps/api/src/modules/auth/auth.employee.service.ts -> apps/api/src/modules/auth/auth.employee.service.ts differ
|
||||
apps/api/src/security/tokens.ts -> apps/api/src/security/tokens.ts differ
|
||||
apps/api/src/tests/helpers/fixtures.ts -> apps/api/src/tests/helpers/fixtures.ts differ
|
||||
packages/database/prisma/migrations: 20260609233000_drop_legacy_company_api_key
|
||||
packages/database/prisma/schema.prisma -> packages/database/prisma/schema.prisma differ
|
||||
packages/database/src/index.d.ts -> packages/database/src/index.d.ts differ
|
||||
packages/database/src/index.ts -> packages/database/src/index.ts differ
|
||||
@@ -0,0 +1,342 @@
|
||||
diff -ru '--exclude=node_modules' car_project_original/apps/api/src/index.ts car_project_work/apps/api/src/index.ts
|
||||
--- car_project_original/apps/api/src/index.ts 2026-06-09 19:40:36.000000000 +0000
|
||||
+++ car_project_work/apps/api/src/index.ts 2026-06-09 23:18:30.521017321 +0000
|
||||
@@ -1,11 +1,11 @@
|
||||
import http from 'http'
|
||||
import { Server as SocketIOServer } from 'socket.io'
|
||||
import cron from 'node-cron'
|
||||
-import jwt from 'jsonwebtoken'
|
||||
import { redis } from './lib/redis'
|
||||
import { prisma } from './lib/prisma'
|
||||
import { assertStorageConfiguration } from './lib/storage'
|
||||
import { createApp, corsOrigins } from './app'
|
||||
+import { verifyAnyActorToken } from './security/tokens'
|
||||
import { sendNotification } from './services/notificationService'
|
||||
import {
|
||||
runTrialExpirationJob,
|
||||
@@ -29,7 +29,7 @@
|
||||
const token = socket.handshake.auth?.token as string | undefined
|
||||
if (!token) return next() // unauthenticated connections allowed; they just don't join rooms
|
||||
try {
|
||||
- const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
+ const payload = verifyAnyActorToken(token)
|
||||
;(socket as any).authenticatedUserId = payload.sub
|
||||
next()
|
||||
} catch {
|
||||
diff -ru '--exclude=node_modules' car_project_original/apps/api/src/middleware/requireApiKey.test.ts car_project_work/apps/api/src/middleware/requireApiKey.test.ts
|
||||
--- car_project_original/apps/api/src/middleware/requireApiKey.test.ts 2026-06-09 19:40:36.000000000 +0000
|
||||
+++ car_project_work/apps/api/src/middleware/requireApiKey.test.ts 2026-06-09 23:18:42.795559504 +0000
|
||||
@@ -1,10 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
+import { generateCompanyApiKey } from '../security/apiKeys'
|
||||
|
||||
vi.mock('../lib/prisma', () => ({
|
||||
prisma: {
|
||||
- company: {
|
||||
+ companyApiKey: {
|
||||
findUnique: vi.fn(),
|
||||
+ update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
@@ -42,19 +44,18 @@
|
||||
message: 'API key required in x-api-key header',
|
||||
statusCode: 401,
|
||||
})
|
||||
- expect(prisma.company.findUnique).not.toHaveBeenCalled()
|
||||
+ expect((prisma as any).companyApiKey.findUnique).not.toHaveBeenCalled()
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
- it('rejects unknown API keys', async () => {
|
||||
- vi.mocked(prisma.company.findUnique).mockResolvedValue(null)
|
||||
+ it('rejects malformed API keys before database lookup', async () => {
|
||||
const req = { headers: { 'x-api-key': 'bad-key' } } as unknown as Request
|
||||
const res = createResponseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireApiKey(req, res, next)
|
||||
|
||||
- expect(prisma.company.findUnique).toHaveBeenCalledWith({ where: { apiKey: 'bad-key' } })
|
||||
+ expect((prisma as any).companyApiKey.findUnique).not.toHaveBeenCalled()
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'invalid_api_key',
|
||||
@@ -64,15 +65,91 @@
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
- it('attaches company context and calls next for valid API keys', async () => {
|
||||
- const company = { id: 'company_1', apiKey: 'valid-key', name: 'Atlas Cars' }
|
||||
- vi.mocked(prisma.company.findUnique).mockResolvedValue(company as any)
|
||||
- const req = { headers: { 'x-api-key': 'valid-key' } } as unknown as Request
|
||||
+ it('rejects unknown API key prefixes', async () => {
|
||||
+ const generated = generateCompanyApiKey()
|
||||
+ vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue(null)
|
||||
+ const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
|
||||
const res = createResponseStub()
|
||||
const next = vi.fn() as NextFunction
|
||||
|
||||
await requireApiKey(req, res, next)
|
||||
|
||||
+ expect((prisma as any).companyApiKey.findUnique).toHaveBeenCalledWith({
|
||||
+ where: { prefix: generated.prefix },
|
||||
+ include: { company: true },
|
||||
+ })
|
||||
+ expect(res.status).toHaveBeenCalledWith(401)
|
||||
+ expect(res.json).toHaveBeenCalledWith({
|
||||
+ error: 'invalid_api_key',
|
||||
+ message: 'Invalid API key',
|
||||
+ statusCode: 401,
|
||||
+ })
|
||||
+ expect(next).not.toHaveBeenCalled()
|
||||
+ })
|
||||
+
|
||||
+ it('rejects revoked API keys', async () => {
|
||||
+ const generated = generateCompanyApiKey()
|
||||
+ vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
|
||||
+ id: 'key_1',
|
||||
+ companyId: 'company_1',
|
||||
+ prefix: generated.prefix,
|
||||
+ keyHash: generated.keyHash,
|
||||
+ revokedAt: new Date('2026-06-01T00:00:00.000Z'),
|
||||
+ company: { id: 'company_1', name: 'Atlas Cars' },
|
||||
+ })
|
||||
+ const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
|
||||
+ const res = createResponseStub()
|
||||
+ const next = vi.fn() as NextFunction
|
||||
+
|
||||
+ await requireApiKey(req, res, next)
|
||||
+
|
||||
+ expect(res.status).toHaveBeenCalledWith(401)
|
||||
+ expect(next).not.toHaveBeenCalled()
|
||||
+ })
|
||||
+
|
||||
+ it('rejects API keys whose secret does not match the stored hash', async () => {
|
||||
+ const generated = generateCompanyApiKey()
|
||||
+ const other = generateCompanyApiKey()
|
||||
+ vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
|
||||
+ id: 'key_1',
|
||||
+ companyId: 'company_1',
|
||||
+ prefix: generated.prefix,
|
||||
+ keyHash: other.keyHash,
|
||||
+ revokedAt: null,
|
||||
+ company: { id: 'company_1', name: 'Atlas Cars' },
|
||||
+ })
|
||||
+ const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
|
||||
+ const res = createResponseStub()
|
||||
+ const next = vi.fn() as NextFunction
|
||||
+
|
||||
+ await requireApiKey(req, res, next)
|
||||
+
|
||||
+ expect(res.status).toHaveBeenCalledWith(401)
|
||||
+ expect(next).not.toHaveBeenCalled()
|
||||
+ })
|
||||
+
|
||||
+ it('attaches company context, updates lastUsedAt, and calls next for valid API keys', async () => {
|
||||
+ const generated = generateCompanyApiKey()
|
||||
+ const company = { id: 'company_1', name: 'Atlas Cars' }
|
||||
+ vi.mocked((prisma as any).companyApiKey.findUnique).mockResolvedValue({
|
||||
+ id: 'key_1',
|
||||
+ companyId: company.id,
|
||||
+ prefix: generated.prefix,
|
||||
+ keyHash: generated.keyHash,
|
||||
+ revokedAt: null,
|
||||
+ company,
|
||||
+ })
|
||||
+ vi.mocked((prisma as any).companyApiKey.update).mockResolvedValue({})
|
||||
+ const req = { headers: { 'x-api-key': generated.rawKey } } as unknown as Request
|
||||
+ const res = createResponseStub()
|
||||
+ const next = vi.fn() as NextFunction
|
||||
+
|
||||
+ await requireApiKey(req, res, next)
|
||||
+
|
||||
+ expect((prisma as any).companyApiKey.update).toHaveBeenCalledWith({
|
||||
+ where: { id: 'key_1' },
|
||||
+ data: { lastUsedAt: expect.any(Date) },
|
||||
+ })
|
||||
expect(req.company).toEqual(company)
|
||||
expect(req.companyId).toBe('company_1')
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
diff -ru '--exclude=node_modules' car_project_original/apps/api/src/middleware/requireApiKey.ts car_project_work/apps/api/src/middleware/requireApiKey.ts
|
||||
--- car_project_original/apps/api/src/middleware/requireApiKey.ts 2026-06-09 19:44:15.000000000 +0000
|
||||
+++ car_project_work/apps/api/src/middleware/requireApiKey.ts 2026-06-09 23:18:30.518795557 +0000
|
||||
@@ -21,14 +21,6 @@
|
||||
|
||||
const hashed = hashApiKey(apiKey)
|
||||
if (!keyRecord || keyRecord.revokedAt || !timingSafeEqualHex(hashed, keyRecord.keyHash)) {
|
||||
- if (process.env.ALLOW_LEGACY_COMPANY_API_KEYS === 'true') {
|
||||
- const company = await prisma.company.findUnique({ where: { apiKey } })
|
||||
- if (company) {
|
||||
- req.company = company
|
||||
- req.companyId = company.id
|
||||
- return next()
|
||||
- }
|
||||
- }
|
||||
return res.status(401).json({ error: 'invalid_api_key', message: 'Invalid API key', statusCode: 401 })
|
||||
}
|
||||
|
||||
diff -ru '--exclude=node_modules' car_project_original/apps/api/src/middleware/requireCompanyAuth.test.ts car_project_work/apps/api/src/middleware/requireCompanyAuth.test.ts
|
||||
--- car_project_original/apps/api/src/middleware/requireCompanyAuth.test.ts 2026-06-09 20:03:48.000000000 +0000
|
||||
+++ car_project_work/apps/api/src/middleware/requireCompanyAuth.test.ts 2026-06-09 23:18:50.351586123 +0000
|
||||
@@ -63,7 +63,7 @@
|
||||
await requireCompanyAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
- expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid token type for this endpoint', statusCode: 401 })
|
||||
+ expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired session token', statusCode: 401 })
|
||||
expect(prisma.employee.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -108,7 +108,11 @@
|
||||
|
||||
await requireCompanyDocumentAuth(req, res, next)
|
||||
|
||||
- expect(jwt.verify).toHaveBeenCalledWith('cookie-token', 'test-secret')
|
||||
+ expect(jwt.verify).toHaveBeenCalledWith('cookie-token', 'test-secret', {
|
||||
+ algorithms: ['HS256'],
|
||||
+ issuer: 'rentaldrivego-api',
|
||||
+ audience: 'employee',
|
||||
+ })
|
||||
expect(req.companyId).toBe('company_2')
|
||||
expect(next).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
diff -ru '--exclude=node_modules' car_project_original/apps/api/src/middleware/requireRenterAuth.test.ts car_project_work/apps/api/src/middleware/requireRenterAuth.test.ts
|
||||
--- car_project_original/apps/api/src/middleware/requireRenterAuth.test.ts 2026-06-09 19:40:36.000000000 +0000
|
||||
+++ car_project_work/apps/api/src/middleware/requireRenterAuth.test.ts 2026-06-09 23:18:50.352660214 +0000
|
||||
@@ -49,7 +49,7 @@
|
||||
await requireRenterAuth(req, res, next)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401)
|
||||
- expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid token type', statusCode: 401 })
|
||||
+ expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', message: 'Invalid or expired token', statusCode: 401 })
|
||||
expect(prisma.renter.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
diff -ru '--exclude=node_modules' car_project_original/apps/api/src/modules/auth/auth.employee.service.ts car_project_work/apps/api/src/modules/auth/auth.employee.service.ts
|
||||
--- car_project_original/apps/api/src/modules/auth/auth.employee.service.ts 2026-06-09 19:42:41.000000000 +0000
|
||||
+++ car_project_work/apps/api/src/modules/auth/auth.employee.service.ts 2026-06-09 23:19:29.323222951 +0000
|
||||
@@ -41,13 +41,22 @@
|
||||
pwdv: getEmployeePasswordResetVersion(passwordHash),
|
||||
},
|
||||
process.env.JWT_SECRET!,
|
||||
- { expiresIn: `${RESET_TOKEN_TTL_MINUTES}m` },
|
||||
+ {
|
||||
+ algorithm: 'HS256',
|
||||
+ issuer: 'rentaldrivego-api',
|
||||
+ audience: 'employee_password_reset',
|
||||
+ expiresIn: `${RESET_TOKEN_TTL_MINUTES}m`,
|
||||
+ },
|
||||
)
|
||||
}
|
||||
|
||||
function verifyEmployeePasswordResetToken(token: string): EmployeePasswordResetPayload | null {
|
||||
try {
|
||||
- const payload = jwt.verify(token, process.env.JWT_SECRET!) as jwt.JwtPayload
|
||||
+ const payload = jwt.verify(token, process.env.JWT_SECRET!, {
|
||||
+ algorithms: ['HS256'],
|
||||
+ issuer: 'rentaldrivego-api',
|
||||
+ audience: 'employee_password_reset',
|
||||
+ }) as jwt.JwtPayload
|
||||
|
||||
if (
|
||||
typeof payload.sub !== 'string' ||
|
||||
diff -ru '--exclude=node_modules' car_project_original/apps/api/src/security/tokens.ts car_project_work/apps/api/src/security/tokens.ts
|
||||
--- car_project_original/apps/api/src/security/tokens.ts 2026-06-09 20:20:10.000000000 +0000
|
||||
+++ car_project_work/apps/api/src/security/tokens.ts 2026-06-09 23:18:30.519590128 +0000
|
||||
@@ -54,3 +54,14 @@
|
||||
|
||||
return payload as ActorTokenPayload
|
||||
}
|
||||
+
|
||||
+export function verifyAnyActorToken(token: string): ActorTokenPayload {
|
||||
+ const decoded = jwt.decode(token) as jwt.JwtPayload | null
|
||||
+ const actorType = decoded?.type
|
||||
+
|
||||
+ if (actorType !== 'admin' && actorType !== 'employee' && actorType !== 'renter') {
|
||||
+ throw new Error('Invalid actor token')
|
||||
+ }
|
||||
+
|
||||
+ return verifyActorToken(token, actorType)
|
||||
+}
|
||||
diff -ru '--exclude=node_modules' car_project_original/apps/api/src/tests/helpers/fixtures.ts car_project_work/apps/api/src/tests/helpers/fixtures.ts
|
||||
--- car_project_original/apps/api/src/tests/helpers/fixtures.ts 2026-06-09 19:40:36.000000000 +0000
|
||||
+++ car_project_work/apps/api/src/tests/helpers/fixtures.ts 2026-06-09 23:19:11.289635569 +0000
|
||||
@@ -1,8 +1,6 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
-import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
-
|
||||
-const JWT_SECRET = process.env.JWT_SECRET ?? 'test-secret'
|
||||
+import { signActorToken } from '../../security/tokens'
|
||||
|
||||
let counter = 0
|
||||
function uid() {
|
||||
@@ -215,28 +213,16 @@
|
||||
})
|
||||
}
|
||||
|
||||
-export function signEmployeeToken(employeeId: string, companyId: string, role: string = 'OWNER') {
|
||||
- return jwt.sign(
|
||||
- { sub: employeeId, companyId, role, type: 'employee' },
|
||||
- JWT_SECRET,
|
||||
- { expiresIn: '1h' },
|
||||
- )
|
||||
+export function signEmployeeToken(employeeId: string, _companyId: string, _role: string = 'OWNER') {
|
||||
+ return signActorToken(employeeId, 'employee', { expiresIn: '1h' })
|
||||
}
|
||||
|
||||
export function signAdminToken(adminId: string) {
|
||||
- return jwt.sign(
|
||||
- { sub: adminId, type: 'admin' },
|
||||
- JWT_SECRET,
|
||||
- { expiresIn: '1h' },
|
||||
- )
|
||||
+ return signActorToken(adminId, 'admin', { expiresIn: '1h', last2faAt: Date.now() })
|
||||
}
|
||||
|
||||
export function signRenterToken(renterId: string) {
|
||||
- return jwt.sign(
|
||||
- { sub: renterId, type: 'renter' },
|
||||
- JWT_SECRET,
|
||||
- { expiresIn: '1h' },
|
||||
- )
|
||||
+ return signActorToken(renterId, 'renter', { expiresIn: '1h' })
|
||||
}
|
||||
|
||||
export function authHeader(token: string) {
|
||||
Only in car_project_work/packages/database/prisma/migrations: 20260609233000_drop_legacy_company_api_key
|
||||
diff -ru '--exclude=node_modules' car_project_original/packages/database/prisma/schema.prisma car_project_work/packages/database/prisma/schema.prisma
|
||||
--- car_project_original/packages/database/prisma/schema.prisma 2026-06-09 20:18:52.000000000 +0000
|
||||
+++ car_project_work/packages/database/prisma/schema.prisma 2026-06-09 23:18:30.516362264 +0000
|
||||
@@ -502,7 +502,6 @@
|
||||
address Json?
|
||||
status CompanyStatus @default(PENDING)
|
||||
subscriptionPaymentRef String?
|
||||
- apiKey String @unique @default(cuid())
|
||||
|
||||
subscription Subscription?
|
||||
billingAccounts BillingAccount[]
|
||||
diff -ru '--exclude=node_modules' car_project_original/packages/database/src/index.d.ts car_project_work/packages/database/src/index.d.ts
|
||||
--- car_project_original/packages/database/src/index.d.ts 2026-06-09 19:40:36.000000000 +0000
|
||||
+++ car_project_work/packages/database/src/index.d.ts 2026-06-09 23:18:30.517981475 +0000
|
||||
@@ -33,7 +33,6 @@
|
||||
email: string
|
||||
phone: string | null
|
||||
status: string
|
||||
- apiKey: string
|
||||
}
|
||||
|
||||
export interface Employee {
|
||||
diff -ru '--exclude=node_modules' car_project_original/packages/database/src/index.ts car_project_work/packages/database/src/index.ts
|
||||
--- car_project_original/packages/database/src/index.ts 2026-06-09 19:40:36.000000000 +0000
|
||||
+++ car_project_work/packages/database/src/index.ts 2026-06-09 23:18:30.517228009 +0000
|
||||
@@ -33,7 +33,6 @@
|
||||
email: string
|
||||
phone: string | null
|
||||
status: string
|
||||
- apiKey: string
|
||||
}
|
||||
|
||||
export interface Employee {
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
A SECURITY_HARDENING_LEFTOVER_APPLIED_REPORT.md
|
||||
M apps/admin/src/app/dashboard/admin-users/page.tsx
|
||||
M apps/admin/src/app/dashboard/companies/[id]/page.tsx
|
||||
M apps/admin/src/app/dashboard/containers/page.tsx
|
||||
M apps/admin/src/app/dashboard/pricing/page.tsx
|
||||
M apps/admin/src/app/dashboard/renters/page.tsx
|
||||
M apps/admin/src/app/forgot-password/page.tsx
|
||||
M apps/admin/src/app/reset-password/page.tsx
|
||||
A apps/admin/src/middleware.ts
|
||||
M apps/api/src/app.ts
|
||||
M apps/api/src/index.ts
|
||||
M apps/api/src/middleware/rateLimiter.ts
|
||||
M apps/api/src/modules/admin/admin.repo.ts
|
||||
M apps/api/src/modules/admin/admin.routes.ts
|
||||
M apps/api/src/modules/admin/admin.schemas.ts
|
||||
M apps/api/src/modules/admin/admin.service.ts
|
||||
M apps/api/src/swagger/openapi.ts
|
||||
M apps/dashboard/README.md
|
||||
M apps/dashboard/src/app/(dashboard)/team/page.tsx
|
||||
M apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx
|
||||
M apps/dashboard/src/components/layout/Sidebar.tsx
|
||||
M apps/dashboard/src/components/layout/TopBar.tsx
|
||||
M apps/dashboard/src/lib/api.ts
|
||||
M apps/dashboard/src/middleware.test.ts
|
||||
M apps/dashboard/src/middleware.ts
|
||||
M apps/marketplace/src/middleware.test.ts
|
||||
M apps/marketplace/src/middleware.ts
|
||||
M docs/project-design/COOKIE_POLICY.md
|
||||
M memory/project_auth_architecture.md
|
||||
A packages/database/prisma/migrations/20260610001500_add_admin_recovery_codes/migration.sql
|
||||
M packages/database/prisma/schema.prisma
|
||||
M scripts/security-static-check.mjs
|
||||
@@ -1,764 +0,0 @@
|
||||
# Strong Grading System Plan
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines a stronger grading-system plan for the Alrahma Sunday School API. It is intentionally a planning document, not an implementation patch.
|
||||
|
||||
The goal is to move the grading system from a functional calculator into a fair, explainable, auditable, backward-compatible, and policy-driven gradebook.
|
||||
|
||||
The current system already has a useful foundation: a centralized semester score service, separated score-entry flows, grading locks, and missing-score override tracking. However, the current scoring model still allows too much ambiguity around blank scores, missing work, validation, and finalization.
|
||||
|
||||
The strongest first move is not to redesign every formula. The strongest first move is to kill ambiguity while keeping old data readable and old calculations explainable.
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
A strong grading system must be:
|
||||
|
||||
1. **Fair**
|
||||
Missing work must not accidentally improve a student's grade.
|
||||
|
||||
2. **Explainable**
|
||||
Every final score must be traceable to category scores, weights, attendance, exam score, and policy rules.
|
||||
|
||||
3. **Auditable**
|
||||
Locked or finalized scores must preserve the exact inputs and formula used at the time.
|
||||
|
||||
4. **Policy-driven**
|
||||
Grading policy should live in services, configuration, and grading profiles, not scattered controller logic.
|
||||
|
||||
5. **Safe**
|
||||
Invalid scores must be rejected before they reach averages or semester calculations.
|
||||
|
||||
6. **Backward-compatible**
|
||||
Old data must remain readable, displayable, and explainable under the legacy policy that created it.
|
||||
|
||||
7. **Additive, not destructive**
|
||||
The new grading system must be introduced beside the old one first. Existing historical scores must not be silently recalculated, reclassified, hidden, or overwritten.
|
||||
|
||||
---
|
||||
|
||||
## Non-Negotiable Backward Compatibility Requirement
|
||||
|
||||
The system must preserve old data display.
|
||||
|
||||
Historical grades must remain visible exactly as they were calculated under the legacy system. The new strong grading system must not reinterpret old blanks, old averages, or old semester scores as if they were created under the new policy.
|
||||
|
||||
This is the hard rule:
|
||||
|
||||
```text
|
||||
Old data must remain readable, displayable, and explainable exactly as it was calculated under the legacy system.
|
||||
```
|
||||
|
||||
The new system must support two modes:
|
||||
|
||||
| Mode | Purpose | Formula Source | Data Behavior |
|
||||
|---|---|---|---|
|
||||
| Legacy display mode | Show old/historical grades | Existing stored semester values and legacy formula | Do not reinterpret blanks or statuses |
|
||||
| Strong scoring mode | Calculate future grades under stronger rules | New scoring service/profile | Uses statuses, max points, snapshots, and finalization validation |
|
||||
|
||||
Legacy records must show stored values from the existing `semester_scores` table, such as:
|
||||
|
||||
```text
|
||||
homework_avg
|
||||
quiz_avg
|
||||
project_avg
|
||||
participation_score
|
||||
attendance_score
|
||||
ptap_score
|
||||
midterm_exam_score
|
||||
final_exam_score
|
||||
semester_score
|
||||
```
|
||||
|
||||
Legacy displays should include a visible label:
|
||||
|
||||
```text
|
||||
Legacy Calculation
|
||||
```
|
||||
|
||||
And a short explanation:
|
||||
|
||||
```text
|
||||
This score was calculated under the legacy grading policy. Blank scores were ignored in category averages, PTAP used legacy dynamic weighting, attendance included one-absence grace, and the semester exam used the legacy 60% weighting.
|
||||
```
|
||||
|
||||
This is not optional. Without this, the system risks showing old data through new rules and creating disputes that nobody can cleanly answer. Software should not gaslight the gradebook. Humans already do enough of that.
|
||||
|
||||
---
|
||||
|
||||
## Historical Data Protection Rules
|
||||
|
||||
Historical grades must not be automatically recalculated under the new scoring policy.
|
||||
|
||||
Rules:
|
||||
|
||||
1. Existing `semester_scores` rows stay in place.
|
||||
2. Existing historical rows are marked as legacy.
|
||||
3. Legacy rows are displayed from stored aggregate values.
|
||||
4. Legacy rows are not forced into the new status model.
|
||||
5. Legacy blanks are not automatically converted to `missing`.
|
||||
6. Legacy scores are not recalculated unless an authorized admin explicitly starts a recalculation job.
|
||||
7. If recalculation is ever allowed, the original legacy score must be preserved beside the new-policy result.
|
||||
|
||||
Recommended display if a historical record is later recalculated for comparison:
|
||||
|
||||
```text
|
||||
legacy_semester_score: 91.4
|
||||
new_policy_semester_score: 87.2
|
||||
displayed_score: 91.4 unless admin explicitly chooses otherwise
|
||||
```
|
||||
|
||||
The default behavior must protect the original displayed score.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Compatibility Metadata
|
||||
|
||||
Do not replace the existing `semester_scores` table. Keep it.
|
||||
|
||||
Add nullable metadata columns:
|
||||
|
||||
```text
|
||||
calculation_mode
|
||||
calculation_policy_version
|
||||
snapshot_id nullable
|
||||
```
|
||||
|
||||
Suggested values:
|
||||
|
||||
```text
|
||||
calculation_mode = legacy | strong
|
||||
calculation_policy_version = legacy_v1 | strong_v1
|
||||
```
|
||||
|
||||
Backfill existing rows as:
|
||||
|
||||
```text
|
||||
calculation_mode = legacy
|
||||
calculation_policy_version = legacy_v1
|
||||
snapshot_id = null
|
||||
```
|
||||
|
||||
New strong-system finalized rows should be saved with:
|
||||
|
||||
```text
|
||||
calculation_mode = strong
|
||||
calculation_policy_version = strong_v1
|
||||
snapshot_id = related semester_score_snapshots row
|
||||
```
|
||||
|
||||
This keeps old grade display stable while allowing new scores to become audit-proof.
|
||||
|
||||
---
|
||||
|
||||
## Display Resolver Requirement
|
||||
|
||||
Create a display resolver instead of forcing every grade display through the new scoring engine.
|
||||
|
||||
Recommended service:
|
||||
|
||||
```php
|
||||
App\Services\Grading\GradeCalculationDisplayResolver
|
||||
```
|
||||
|
||||
Responsibilities:
|
||||
|
||||
```text
|
||||
If calculation_mode is legacy:
|
||||
Load and display existing semester_scores fields.
|
||||
Show Legacy Calculation badge.
|
||||
Do not apply new status or profile rules.
|
||||
|
||||
If calculation_mode is strong:
|
||||
Load snapshot and/or strong calculation breakdown.
|
||||
Show Strong Calculation badge.
|
||||
Display status-aware category breakdown.
|
||||
```
|
||||
|
||||
The display layer must not recalculate legacy scores just to show them. It should show the stored values. Recalculating old scores on display is a terrible idea, and yet somehow tempting to developers who enjoy turning page loads into historical revisionism.
|
||||
|
||||
---
|
||||
|
||||
## Important Policy Clarification: Attendance Grace
|
||||
|
||||
The attendance grace rule is school policy.
|
||||
|
||||
The current formula effectively gives students one absence before attendance points are reduced:
|
||||
|
||||
```text
|
||||
attendance_score = ((total_days + 1 - absences) / total_days) * 100
|
||||
```
|
||||
|
||||
This is policy-compliant, but the formula hides the intent. It should be rewritten more explicitly:
|
||||
|
||||
```text
|
||||
grace_absences = 1
|
||||
effective_absences = max(0, absences - grace_absences)
|
||||
attendance_score = ((total_days - effective_absences) / total_days) * 100
|
||||
```
|
||||
|
||||
This keeps the same policy while making the rule clear. Future developers should not have to decode a `+1` and wonder whether it is a bug wearing a tiny policy hat.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Lock the Grading Policy Before Writing Code
|
||||
|
||||
Before implementation, these policy decisions must be explicit.
|
||||
|
||||
| Question | Recommended Decision |
|
||||
|---|---|
|
||||
| Are old/historical grades still displayed? | Yes. Always. |
|
||||
| Are old/historical grades recalculated automatically? | No. Never silently. |
|
||||
| Are old blanks converted to missing? | No. Old blanks remain legacy behavior unless explicitly migrated by admin workflow. |
|
||||
| Are scores always out of 100? | No. Add `max_points`, then normalize to percentages for strong scoring. |
|
||||
| What does a blank score mean in the new system? | Nothing final. It means pending or unresolved. |
|
||||
| Does missing work count as zero in the new system? | Yes, unless explicitly excused. |
|
||||
| Does excused work affect the average? | No. Excused work is excluded from the denominator. |
|
||||
| Can a class be locked with pending scores in the new system? | No. |
|
||||
| Can a class be locked with missing work in the new system? | Yes, but missing work counts as zero unless excused. |
|
||||
| Does attendance have one absence grace? | Yes. This is school policy. |
|
||||
| Should final scores keep an audit trail? | Yes. Store a calculation snapshot when locking/finalizing new strong scores. |
|
||||
|
||||
These decisions are non-negotiable if the system needs to be defensible.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Stabilize the Current Legacy System
|
||||
|
||||
Do not start with grading profiles. That is architecture candy. It looks mature, but it does not fix the most dangerous problem first.
|
||||
|
||||
The first goal is to stop bad data, protect current behavior with tests, and keep legacy grade display working.
|
||||
|
||||
### 1. Add Tests for Existing Legacy Formulas
|
||||
|
||||
Before changing calculation behavior, freeze the current formulas in tests.
|
||||
|
||||
Test coverage should include:
|
||||
|
||||
```text
|
||||
homework average ignores blanks
|
||||
quiz average ignores blanks
|
||||
project average ignores blanks
|
||||
participation missing returns null
|
||||
PTAP with no quiz/project
|
||||
PTAP with quiz only
|
||||
PTAP with project only
|
||||
PTAP with all categories
|
||||
Fall semester formula
|
||||
Spring semester formula
|
||||
attendance with 0 absences = 100
|
||||
attendance with 1 absence = 100
|
||||
attendance with 2 absences = reduced score
|
||||
legacy semester_scores display from stored values
|
||||
legacy rows are not recalculated on display
|
||||
```
|
||||
|
||||
This creates a safety net. Without it, every refactor becomes a guessing game with grades, which is exactly as bad as it sounds.
|
||||
|
||||
### 2. Add Central Score Validation
|
||||
|
||||
Create one central validator used by every score write path.
|
||||
|
||||
Validation rules:
|
||||
|
||||
```text
|
||||
score may be null
|
||||
score must be numeric when present
|
||||
score must be >= 0
|
||||
score must be <= max_points
|
||||
max_points must be > 0
|
||||
```
|
||||
|
||||
For the first pass, use `max_points = 100` by default.
|
||||
|
||||
Example service shape:
|
||||
|
||||
```php
|
||||
final class ScoreValueValidator
|
||||
{
|
||||
public function normalizeOrFail(mixed $value, float $maxPoints = 100.0): ?float
|
||||
{
|
||||
if ($value === null || (is_string($value) && trim($value) === '')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_numeric($value)) {
|
||||
throw new InvalidArgumentException('Score must be numeric.');
|
||||
}
|
||||
|
||||
$score = (float) $value;
|
||||
|
||||
if ($score < 0 || $score > $maxPoints) {
|
||||
throw new InvalidArgumentException("Score must be between 0 and {$maxPoints}.");
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use this validator in all score-entry paths:
|
||||
|
||||
```text
|
||||
HomeworkScoreService
|
||||
QuizScoreService
|
||||
ProjectScoreService
|
||||
ParticipationScoreService
|
||||
ExamScoreService
|
||||
GradingScoreService
|
||||
```
|
||||
|
||||
This should reject impossible new values without changing how existing historical rows are displayed.
|
||||
|
||||
### 3. Make Attendance Grace Explicit
|
||||
|
||||
Refactor the attendance calculation without changing its result.
|
||||
|
||||
Replace hidden arithmetic:
|
||||
|
||||
```text
|
||||
(total_days + 1 - absences) / total_days
|
||||
```
|
||||
|
||||
With named policy logic:
|
||||
|
||||
```text
|
||||
grace_absences = 1
|
||||
effective_absences = max(0, absences - grace_absences)
|
||||
attendance_score = ((total_days - effective_absences) / total_days) * 100
|
||||
```
|
||||
|
||||
Add tests:
|
||||
|
||||
| Total Days | Absences | Expected Attendance Score |
|
||||
|---:|---:|---:|
|
||||
| 10 | 0 | 100 |
|
||||
| 10 | 1 | 100 |
|
||||
| 10 | 2 | 90 |
|
||||
| 10 | 10 | 10 |
|
||||
| 10 | 11+ | 0 |
|
||||
|
||||
### 4. Add Legacy Display Badge
|
||||
|
||||
Before introducing strong scoring, make the UI able to label old records.
|
||||
|
||||
For legacy rows, show:
|
||||
|
||||
```text
|
||||
Policy: Legacy Calculation
|
||||
```
|
||||
|
||||
Example display:
|
||||
|
||||
```text
|
||||
Semester Score: 91.4
|
||||
Policy: Legacy Calculation
|
||||
Attendance: 100.0
|
||||
PTAP: 88.5
|
||||
Midterm: 92.0
|
||||
|
||||
Note: This score was calculated under the legacy policy. Blank scores were ignored in averages.
|
||||
```
|
||||
|
||||
This prevents users from assuming legacy and strong scores use the same rules.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Make Missing Scores Explicit for New/Strong Scoring
|
||||
|
||||
This phase applies to the new strong scoring path. It must not automatically rewrite old historical records.
|
||||
|
||||
Right now, a blank score can mean too many things:
|
||||
|
||||
```text
|
||||
not graded yet
|
||||
student missing work
|
||||
student excused
|
||||
teacher forgot
|
||||
assignment not applicable
|
||||
```
|
||||
|
||||
A grading system cannot be fair if the data refuses to say what it means.
|
||||
|
||||
### 1. Add Score Statuses
|
||||
|
||||
Every new strong score-bearing record should have both a numeric score and a status.
|
||||
|
||||
Minimum statuses:
|
||||
|
||||
| Status | Meaning | Counts in Average? | Blocks Finalization? |
|
||||
|---|---|---:|---:|
|
||||
| `pending` | Teacher has not resolved the item | No | Yes |
|
||||
| `scored` | Valid numeric score exists | Yes | No |
|
||||
| `missing` | Student did not submit | Yes, as 0 | No |
|
||||
| `excused` | Approved exclusion | No | No |
|
||||
| `not_assigned` | Does not apply | No | No |
|
||||
|
||||
Recommended columns for homework, quiz, project, participation, midterm, and final tables:
|
||||
|
||||
```text
|
||||
status
|
||||
max_points
|
||||
excused_reason
|
||||
locked_at
|
||||
locked_by
|
||||
```
|
||||
|
||||
### 2. Migrate Existing Data Carefully
|
||||
|
||||
Do not blindly turn old blanks into missing work. That would punish students for old system ambiguity.
|
||||
|
||||
Initial migration mapping for active, not-yet-finalized records:
|
||||
|
||||
| Existing Data | Initial Status |
|
||||
|---|---|
|
||||
| Numeric score exists | `scored` |
|
||||
| Blank/null score | `pending` |
|
||||
| Missing override exists and is allowed | `excused` or `not_assigned`, depending policy meaning |
|
||||
|
||||
For historical finalized legacy records, do not require full status migration just to display them. Keep them as legacy display records.
|
||||
|
||||
After migration, teachers/admins must resolve pending items before final lock for strong-scoring sections.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Change Calculation Semantics for Strong Scoring
|
||||
|
||||
Once statuses exist, strong category calculations should stop relying on blank/null behavior.
|
||||
|
||||
New score handling:
|
||||
|
||||
| Status | Calculation Behavior |
|
||||
|---|---|
|
||||
| `scored` | Include normalized score |
|
||||
| `missing` | Include 0 |
|
||||
| `excused` | Exclude from denominator |
|
||||
| `not_assigned` | Exclude from denominator |
|
||||
| `pending` | Block finalization |
|
||||
|
||||
Normalize every scored item:
|
||||
|
||||
```text
|
||||
normalized_score = (raw_score / max_points) * 100
|
||||
```
|
||||
|
||||
Then calculate the category average:
|
||||
|
||||
```text
|
||||
category_average = average(all included normalized scores)
|
||||
```
|
||||
|
||||
This prevents missing work from silently improving a student's grade.
|
||||
|
||||
Legacy records must still display legacy stored values. Do not apply this status-aware calculation to historical rows unless an admin explicitly opts into a recalculation workflow.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Add Finalization Validation
|
||||
|
||||
Locking a strong-scoring class should not simply flip a flag. It should prove the gradebook is complete enough to finalize.
|
||||
|
||||
Create a validator service:
|
||||
|
||||
```php
|
||||
App\Services\Grading\GradebookFinalizationValidator
|
||||
```
|
||||
|
||||
It should validate:
|
||||
|
||||
```text
|
||||
No pending required scores
|
||||
All numeric scores are within range
|
||||
Required categories are present
|
||||
Required exam score exists
|
||||
Required participation score exists
|
||||
Attendance can be calculated
|
||||
Missing items are either counted as zero or excused
|
||||
Every student has a calculable final score
|
||||
```
|
||||
|
||||
If validation fails, return a structured report:
|
||||
|
||||
```text
|
||||
student
|
||||
category
|
||||
item
|
||||
problem
|
||||
required_action
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Student: Ahmed Ali
|
||||
Category: Homework
|
||||
Item: Homework #4
|
||||
Problem: Pending score
|
||||
Required action: Mark as scored, missing, excused, or not assigned before locking.
|
||||
```
|
||||
|
||||
The lock button should become a gate, not a decorative suggestion.
|
||||
|
||||
Legacy historical records do not need to pass the new finalization validator just to be displayed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Add Calculation Snapshots for Strong Scores
|
||||
|
||||
When strong grades are finalized or locked, store exactly how the final score was produced.
|
||||
|
||||
Create table:
|
||||
|
||||
```text
|
||||
semester_score_snapshots
|
||||
```
|
||||
|
||||
Recommended fields:
|
||||
|
||||
```text
|
||||
id
|
||||
student_id
|
||||
class_section_id
|
||||
semester
|
||||
school_year
|
||||
grading_policy_version
|
||||
input_json
|
||||
calculation_json
|
||||
semester_score
|
||||
calculated_by
|
||||
calculated_at
|
||||
```
|
||||
|
||||
The snapshot should include:
|
||||
|
||||
```text
|
||||
raw scores used
|
||||
score statuses used
|
||||
max points used
|
||||
normalized scores
|
||||
category averages
|
||||
category weights
|
||||
attendance grace policy
|
||||
exam score
|
||||
final formula
|
||||
final score
|
||||
```
|
||||
|
||||
This protects the school when someone disputes a grade later.
|
||||
|
||||
The current `semester_scores` table can show the latest aggregate. The snapshot proves what was true at finalization.
|
||||
|
||||
Legacy historical grades may have no snapshot. That is acceptable as long as they are clearly labeled `legacy_v1` and displayed from stored values.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Replace Hardcoded PTAP With Grading Profiles
|
||||
|
||||
Only after validation, statuses, legacy display protection, and snapshots are in place should hardcoded weighting be replaced.
|
||||
|
||||
Current PTAP behavior changes based on data availability:
|
||||
|
||||
```text
|
||||
No tests and no projects
|
||||
Tests but no projects
|
||||
Projects but no tests
|
||||
All categories exist
|
||||
```
|
||||
|
||||
That is flexible, but dangerous. It lets missing or unentered data affect the formula.
|
||||
|
||||
### Recommended Tables
|
||||
|
||||
```text
|
||||
grading_profiles
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
```text
|
||||
id
|
||||
name
|
||||
school_year
|
||||
semester
|
||||
class_section_id nullable
|
||||
is_default
|
||||
version
|
||||
created_by
|
||||
created_at
|
||||
updated_at
|
||||
```
|
||||
|
||||
```text
|
||||
grading_profile_categories
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
```text
|
||||
id
|
||||
grading_profile_id
|
||||
category_key
|
||||
weight
|
||||
required
|
||||
redistribute_if_missing
|
||||
```
|
||||
|
||||
### Example Profile
|
||||
|
||||
| Category | Weight | Required | Redistribute If Unused |
|
||||
|---|---:|---:|---:|
|
||||
| homework | 15 | Yes | No |
|
||||
| quiz | 15 | Configurable | Yes |
|
||||
| project | 15 | Configurable | Yes |
|
||||
| participation | 15 | Yes | No |
|
||||
| attendance | 10 | Yes | No |
|
||||
| exam | 30 | Yes | No |
|
||||
|
||||
Important distinction:
|
||||
|
||||
```text
|
||||
Category not used by profile -> redistribute its weight
|
||||
Category assigned but scores pending -> block lock
|
||||
Category assigned but student missing -> count as zero
|
||||
Category excused -> exclude according to policy
|
||||
```
|
||||
|
||||
Do not redistribute weight just because a teacher forgot to enter grades. That would be grading by accident, which is somehow worse than grading by spreadsheet.
|
||||
|
||||
Legacy rows continue using legacy PTAP display and must not be forced into grading profiles.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Opt-In Activation Strategy
|
||||
|
||||
Strong scoring should be activated by class section, semester, and school year. It should not globally replace legacy behavior in one switch.
|
||||
|
||||
Recommended activation metadata:
|
||||
|
||||
```text
|
||||
class_section_id
|
||||
school_year
|
||||
semester
|
||||
calculation_mode = legacy | strong
|
||||
grading_profile_id nullable
|
||||
activated_by
|
||||
activated_at
|
||||
```
|
||||
|
||||
Activation rules:
|
||||
|
||||
1. Existing historical records remain `legacy`.
|
||||
2. Current active sections may remain `legacy` until admins choose migration.
|
||||
3. New future sections can default to `strong` once the system is ready.
|
||||
4. A class section using `legacy` must continue calculating and displaying using the legacy service.
|
||||
5. A class section using `strong` must use statuses, max points, finalization validation, and snapshots.
|
||||
6. Switching a section from legacy to strong should require an admin confirmation and a preflight report.
|
||||
|
||||
Preflight report should show:
|
||||
|
||||
```text
|
||||
number of numeric scores that will become scored
|
||||
number of blanks that will become pending
|
||||
number of overrides requiring review
|
||||
students blocked from finalization until pending items are resolved
|
||||
```
|
||||
|
||||
No quiet migrations. Quiet migrations are where data integrity goes to disappear.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Refactor Controllers Last
|
||||
|
||||
Controllers should not contain grading policy.
|
||||
|
||||
Final desired flow:
|
||||
|
||||
```text
|
||||
Controller receives request
|
||||
Request validates input shape
|
||||
Application service saves score/status
|
||||
Scoring mode resolver selects legacy or strong path
|
||||
Scoring service recalculates draft score
|
||||
Finalization validator blocks or allows lock for strong mode
|
||||
Snapshot service records locked strong calculation
|
||||
Display resolver shows legacy or strong breakdown
|
||||
```
|
||||
|
||||
Controllers should not know formulas. They should not decide whether missing counts. They should not know category redistribution rules.
|
||||
|
||||
Controllers are traffic cops, not education philosophers.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Implementation Order
|
||||
|
||||
| Step | Work | Risk Reduced |
|
||||
|---:|---|---|
|
||||
| 1 | Add tests for current legacy formulas and legacy display | Prevent accidental formula/display drift |
|
||||
| 2 | Add compatibility metadata to `semester_scores` | Preserve old records clearly |
|
||||
| 3 | Add legacy/strong display resolver | Keep old data visible |
|
||||
| 4 | Add central score validation | Stop impossible new scores |
|
||||
| 5 | Make attendance grace explicit | Preserve policy and improve clarity |
|
||||
| 6 | Add `max_points` columns | Enable normalization |
|
||||
| 7 | Add `status` columns | Remove blank-score ambiguity for strong scoring |
|
||||
| 8 | Build category calculator using statuses | Fix missing-score fairness |
|
||||
| 9 | Add finalization validator | Stop invalid strong locks |
|
||||
| 10 | Add calculation snapshots | Audit-proof locked strong grades |
|
||||
| 11 | Add grading profiles | Remove hardcoded weights for strong mode |
|
||||
| 12 | Add opt-in activation by class/semester | Prevent disruptive rollout |
|
||||
| 13 | Refactor controllers | Clean architecture after behavior is safe |
|
||||
|
||||
---
|
||||
|
||||
## MVP Strong Upgrade
|
||||
|
||||
The smallest serious upgrade is:
|
||||
|
||||
```text
|
||||
1. Keep current legacy formulas.
|
||||
2. Keep old data display from stored semester_scores values.
|
||||
3. Add calculation_mode and calculation_policy_version metadata.
|
||||
4. Add legacy display badge.
|
||||
5. Add central score validation for new writes.
|
||||
6. Make attendance grace explicit without changing results.
|
||||
7. Add score statuses for active/future strong-scoring sections.
|
||||
8. Treat missing as zero, excused as excluded, and pending as blocking lock in strong mode only.
|
||||
9. Add a lock validation report for strong mode.
|
||||
10. Add calculation snapshots on strong-mode lock.
|
||||
```
|
||||
|
||||
This improves fairness and auditability without breaking old records or immediately changing all weights.
|
||||
|
||||
After that, replace PTAP branching with grading profiles.
|
||||
|
||||
---
|
||||
|
||||
## Hard Safety Rules
|
||||
|
||||
1. Do not change formulas and database semantics in the same step without tests.
|
||||
2. Do not silently recalculate historical scores.
|
||||
3. Do not convert old blanks to missing for historical records.
|
||||
4. Do not hide legacy data because it lacks new statuses.
|
||||
5. Do not activate strong scoring globally without class/semester-level opt-in.
|
||||
6. Do not display a strong-policy explanation for a legacy-policy score.
|
||||
7. Do not let a lock action become the first time the system discovers incomplete data.
|
||||
|
||||
That is how a school ends up unable to explain whether grades changed because of policy, migration, code, or a null-handling goblin hiding in the service layer.
|
||||
|
||||
---
|
||||
|
||||
## Final Recommendation
|
||||
|
||||
Do not begin by building grading profiles.
|
||||
|
||||
Begin by making legacy display safe and every new score state explicit.
|
||||
|
||||
The strongest first milestone is:
|
||||
|
||||
```text
|
||||
Old grades remain visible under Legacy Calculation, and future strong-mode grades cannot be locked until every score is classified as scored, missing, excused, pending, or not assigned.
|
||||
```
|
||||
|
||||
Once that is true, the grading system becomes defensible. Until that is true, every formula is built on fog.
|
||||
@@ -0,0 +1,268 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { TeamMember } from '../../hooks/useTeam'
|
||||
|
||||
interface Props {
|
||||
member: TeamMember | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onUpdateRole: (memberId: string, role: 'MANAGER' | 'AGENT') => Promise<void>
|
||||
onDeactivate: (memberId: string) => Promise<void>
|
||||
onReactivate: (memberId: string) => Promise<void>
|
||||
onRemove: (memberId: string) => Promise<void>
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'MANAGER' as const, label: 'Manager', description: 'Full ops — no billing' },
|
||||
{ value: 'AGENT' as const, label: 'Agent', description: 'Reservations & check-ins' },
|
||||
]
|
||||
|
||||
type ActionState = 'idle' | 'saving' | 'deactivating' | 'reactivating' | 'removing'
|
||||
type ConfirmAction = 'deactivate' | 'reactivate' | 'remove' | null
|
||||
|
||||
export default function EditMemberModal({
|
||||
member,
|
||||
open,
|
||||
onClose,
|
||||
onUpdateRole,
|
||||
onDeactivate,
|
||||
onReactivate,
|
||||
onRemove,
|
||||
}: Props) {
|
||||
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
|
||||
const [actionState, setActionState] = useState<ActionState>('idle')
|
||||
const [confirm, setConfirm] = useState<ConfirmAction>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (member && open) {
|
||||
setRole(member.role === 'OWNER' ? 'MANAGER' : (member.role as 'MANAGER' | 'AGENT'))
|
||||
setActionState('idle')
|
||||
setConfirm(null)
|
||||
setError(null)
|
||||
}
|
||||
}, [member, open])
|
||||
|
||||
if (!open || !member) return null
|
||||
|
||||
const isPending = member.invitationStatus === 'pending'
|
||||
const isActive = member.isActive
|
||||
|
||||
const handleSave = async () => {
|
||||
if (role === member.role) { onClose(); return }
|
||||
setActionState('saving')
|
||||
setError(null)
|
||||
try {
|
||||
await onUpdateRole(member.id, role)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
setActionState('idle')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeactivate = async () => {
|
||||
setActionState('deactivating')
|
||||
setError(null)
|
||||
try {
|
||||
await onDeactivate(member.id)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
setActionState('idle')
|
||||
}
|
||||
setConfirm(null)
|
||||
}
|
||||
|
||||
const handleReactivate = async () => {
|
||||
setActionState('reactivating')
|
||||
setError(null)
|
||||
try {
|
||||
await onReactivate(member.id)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
setActionState('idle')
|
||||
}
|
||||
setConfirm(null)
|
||||
}
|
||||
|
||||
const handleRemove = async () => {
|
||||
setActionState('removing')
|
||||
setError(null)
|
||||
try {
|
||||
await onRemove(member.id)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
setActionState('idle')
|
||||
}
|
||||
setConfirm(null)
|
||||
}
|
||||
|
||||
const busy = actionState !== 'idle'
|
||||
const initials = (member.firstName[0] ?? '') + (member.lastName[0] ?? '')
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
|
||||
>
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
|
||||
|
||||
{/* Member header */}
|
||||
<div className="flex items-center gap-3 mb-5 pb-5 border-b border-zinc-100 dark:border-zinc-800">
|
||||
<div className="w-10 h-10 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-sm font-medium text-zinc-600 dark:text-zinc-300">
|
||||
{initials}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-zinc-900 dark:text-white text-sm">
|
||||
{member.firstName} {member.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</p>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
{isPending && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border border-amber-100 dark:border-amber-900/50">
|
||||
Pending invite
|
||||
</span>
|
||||
)}
|
||||
{!isPending && !isActive && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400">
|
||||
Deactivated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm overlay */}
|
||||
{confirm && (
|
||||
<div className="mb-4 px-4 py-3 rounded-xl border border-red-100 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20">
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-1">
|
||||
{confirm === 'deactivate' && 'Deactivate this member?'}
|
||||
{confirm === 'remove' && 'Permanently remove this member?'}
|
||||
{confirm === 'reactivate' && 'Reactivate this member?'}
|
||||
</p>
|
||||
<p className="text-xs text-red-600 dark:text-red-500 mb-3">
|
||||
{confirm === 'deactivate' && "They'll immediately lose dashboard access. You can reactivate anytime."}
|
||||
{confirm === 'remove' && "This will delete the employee record and revoke their Clerk access. This cannot be undone."}
|
||||
{confirm === 'reactivate' && "They'll regain dashboard access with their current role."}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setConfirm(null)}
|
||||
className="flex-1 text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={
|
||||
confirm === 'deactivate' ? handleDeactivate :
|
||||
confirm === 'reactivate' ? handleReactivate :
|
||||
handleRemove
|
||||
}
|
||||
disabled={busy}
|
||||
className={[
|
||||
'flex-1 text-xs px-3 py-1.5 rounded-lg font-medium disabled:opacity-50',
|
||||
confirm === 'reactivate'
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'bg-red-600 text-white hover:bg-red-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{busy ? 'Working…' : (
|
||||
confirm === 'deactivate' ? 'Deactivate' :
|
||||
confirm === 'reactivate' ? 'Reactivate' :
|
||||
'Remove permanently'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Role selector (hidden for pending invites — role locked until accepted) */}
|
||||
{!isPending && isActive && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">
|
||||
Role
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ROLE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setRole(option.value)}
|
||||
className={[
|
||||
'text-left px-3 py-2.5 rounded-xl border transition-all',
|
||||
role === option.value
|
||||
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
|
||||
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="text-sm font-medium text-zinc-900 dark:text-white">{option.label}</div>
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{option.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4 px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="flex items-center gap-2 pt-4 border-t border-zinc-100 dark:border-zinc-800">
|
||||
{/* Danger zone */}
|
||||
<div className="flex gap-2 mr-auto">
|
||||
{isActive && !isPending && (
|
||||
<button
|
||||
onClick={() => setConfirm('deactivate')}
|
||||
disabled={busy}
|
||||
className="text-xs px-3 py-1.5 rounded-lg border border-red-200 dark:border-red-900/50 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Deactivate
|
||||
</button>
|
||||
)}
|
||||
{!isActive && (
|
||||
<button
|
||||
onClick={() => setConfirm('reactivate')}
|
||||
disabled={busy}
|
||||
className="text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Reactivate
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setConfirm('remove')}
|
||||
disabled={busy}
|
||||
className="text-xs px-3 py-1.5 rounded-lg text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{isActive && !isPending && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionState === 'saving' ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { InvitePayload } from '../../hooks/useTeam'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onInvite: (payload: InvitePayload) => Promise<void>
|
||||
}
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{
|
||||
value: 'MANAGER' as const,
|
||||
label: 'Manager',
|
||||
description: 'Full fleet ops — no billing or team settings',
|
||||
permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'],
|
||||
},
|
||||
{
|
||||
value: 'AGENT' as const,
|
||||
label: 'Agent',
|
||||
description: 'Day-to-day operations only',
|
||||
permissions: ['View fleet', 'Reservations', 'Check-in / Check-out', 'View customers'],
|
||||
},
|
||||
]
|
||||
|
||||
export default function InviteModal({ open, onClose, onInvite }: Props) {
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Reset form when modal closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setTimeout(() => {
|
||||
setFirstName('')
|
||||
setLastName('')
|
||||
setEmail('')
|
||||
setRole('AGENT')
|
||||
setError(null)
|
||||
}, 200)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await onInvite({ firstName: firstName.trim(), lastName: lastName.trim(), email: email.trim(), role })
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to send invitation')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<div className="mb-5">
|
||||
<h2 className="text-lg font-medium text-zinc-900 dark:text-white">Invite a team member</h2>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
They'll receive an email invitation to join your dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Name row */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
|
||||
First name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
placeholder="Youssef"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:ring-offset-0 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
|
||||
Last name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
placeholder="Benali"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="youssef@yourcompany.com"
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
|
||||
Role
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ROLE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setRole(option.value)}
|
||||
className={[
|
||||
'text-left px-3 py-3 rounded-xl border transition-all',
|
||||
role === option.value
|
||||
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
|
||||
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-zinc-900 dark:text-white">
|
||||
{option.label}
|
||||
</span>
|
||||
{role === option.value && (
|
||||
<span className="w-4 h-4 rounded-full bg-zinc-900 dark:bg-white flex items-center justify-center">
|
||||
<svg className="w-2.5 h-2.5 text-white dark:text-zinc-900" fill="currentColor" viewBox="0 0 12 12">
|
||||
<path d="M10 3L5 8.5 2 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-snug">
|
||||
{option.description}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{option.permissions.slice(0, 3).map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
{option.permissions.length > 3 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400">
|
||||
+{option.permissions.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="px-3 py-2.5 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex gap-2 pt-2 border-t border-zinc-100 dark:border-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? 'Sending…' : 'Send invite →'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
type Access = 'full' | 'partial' | 'none'
|
||||
|
||||
interface Feature {
|
||||
label: string
|
||||
manager: Access
|
||||
managerNote?: string
|
||||
agent: Access
|
||||
agentNote?: string
|
||||
}
|
||||
|
||||
const FEATURES: Feature[] = [
|
||||
{ label: 'View fleet & vehicles', manager: 'full', agent: 'full' },
|
||||
{ label: 'Add / edit vehicles', manager: 'full', agent: 'none' },
|
||||
{ label: 'Publish / unpublish vehicle', manager: 'full', agent: 'none' },
|
||||
{ label: 'Upload vehicle photos', manager: 'full', agent: 'none' },
|
||||
{ label: 'View reservations', manager: 'full', agent: 'full' },
|
||||
{ label: 'Create reservations', manager: 'full', agent: 'full' },
|
||||
{ label: 'Cancel reservations', manager: 'full', agent: 'partial', agentNote: 'Own only' },
|
||||
{ label: 'Check-in / check-out', manager: 'full', agent: 'full' },
|
||||
{ label: 'Damage inspection', manager: 'full', agent: 'full' },
|
||||
{ label: 'Customer CRM — view', manager: 'full', agent: 'full' },
|
||||
{ label: 'Customer CRM — edit', manager: 'full', agent: 'partial', agentNote: 'Notes only' },
|
||||
{ label: 'Approve driver licenses', manager: 'full', agent: 'none' },
|
||||
{ label: 'Flag / blacklist customer', manager: 'full', agent: 'none' },
|
||||
{ label: 'Offers & promotions', manager: 'full', agent: 'none' },
|
||||
{ label: 'Analytics & reports', manager: 'full', agent: 'none' },
|
||||
{ label: 'Contract & invoice PDF', manager: 'full', agent: 'full' },
|
||||
{ label: 'Billing & subscription', manager: 'none', agent: 'none' },
|
||||
{ label: 'Invite / remove staff', manager: 'none', agent: 'none' },
|
||||
{ label: 'Brand & site settings', manager: 'none', agent: 'none' },
|
||||
{ label: 'Payment settings', manager: 'none', agent: 'none' },
|
||||
]
|
||||
|
||||
function AccessCell({ access, note }: { access: Access; note?: string }) {
|
||||
if (access === 'full') {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg className="w-3.5 h-3.5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 14 14">
|
||||
<path d="M2.5 7L5.5 10L11.5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span className="text-xs text-zinc-700 dark:text-zinc-300">Full</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (access === 'partial') {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
|
||||
<div className="w-2.5 h-0.5 rounded bg-amber-500" />
|
||||
</div>
|
||||
<span className="text-xs text-amber-700 dark:text-amber-400">{note ?? 'Limited'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
|
||||
<div className="w-2 h-0.5 rounded bg-zinc-300 dark:bg-zinc-600" />
|
||||
</div>
|
||||
<span className="text-xs text-zinc-400 dark:text-zinc-600">No access</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PermissionsMatrix() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-base font-medium text-zinc-900 dark:text-white">Role permissions</h2>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">
|
||||
What each role can do across the dashboard. Owners have full access to everything.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-[1fr_120px_120px] bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">
|
||||
Feature
|
||||
</div>
|
||||
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
|
||||
Manager
|
||||
</div>
|
||||
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
|
||||
Agent
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{FEATURES.map((feature, i) => (
|
||||
<div
|
||||
key={feature.label}
|
||||
className={[
|
||||
'grid grid-cols-[1fr_120px_120px]',
|
||||
i < FEATURES.length - 1 ? 'border-b border-zinc-100 dark:border-zinc-800' : '',
|
||||
i % 2 === 0 ? '' : 'bg-zinc-50/50 dark:bg-zinc-800/20',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="px-4 py-2.5 text-xs text-zinc-600 dark:text-zinc-400">
|
||||
{feature.label}
|
||||
</div>
|
||||
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
|
||||
<AccessCell access={feature.manager} note={feature.managerNote} />
|
||||
</div>
|
||||
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
|
||||
<AccessCell access={feature.agent} note={feature.agentNote} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { EmployeeRole } from '@prisma/client'
|
||||
|
||||
// Role hierarchy: OWNER > MANAGER > AGENT
|
||||
const ROLE_RANK: Record<EmployeeRole, number> = {
|
||||
OWNER: 3,
|
||||
MANAGER: 2,
|
||||
AGENT: 1,
|
||||
}
|
||||
|
||||
/**
|
||||
* requireRole(minimumRole)
|
||||
* Middleware that blocks requests from employees whose role rank
|
||||
* is below the required minimum.
|
||||
*
|
||||
* Usage:
|
||||
* router.post('/invite', requireRole('OWNER'), handler)
|
||||
* router.patch('/something', requireRole('MANAGER'), handler)
|
||||
*/
|
||||
export function requireRole(minimumRole: EmployeeRole) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const employee = req.employee
|
||||
|
||||
if (!employee) {
|
||||
return res.status(401).json({
|
||||
error: 'unauthenticated',
|
||||
message: 'Authentication required',
|
||||
statusCode: 401,
|
||||
})
|
||||
}
|
||||
|
||||
const employeeRank = ROLE_RANK[employee.role as EmployeeRole] ?? 0
|
||||
const requiredRank = ROLE_RANK[minimumRole] ?? 99
|
||||
|
||||
if (employeeRank < requiredRank) {
|
||||
return res.status(403).json({
|
||||
error: 'forbidden',
|
||||
message: `This action requires the ${minimumRole} role or higher`,
|
||||
statusCode: 403,
|
||||
requiredRole: minimumRole,
|
||||
yourRole: employee.role,
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
listEmployees,
|
||||
inviteEmployee,
|
||||
updateEmployeeRole,
|
||||
deactivateEmployee,
|
||||
reactivateEmployee,
|
||||
removeEmployee,
|
||||
} from '../services/teamService'
|
||||
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
|
||||
import { requireTenant } from '../middleware/requireTenant'
|
||||
import { requireSubscription } from '../middleware/requireSubscription'
|
||||
import { requireRole } from '../middleware/requireRole'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// All team routes require a valid company employee session + active subscription
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
|
||||
// ─── Validation schemas ───────────────────────────────────────
|
||||
|
||||
const inviteSchema = z.object({
|
||||
firstName: z.string().min(1).max(64),
|
||||
lastName: z.string().min(1).max(64),
|
||||
email: z.string().email(),
|
||||
role: z.enum(['MANAGER', 'AGENT']), // OWNER cannot be invited
|
||||
})
|
||||
|
||||
const updateRoleSchema = z.object({
|
||||
role: z.enum(['MANAGER', 'AGENT']),
|
||||
})
|
||||
|
||||
// ─── GET /team ────────────────────────────────────────────────
|
||||
// List all employees for this company.
|
||||
// All roles can view the team list.
|
||||
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const members = await listEmployees(req.companyId)
|
||||
res.json({ data: members })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ─── GET /team/stats ──────────────────────────────────────────
|
||||
// Quick counts for dashboard stat cards.
|
||||
|
||||
router.get('/stats', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const members = await listEmployees(req.companyId)
|
||||
const stats = {
|
||||
total: members.length,
|
||||
active: members.filter((m) => m.isActive && m.invitationStatus === 'accepted').length,
|
||||
pending: members.filter((m) => m.invitationStatus === 'pending').length,
|
||||
inactive: members.filter((m) => !m.isActive && m.invitationStatus === 'accepted').length,
|
||||
}
|
||||
res.json({ data: stats })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ─── POST /team/invite ────────────────────────────────────────
|
||||
// Invite a new employee by email. OWNER only.
|
||||
|
||||
router.post(
|
||||
'/invite',
|
||||
requireRole('OWNER'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const body = inviteSchema.parse(req.body)
|
||||
const result = await inviteEmployee(
|
||||
req.companyId,
|
||||
req.employee.id,
|
||||
body
|
||||
)
|
||||
res.status(201).json({ data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ─── PATCH /team/:id/role ─────────────────────────────────────
|
||||
// Change an employee's role. OWNER only.
|
||||
|
||||
router.patch(
|
||||
'/:id/role',
|
||||
requireRole('OWNER'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const body = updateRoleSchema.parse(req.body)
|
||||
const updated = await updateEmployeeRole(
|
||||
req.companyId,
|
||||
req.employee.id,
|
||||
req.employee.role,
|
||||
req.params.id,
|
||||
body
|
||||
)
|
||||
res.json({ data: updated })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ─── POST /team/:id/deactivate ────────────────────────────────
|
||||
// Suspend access without deleting the record. OWNER only.
|
||||
|
||||
router.post(
|
||||
'/:id/deactivate',
|
||||
requireRole('OWNER'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const updated = await deactivateEmployee(
|
||||
req.companyId,
|
||||
req.employee.role,
|
||||
req.params.id
|
||||
)
|
||||
res.json({ data: updated })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ─── POST /team/:id/reactivate ────────────────────────────────
|
||||
// Restore a deactivated employee's access. OWNER only.
|
||||
|
||||
router.post(
|
||||
'/:id/reactivate',
|
||||
requireRole('OWNER'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const updated = await reactivateEmployee(
|
||||
req.companyId,
|
||||
req.employee.role,
|
||||
req.params.id
|
||||
)
|
||||
res.json({ data: updated })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ─── DELETE /team/:id ─────────────────────────────────────────
|
||||
// Permanently remove an employee + revoke their Clerk session/invite. OWNER only.
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('OWNER'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await removeEmployee(
|
||||
req.companyId,
|
||||
req.employee.role,
|
||||
req.params.id
|
||||
)
|
||||
res.json({ data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,337 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTeam, TeamMember } from '../hooks/useTeam'
|
||||
import InviteModal from '../components/team/InviteModal'
|
||||
import EditMemberModal from '../components/team/EditMemberModal'
|
||||
import PermissionsMatrix from '../components/team/PermissionsMatrix'
|
||||
import { useEmployee } from '@/hooks/useEmployee' // your existing auth hook
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
function initials(m: TeamMember) {
|
||||
return (m.firstName[0] ?? '') + (m.lastName[0] ?? '')
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string | null) {
|
||||
if (!dateStr) return 'Never'
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 2) return 'Just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs < 24) return `${hrs}h ago`
|
||||
const days = Math.floor(hrs / 24)
|
||||
if (days < 30) return `${days}d ago`
|
||||
return new Date(dateStr).toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
const AVATAR_BG: string[] = [
|
||||
'bg-violet-100 dark:bg-violet-900/40 text-violet-700 dark:text-violet-300',
|
||||
'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300',
|
||||
'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300',
|
||||
'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300',
|
||||
'bg-pink-100 dark:bg-pink-900/40 text-pink-700 dark:text-pink-300',
|
||||
]
|
||||
|
||||
function avatarColor(id: string) {
|
||||
const hash = id.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0)
|
||||
return AVATAR_BG[hash % AVATAR_BG.length]
|
||||
}
|
||||
|
||||
function RoleBadge({ role }: { role: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
OWNER: 'bg-violet-50 dark:bg-violet-950/40 text-violet-700 dark:text-violet-300 border-violet-100 dark:border-violet-900/50',
|
||||
MANAGER: 'bg-blue-50 dark:bg-blue-950/40 text-blue-700 dark:text-blue-300 border-blue-100 dark:border-blue-900/50',
|
||||
AGENT: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border-zinc-200 dark:border-zinc-700',
|
||||
}
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium ${styles[role] ?? styles.AGENT}`}>
|
||||
{role.charAt(0) + role.slice(1).toLowerCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ member }: { member: TeamMember }) {
|
||||
if (member.invitationStatus === 'pending') {
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border-amber-100 dark:border-amber-900/50">
|
||||
Pending invite
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (!member.isActive) {
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-500 border-zinc-200 dark:border-zinc-700">
|
||||
Deactivated
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-100 dark:border-green-900/50">
|
||||
Active
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────
|
||||
|
||||
export default function TeamPage() {
|
||||
const { employee: currentEmployee } = useEmployee()
|
||||
const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam()
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [roleFilter, setRoleFilter] = useState<string>('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||
const [inviteOpen, setInviteOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<TeamMember | null>(null)
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
|
||||
const isOwner = currentEmployee?.role === 'OWNER'
|
||||
|
||||
function showToast(msg: string) {
|
||||
setToast(msg)
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return members.filter((m) => {
|
||||
const q = search.toLowerCase()
|
||||
const matchQ = !q ||
|
||||
`${m.firstName} ${m.lastName}`.toLowerCase().includes(q) ||
|
||||
m.email.toLowerCase().includes(q)
|
||||
const matchRole = !roleFilter || m.role === roleFilter
|
||||
const matchStatus =
|
||||
!statusFilter ||
|
||||
(statusFilter === 'active' && m.isActive && m.invitationStatus === 'accepted') ||
|
||||
(statusFilter === 'pending' && m.invitationStatus === 'pending') ||
|
||||
(statusFilter === 'inactive' && !m.isActive && m.invitationStatus === 'accepted')
|
||||
return matchQ && matchRole && matchStatus
|
||||
})
|
||||
}, [members, search, roleFilter, statusFilter])
|
||||
|
||||
// ── Handlers with toasts ───────────────────────────────────
|
||||
|
||||
const handleInvite: typeof invite = async (payload) => {
|
||||
await invite(payload)
|
||||
showToast(`Invite sent to ${payload.email}`)
|
||||
}
|
||||
|
||||
const handleUpdateRole = async (id: string, role: 'MANAGER' | 'AGENT') => {
|
||||
await updateRole(id, role)
|
||||
showToast('Role updated')
|
||||
}
|
||||
|
||||
const handleDeactivate = async (id: string) => {
|
||||
await deactivate(id)
|
||||
showToast('Member deactivated')
|
||||
}
|
||||
|
||||
const handleReactivate = async (id: string) => {
|
||||
await reactivate(id)
|
||||
showToast('Member reactivated')
|
||||
}
|
||||
|
||||
const handleRemove = async (id: string) => {
|
||||
await remove(id)
|
||||
showToast('Member removed')
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-8 px-4 sm:px-6">
|
||||
|
||||
{/* Page header */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-medium text-zinc-900 dark:text-white">Team</h1>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
Manage your employees and their access levels
|
||||
</p>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => setInviteOpen(true)}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 16 16">
|
||||
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
Invite member
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
||||
{[
|
||||
{ label: 'Total members', value: stats.total },
|
||||
{ label: 'Active', value: stats.active },
|
||||
{ label: 'Pending invite', value: stats.pending },
|
||||
{ label: 'Deactivated', value: stats.inactive },
|
||||
].map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-4 flex flex-col gap-1"
|
||||
>
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">{s.label}</span>
|
||||
<span className="text-2xl font-medium text-zinc-900 dark:text-white">{s.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<div className="relative flex-1 min-w-48">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-400" fill="none" viewBox="0 0 14 14">
|
||||
<circle cx="6" cy="6" r="4.5" stroke="currentColor" strokeWidth="1.2" />
|
||||
<path d="M10 10L13 13" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or email…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter(e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
|
||||
>
|
||||
<option value="">All roles</option>
|
||||
<option value="OWNER">Owner</option>
|
||||
<option value="MANAGER">Manager</option>
|
||||
<option value="AGENT">Agent</option>
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="inactive">Deactivated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-10">
|
||||
{loading ? (
|
||||
<div className="py-16 flex flex-col items-center gap-2 text-zinc-400">
|
||||
<div className="w-5 h-5 border-2 border-zinc-200 dark:border-zinc-700 border-t-zinc-500 rounded-full animate-spin" />
|
||||
<span className="text-sm">Loading team…</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-12 text-center text-sm text-red-500">{error}</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">No members match your filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Member</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Role</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Status</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide hidden sm:table-cell">Last active</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((member, i) => (
|
||||
<tr
|
||||
key={member.id}
|
||||
className={[
|
||||
'border-b border-zinc-100 dark:border-zinc-800 last:border-0',
|
||||
'hover:bg-zinc-50 dark:hover:bg-zinc-800/30 transition-colors',
|
||||
].join(' ')}
|
||||
>
|
||||
{/* Member */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${avatarColor(member.id)}`}>
|
||||
{initials(member)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-zinc-900 dark:text-white">
|
||||
{member.firstName} {member.lastName}
|
||||
{member.id === currentEmployee?.id && (
|
||||
<span className="ml-1.5 text-[10px] text-zinc-400">(you)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Role */}
|
||||
<td className="px-4 py-3">
|
||||
<RoleBadge role={member.role} />
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge member={member} />
|
||||
</td>
|
||||
|
||||
{/* Last active */}
|
||||
<td className="px-4 py-3 hidden sm:table-cell">
|
||||
<span className="text-xs text-zinc-400 dark:text-zinc-500">
|
||||
{member.invitationStatus === 'pending' ? '—' : timeAgo(member.lastActiveAt)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-3 text-right">
|
||||
{isOwner && member.role !== 'OWNER' && (
|
||||
<button
|
||||
onClick={() => setEditTarget(member)}
|
||||
className="text-xs px-3 py-1.5 border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Permissions matrix */}
|
||||
<PermissionsMatrix />
|
||||
|
||||
{/* Modals */}
|
||||
<InviteModal
|
||||
open={inviteOpen}
|
||||
onClose={() => setInviteOpen(false)}
|
||||
onInvite={handleInvite}
|
||||
/>
|
||||
<EditMemberModal
|
||||
member={editTarget}
|
||||
open={!!editTarget}
|
||||
onClose={() => setEditTarget(null)}
|
||||
onUpdateRole={handleUpdateRole}
|
||||
onDeactivate={handleDeactivate}
|
||||
onReactivate={handleReactivate}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-2 px-4 py-2.5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-xl shadow-lg text-sm font-medium animate-in slide-in-from-bottom-2 duration-200">
|
||||
<svg className="w-4 h-4 text-green-400 dark:text-green-600" fill="none" viewBox="0 0 16 16">
|
||||
<path d="M3 8L6.5 11.5L13 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import { PrismaClient, EmployeeRole } from '@prisma/client'
|
||||
import { clerkClient } from '@clerk/clerk-sdk-node'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────
|
||||
|
||||
export interface InvitePayload {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
role: EmployeeRole
|
||||
}
|
||||
|
||||
export interface UpdateRolePayload {
|
||||
role: EmployeeRole
|
||||
}
|
||||
|
||||
export interface TeamMember {
|
||||
id: string
|
||||
clerkUserId: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string | null
|
||||
role: EmployeeRole
|
||||
isActive: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
// hydrated from Clerk
|
||||
lastActiveAt?: Date | null
|
||||
invitationStatus?: 'accepted' | 'pending' | 'revoked'
|
||||
}
|
||||
|
||||
// ─── List employees ───────────────────────────────────────────
|
||||
|
||||
export async function listEmployees(companyId: string): Promise<TeamMember[]> {
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { companyId },
|
||||
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
|
||||
// Hydrate last-active timestamps from Clerk in parallel
|
||||
const hydrated = await Promise.allSettled(
|
||||
employees.map(async (emp) => {
|
||||
try {
|
||||
const clerkUser = await clerkClient.users.getUser(emp.clerkUserId)
|
||||
return {
|
||||
...emp,
|
||||
lastActiveAt: clerkUser.lastActiveAt
|
||||
? new Date(clerkUser.lastActiveAt)
|
||||
: null,
|
||||
invitationStatus: 'accepted' as const,
|
||||
}
|
||||
} catch {
|
||||
// Clerk user not yet accepted invitation
|
||||
return {
|
||||
...emp,
|
||||
lastActiveAt: null,
|
||||
invitationStatus: 'pending' as const,
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return hydrated.map((r) =>
|
||||
r.status === 'fulfilled' ? r.value : (r as any).value
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Get single employee (scoped to company) ──────────────────
|
||||
|
||||
export async function getEmployee(companyId: string, employeeId: string) {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: employeeId, companyId },
|
||||
})
|
||||
if (!employee) {
|
||||
throw Object.assign(new Error('Employee not found'), { statusCode: 404, code: 'employee_not_found' })
|
||||
}
|
||||
return employee
|
||||
}
|
||||
|
||||
// ─── Invite ───────────────────────────────────────────────────
|
||||
// 1. Send a Clerk invitation (creates the Clerk user + sends email)
|
||||
// 2. Create the Employee row immediately so the company sees it in the list
|
||||
// with isActive=false until they accept and log in (Clerk webhook sets isActive=true)
|
||||
|
||||
export async function inviteEmployee(
|
||||
companyId: string,
|
||||
inviterId: string,
|
||||
payload: InvitePayload
|
||||
) {
|
||||
const { firstName, lastName, email, role } = payload
|
||||
|
||||
// Owners cannot be invited — must be the account creator
|
||||
if (role === 'OWNER') {
|
||||
throw Object.assign(
|
||||
new Error('Cannot invite a member with the OWNER role'),
|
||||
{ statusCode: 400, code: 'invalid_role' }
|
||||
)
|
||||
}
|
||||
|
||||
// Check for existing active employee with that email in this company
|
||||
const existing = await prisma.employee.findFirst({
|
||||
where: { companyId, email },
|
||||
})
|
||||
if (existing) {
|
||||
throw Object.assign(
|
||||
new Error('An employee with this email already exists in your team'),
|
||||
{ statusCode: 409, code: 'employee_already_exists' }
|
||||
)
|
||||
}
|
||||
|
||||
// Get the company's subdomain/name for the invitation email redirect URL
|
||||
const company = await prisma.company.findUniqueOrThrow({
|
||||
where: { id: companyId },
|
||||
include: { brand: { select: { subdomain: true, displayName: true } } },
|
||||
})
|
||||
|
||||
// Send Clerk invitation
|
||||
// Clerk will create the user and email them a magic link
|
||||
let clerkInvitation: Awaited<ReturnType<typeof clerkClient.invitations.createInvitation>>
|
||||
|
||||
try {
|
||||
clerkInvitation = await clerkClient.invitations.createInvitation({
|
||||
emailAddress: email,
|
||||
redirectUrl: `${process.env.DASHBOARD_URL}/onboarding/accept-invite`,
|
||||
publicMetadata: {
|
||||
companyId,
|
||||
companyName: company.brand?.displayName ?? company.name,
|
||||
role,
|
||||
invitedBy: inviterId,
|
||||
},
|
||||
})
|
||||
} catch (err: any) {
|
||||
// Clerk throws if email already has a Clerk account with a pending invite
|
||||
if (err?.errors?.[0]?.code === 'duplicate_record') {
|
||||
throw Object.assign(
|
||||
new Error('This email already has a pending invitation'),
|
||||
{ statusCode: 409, code: 'duplicate_invitation' }
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
// Create the Employee record with a placeholder clerkUserId
|
||||
// It will be updated when the user accepts the invite (via Clerk webhook)
|
||||
const employee = await prisma.employee.create({
|
||||
data: {
|
||||
companyId,
|
||||
clerkUserId: `pending_${clerkInvitation.id}`, // replaced by webhook
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
role,
|
||||
isActive: false, // activated on invite acceptance
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
employee,
|
||||
invitationId: clerkInvitation.id,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Update role ──────────────────────────────────────────────
|
||||
|
||||
export async function updateEmployeeRole(
|
||||
companyId: string,
|
||||
requesterId: string,
|
||||
requesterRole: EmployeeRole,
|
||||
employeeId: string,
|
||||
payload: UpdateRolePayload
|
||||
) {
|
||||
const target = await getEmployee(companyId, employeeId)
|
||||
|
||||
// Only OWNER can change roles
|
||||
if (requesterRole !== 'OWNER') {
|
||||
throw Object.assign(
|
||||
new Error('Only the account owner can change team member roles'),
|
||||
{ statusCode: 403, code: 'forbidden' }
|
||||
)
|
||||
}
|
||||
|
||||
// Cannot change an OWNER's role (there must always be one owner)
|
||||
if (target.role === 'OWNER') {
|
||||
throw Object.assign(
|
||||
new Error('Cannot change the role of the account owner'),
|
||||
{ statusCode: 400, code: 'cannot_change_owner_role' }
|
||||
)
|
||||
}
|
||||
|
||||
// Cannot assign OWNER role via this endpoint
|
||||
if (payload.role === 'OWNER') {
|
||||
throw Object.assign(
|
||||
new Error('Cannot assign the OWNER role via this endpoint'),
|
||||
{ statusCode: 400, code: 'invalid_role' }
|
||||
)
|
||||
}
|
||||
|
||||
// Prevent self-role-change (edge case)
|
||||
if (target.id === requesterId) {
|
||||
throw Object.assign(
|
||||
new Error('You cannot change your own role'),
|
||||
{ statusCode: 400, code: 'cannot_change_own_role' }
|
||||
)
|
||||
}
|
||||
|
||||
const updated = await prisma.employee.update({
|
||||
where: { id: employeeId },
|
||||
data: { role: payload.role },
|
||||
})
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
// ─── Deactivate ───────────────────────────────────────────────
|
||||
|
||||
export async function deactivateEmployee(
|
||||
companyId: string,
|
||||
requesterRole: EmployeeRole,
|
||||
employeeId: string
|
||||
) {
|
||||
const target = await getEmployee(companyId, employeeId)
|
||||
|
||||
if (requesterRole !== 'OWNER') {
|
||||
throw Object.assign(
|
||||
new Error('Only the account owner can deactivate team members'),
|
||||
{ statusCode: 403, code: 'forbidden' }
|
||||
)
|
||||
}
|
||||
|
||||
if (target.role === 'OWNER') {
|
||||
throw Object.assign(
|
||||
new Error('Cannot deactivate the account owner'),
|
||||
{ statusCode: 400, code: 'cannot_deactivate_owner' }
|
||||
)
|
||||
}
|
||||
|
||||
// Revoke Clerk session so they are immediately signed out
|
||||
if (!target.clerkUserId.startsWith('pending_')) {
|
||||
try {
|
||||
await clerkClient.users.banUser(target.clerkUserId)
|
||||
} catch {
|
||||
// Non-fatal — still deactivate in DB
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.employee.update({
|
||||
where: { id: employeeId },
|
||||
data: { isActive: false },
|
||||
})
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
// ─── Reactivate ───────────────────────────────────────────────
|
||||
|
||||
export async function reactivateEmployee(
|
||||
companyId: string,
|
||||
requesterRole: EmployeeRole,
|
||||
employeeId: string
|
||||
) {
|
||||
if (requesterRole !== 'OWNER') {
|
||||
throw Object.assign(
|
||||
new Error('Only the account owner can reactivate team members'),
|
||||
{ statusCode: 403, code: 'forbidden' }
|
||||
)
|
||||
}
|
||||
|
||||
const target = await getEmployee(companyId, employeeId)
|
||||
|
||||
if (!target.clerkUserId.startsWith('pending_')) {
|
||||
try {
|
||||
await clerkClient.users.unbanUser(target.clerkUserId)
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.employee.update({
|
||||
where: { id: employeeId },
|
||||
data: { isActive: true },
|
||||
})
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
// ─── Remove (hard delete) ─────────────────────────────────────
|
||||
// Only OWNER can remove. Removes employee row + revokes Clerk invite if pending.
|
||||
|
||||
export async function removeEmployee(
|
||||
companyId: string,
|
||||
requesterRole: EmployeeRole,
|
||||
employeeId: string
|
||||
) {
|
||||
if (requesterRole !== 'OWNER') {
|
||||
throw Object.assign(
|
||||
new Error('Only the account owner can remove team members'),
|
||||
{ statusCode: 403, code: 'forbidden' }
|
||||
)
|
||||
}
|
||||
|
||||
const target = await getEmployee(companyId, employeeId)
|
||||
|
||||
if (target.role === 'OWNER') {
|
||||
throw Object.assign(
|
||||
new Error('Cannot remove the account owner'),
|
||||
{ statusCode: 400, code: 'cannot_remove_owner' }
|
||||
)
|
||||
}
|
||||
|
||||
// Revoke pending Clerk invitation
|
||||
if (target.clerkUserId.startsWith('pending_')) {
|
||||
const inviteId = target.clerkUserId.replace('pending_', '')
|
||||
try {
|
||||
await clerkClient.invitations.revokeInvitation(inviteId)
|
||||
} catch {
|
||||
// Already accepted or expired — safe to ignore
|
||||
}
|
||||
} else {
|
||||
// Ban the Clerk user so they can't log in
|
||||
try {
|
||||
await clerkClient.users.banUser(target.clerkUserId)
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.employee.delete({ where: { id: employeeId } })
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ─── Clerk webhook handler ────────────────────────────────────
|
||||
// Called when a user accepts their invitation. Updates the Employee row
|
||||
// with the real Clerk user ID and activates the account.
|
||||
|
||||
export async function handleInvitationAccepted(
|
||||
clerkUserId: string,
|
||||
email: string,
|
||||
companyId: string,
|
||||
role: EmployeeRole
|
||||
) {
|
||||
const pendingEmployee = await prisma.employee.findFirst({
|
||||
where: {
|
||||
companyId,
|
||||
email,
|
||||
clerkUserId: { startsWith: 'pending_' },
|
||||
},
|
||||
})
|
||||
|
||||
if (!pendingEmployee) return null
|
||||
|
||||
const activated = await prisma.employee.update({
|
||||
where: { id: pendingEmployee.id },
|
||||
data: {
|
||||
clerkUserId,
|
||||
isActive: true,
|
||||
role, // in case it was updated between invite and acceptance
|
||||
},
|
||||
})
|
||||
|
||||
return activated
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
|
||||
export type InvitationStatus = 'accepted' | 'pending' | 'revoked'
|
||||
|
||||
export interface TeamMember {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string | null
|
||||
role: EmployeeRole
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastActiveAt: string | null
|
||||
invitationStatus: InvitationStatus
|
||||
}
|
||||
|
||||
export interface TeamStats {
|
||||
total: number
|
||||
active: number
|
||||
pending: number
|
||||
inactive: number
|
||||
}
|
||||
|
||||
export interface InvitePayload {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
role: 'MANAGER' | 'AGENT'
|
||||
}
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'https://api.RentalDriveGo.com/api/v1'
|
||||
|
||||
async function apiFetch<T>(
|
||||
path: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
...options,
|
||||
})
|
||||
|
||||
const json = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
const err = new Error(json.message ?? 'Request failed') as any
|
||||
err.code = json.error
|
||||
err.statusCode = res.status
|
||||
throw err
|
||||
}
|
||||
|
||||
return json.data as T
|
||||
}
|
||||
|
||||
// ─── useTeam ─────────────────────────────────────────────────
|
||||
|
||||
export function useTeam() {
|
||||
const [members, setMembers] = useState<TeamMember[]>([])
|
||||
const [stats, setStats] = useState<TeamStats>({ total: 0, active: 0, pending: 0, inactive: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchMembers = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [membersData, statsData] = await Promise.all([
|
||||
apiFetch<TeamMember[]>('/team'),
|
||||
apiFetch<TeamStats>('/team/stats'),
|
||||
])
|
||||
setMembers(membersData)
|
||||
setStats(statsData)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchMembers() }, [fetchMembers])
|
||||
|
||||
// ── Invite ─────────────────────────────────────────────────
|
||||
|
||||
const invite = useCallback(async (payload: InvitePayload) => {
|
||||
const result = await apiFetch<{ employee: TeamMember; invitationId: string }>(
|
||||
'/team/invite',
|
||||
{ method: 'POST', body: JSON.stringify(payload) }
|
||||
)
|
||||
// Optimistically add the pending member
|
||||
setMembers((prev) => [...prev, result.employee])
|
||||
setStats((prev) => ({
|
||||
...prev,
|
||||
total: prev.total + 1,
|
||||
pending: prev.pending + 1,
|
||||
}))
|
||||
return result
|
||||
}, [])
|
||||
|
||||
// ── Update role ────────────────────────────────────────────
|
||||
|
||||
const updateRole = useCallback(async (memberId: string, role: 'MANAGER' | 'AGENT') => {
|
||||
const updated = await apiFetch<TeamMember>(`/team/${memberId}/role`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ role }),
|
||||
})
|
||||
setMembers((prev) =>
|
||||
prev.map((m) => (m.id === memberId ? { ...m, role: updated.role } : m))
|
||||
)
|
||||
return updated
|
||||
}, [])
|
||||
|
||||
// ── Deactivate ─────────────────────────────────────────────
|
||||
|
||||
const deactivate = useCallback(async (memberId: string) => {
|
||||
const updated = await apiFetch<TeamMember>(`/team/${memberId}/deactivate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
setMembers((prev) =>
|
||||
prev.map((m) => (m.id === memberId ? { ...m, isActive: false } : m))
|
||||
)
|
||||
setStats((prev) => ({ ...prev, active: prev.active - 1, inactive: prev.inactive + 1 }))
|
||||
return updated
|
||||
}, [])
|
||||
|
||||
// ── Reactivate ─────────────────────────────────────────────
|
||||
|
||||
const reactivate = useCallback(async (memberId: string) => {
|
||||
const updated = await apiFetch<TeamMember>(`/team/${memberId}/reactivate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
setMembers((prev) =>
|
||||
prev.map((m) => (m.id === memberId ? { ...m, isActive: true } : m))
|
||||
)
|
||||
setStats((prev) => ({ ...prev, active: prev.active + 1, inactive: prev.inactive - 1 }))
|
||||
return updated
|
||||
}, [])
|
||||
|
||||
// ── Remove ─────────────────────────────────────────────────
|
||||
|
||||
const remove = useCallback(async (memberId: string) => {
|
||||
await apiFetch<{ success: boolean }>(`/team/${memberId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const target = members.find((m) => m.id === memberId)
|
||||
setMembers((prev) => prev.filter((m) => m.id !== memberId))
|
||||
setStats((prev) => ({
|
||||
...prev,
|
||||
total: prev.total - 1,
|
||||
active: target?.isActive ? prev.active - 1 : prev.active,
|
||||
pending: target?.invitationStatus === 'pending' ? prev.pending - 1 : prev.pending,
|
||||
inactive: !target?.isActive && target?.invitationStatus === 'accepted'
|
||||
? prev.inactive - 1
|
||||
: prev.inactive,
|
||||
}))
|
||||
}, [members])
|
||||
|
||||
return {
|
||||
members,
|
||||
stats,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchMembers,
|
||||
invite,
|
||||
updateRole,
|
||||
deactivate,
|
||||
reactivate,
|
||||
remove,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import { Webhook } from 'svix'
|
||||
import { handleInvitationAccepted } from '../services/teamService'
|
||||
import { EmployeeRole } from '@prisma/client'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/**
|
||||
* POST /webhooks/clerk
|
||||
*
|
||||
* Handles Clerk webhook events. Registered in the Clerk dashboard.
|
||||
* Uses svix signature verification — raw body must be preserved
|
||||
* (use express.raw() before this route, not express.json()).
|
||||
*
|
||||
* Events handled:
|
||||
* - user.created → when an invited employee accepts and creates their account
|
||||
*/
|
||||
router.post(
|
||||
'/clerk',
|
||||
async (req: Request, res: Response) => {
|
||||
const webhookSecret = process.env.CLERK_WEBHOOK_SECRET
|
||||
|
||||
if (!webhookSecret) {
|
||||
console.error('CLERK_WEBHOOK_SECRET is not set')
|
||||
return res.status(500).json({ error: 'Webhook secret not configured' })
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
const wh = new Webhook(webhookSecret)
|
||||
let event: any
|
||||
|
||||
try {
|
||||
event = wh.verify(req.body, {
|
||||
'svix-id': req.headers['svix-id'] as string,
|
||||
'svix-timestamp': req.headers['svix-timestamp'] as string,
|
||||
'svix-signature': req.headers['svix-signature'] as string,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Clerk webhook signature verification failed:', err)
|
||||
return res.status(400).json({ error: 'Invalid webhook signature' })
|
||||
}
|
||||
|
||||
const { type, data } = event
|
||||
|
||||
// ── user.created ──────────────────────────────────────────
|
||||
// Fired when an invited employee accepts their invite and creates
|
||||
// their Clerk account. publicMetadata contains companyId + role
|
||||
// that we embedded when creating the invitation.
|
||||
|
||||
if (type === 'user.created') {
|
||||
const clerkUserId: string = data.id
|
||||
const email: string = data.email_addresses?.[0]?.email_address ?? ''
|
||||
const meta = data.public_metadata ?? {}
|
||||
|
||||
const companyId: string | undefined = meta.companyId
|
||||
const role: EmployeeRole | undefined = meta.role
|
||||
|
||||
if (companyId && role && email) {
|
||||
try {
|
||||
const employee = await handleInvitationAccepted(
|
||||
clerkUserId,
|
||||
email,
|
||||
companyId,
|
||||
role
|
||||
)
|
||||
if (employee) {
|
||||
console.log(
|
||||
`Employee activated: ${employee.firstName} ${employee.lastName} (${employee.id})`
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
// Log but do not return 500 — Clerk will retry on 5xx
|
||||
console.error('Failed to activate employee from Clerk webhook:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ received: true })
|
||||
}
|
||||
)
|
||||
|
||||
export default router
|
||||
@@ -1,509 +0,0 @@
|
||||
# Student Promotion and Enrollment Management Plan
|
||||
|
||||
## 1. Core Rule
|
||||
|
||||
A student who passes the current level should not be automatically placed into the next level.
|
||||
|
||||
The student should first be marked as:
|
||||
|
||||
**Eligible for Promotion**
|
||||
|
||||
After that, the parent or guardian must complete enrollment for the promoted level.
|
||||
|
||||
Once the parent completes enrollment, the student becomes:
|
||||
|
||||
**Promoted and Enrolled**
|
||||
|
||||
No separate school approval is required.
|
||||
|
||||
## 2. Promotion Principle
|
||||
|
||||
Promotion and enrollment are separate steps.
|
||||
|
||||
### Promotion
|
||||
|
||||
Promotion confirms that the student passed the current level and qualifies for the next level.
|
||||
|
||||
### Enrollment
|
||||
|
||||
Enrollment confirms that the parent or guardian wants the student to continue at the school in the promoted level for the next school year.
|
||||
|
||||
Once enrollment is completed by the parent, the student should be officially assigned to the new promoted level.
|
||||
|
||||
## 3. Promotion Statuses
|
||||
|
||||
Recommended statuses:
|
||||
|
||||
- Not Reviewed
|
||||
- Eligible for Promotion
|
||||
- Awaiting Parent Enrollment
|
||||
- Enrollment Started
|
||||
- Promoted and Enrolled
|
||||
- Conditional Promotion
|
||||
- Repeated Level
|
||||
- On Hold
|
||||
- Withdrawn
|
||||
- Graduated
|
||||
- Not Enrolled for Next Year
|
||||
|
||||
## 4. Status Definitions
|
||||
|
||||
### Not Reviewed
|
||||
|
||||
The student has not yet been evaluated for promotion.
|
||||
|
||||
### Eligible for Promotion
|
||||
|
||||
The student passed the current level and qualifies for the next level, but the parent has not started enrollment yet.
|
||||
|
||||
### Awaiting Parent Enrollment
|
||||
|
||||
The student is eligible for the next level, and the system is waiting for the parent to complete enrollment.
|
||||
|
||||
### Enrollment Started
|
||||
|
||||
The parent has started the enrollment process but has not completed all required steps.
|
||||
|
||||
### Promoted and Enrolled
|
||||
|
||||
The parent completed enrollment for the promoted level. The student is officially placed into the next level for the new school year.
|
||||
|
||||
### Conditional Promotion
|
||||
|
||||
The student has pending academic or administrative requirements before promotion eligibility can be finalized.
|
||||
|
||||
Examples:
|
||||
|
||||
- Makeup exam required
|
||||
- Missing final grade
|
||||
- Attendance review
|
||||
- Academic condition pending
|
||||
|
||||
### Repeated Level
|
||||
|
||||
The student did not meet the passing requirements and must repeat the same level.
|
||||
|
||||
### On Hold
|
||||
|
||||
The student cannot continue through the promotion process because of an unresolved issue.
|
||||
|
||||
Examples:
|
||||
|
||||
- Missing academic result
|
||||
- Enrollment form incomplete
|
||||
- Financial hold, if used by school policy
|
||||
- Required documents missing
|
||||
|
||||
### Withdrawn
|
||||
|
||||
The student is no longer continuing at the school.
|
||||
|
||||
### Graduated
|
||||
|
||||
The student completed the final level and does not move to another school level.
|
||||
|
||||
### Not Enrolled for Next Year
|
||||
|
||||
The student passed and was eligible for promotion, but the parent did not complete enrollment before the deadline.
|
||||
|
||||
This should not be treated as failure. It means the student passed but did not continue enrollment.
|
||||
|
||||
## 5. Promotion Eligibility Criteria
|
||||
|
||||
A student may become eligible for promotion when the school confirms that the student passed the current level.
|
||||
|
||||
Eligibility may be based on:
|
||||
|
||||
- Final academic result
|
||||
- Required subject completion
|
||||
- Final average
|
||||
- Attendance requirement, if applicable
|
||||
- Makeup exam result, if applicable
|
||||
- Level completion status
|
||||
|
||||
The school should define these rules clearly per level or program.
|
||||
|
||||
## 6. Updated Workflow
|
||||
|
||||
### Step 1: Academic Result Finalized
|
||||
|
||||
Teachers or academic staff submit the student’s final result for the current level.
|
||||
|
||||
The system records:
|
||||
|
||||
- Final average
|
||||
- Passed or failed result
|
||||
- Attendance result, if required
|
||||
- Current level completion status
|
||||
- Teacher comments, if needed
|
||||
|
||||
### Step 2: Promotion Eligibility Check
|
||||
|
||||
The system checks whether the student passed the current level.
|
||||
|
||||
If the student passed, the system marks the student as:
|
||||
|
||||
**Eligible for Promotion**
|
||||
|
||||
or
|
||||
|
||||
**Awaiting Parent Enrollment**
|
||||
|
||||
The student is not yet moved into the next level.
|
||||
|
||||
### Step 3: Parent Enrollment Required
|
||||
|
||||
The system notifies the parent or guardian that the student is eligible for the next level and must complete enrollment.
|
||||
|
||||
The notification should include:
|
||||
|
||||
- Student name
|
||||
- Current level completed
|
||||
- Promoted level
|
||||
- Enrollment deadline
|
||||
- Required documents, if any
|
||||
- Required payment or deposit, if applicable
|
||||
- Instructions to complete enrollment
|
||||
|
||||
### Step 4: Parent Starts Enrollment
|
||||
|
||||
When the parent begins the enrollment process, the student status becomes:
|
||||
|
||||
**Enrollment Started**
|
||||
|
||||
The enrollment process may include:
|
||||
|
||||
- Confirming student information
|
||||
- Confirming parent or guardian information
|
||||
- Confirming the promoted level
|
||||
- Uploading required documents
|
||||
- Accepting school policies
|
||||
- Paying registration fee or deposit, if applicable
|
||||
- Submitting the enrollment form
|
||||
|
||||
### Step 5: Parent Completes Enrollment
|
||||
|
||||
Once the parent submits all required enrollment information and completes required payment, if applicable, the enrollment is considered complete.
|
||||
|
||||
The student status becomes:
|
||||
|
||||
**Promoted and Enrolled**
|
||||
|
||||
No additional school approval is needed.
|
||||
|
||||
### Step 6: New School-Year Record Created
|
||||
|
||||
After parent enrollment is completed, the system creates the student’s new school-year enrollment record.
|
||||
|
||||
The new record should include:
|
||||
|
||||
- Student ID
|
||||
- Parent ID
|
||||
- New school year
|
||||
- Promoted level
|
||||
- Enrollment status
|
||||
- Enrollment completion date
|
||||
- Source promotion record
|
||||
|
||||
## 7. Decision Logic
|
||||
|
||||
```text
|
||||
IF student did not pass current level:
|
||||
promotion_status = repeated_level
|
||||
|
||||
ELSE IF student passed current level AND parent has not started enrollment:
|
||||
promotion_status = awaiting_parent_enrollment
|
||||
|
||||
ELSE IF student passed current level AND parent started enrollment BUT enrollment incomplete:
|
||||
promotion_status = enrollment_started
|
||||
|
||||
ELSE IF student passed current level AND parent completed enrollment:
|
||||
promotion_status = promoted_and_enrolled
|
||||
|
||||
ELSE:
|
||||
promotion_status = on_hold
|
||||
```
|
||||
|
||||
## 8. Enrollment Completion Rule
|
||||
|
||||
Enrollment should be considered complete when all required parent-side actions are finished.
|
||||
|
||||
Required actions may include:
|
||||
|
||||
- Enrollment form submitted
|
||||
- Required student information confirmed
|
||||
- Required parent information confirmed
|
||||
- Required documents uploaded
|
||||
- Required agreement accepted
|
||||
- Required registration fee or deposit paid, if applicable
|
||||
|
||||
Once these are complete, the system should automatically finalize the promotion.
|
||||
|
||||
## 9. Record Update Rule
|
||||
|
||||
The system should not overwrite the student’s current-level record.
|
||||
|
||||
Instead, it should:
|
||||
|
||||
1. Keep the current school-year academic record unchanged.
|
||||
2. Create a promotion eligibility record.
|
||||
3. Wait for parent enrollment.
|
||||
4. Create a new enrollment record for the next school year after parent enrollment is complete.
|
||||
5. Assign the student to the promoted level only in the new school-year record.
|
||||
|
||||
## 10. Level Mapping
|
||||
|
||||
The school should maintain a clear level progression map.
|
||||
|
||||
Example:
|
||||
|
||||
| Current Level | Next Level |
|
||||
|---|---|
|
||||
| KG1 | KG2 |
|
||||
| KG2 | Grade 1 |
|
||||
| Grade 1 | Grade 2 |
|
||||
| Grade 2 | Grade 3 |
|
||||
| Grade 3 | Grade 4 |
|
||||
| Grade 4 | Grade 5 |
|
||||
| Grade 5 | Grade 6 |
|
||||
| Grade 6 | Grade 7 |
|
||||
| Grade 7 | Grade 8 |
|
||||
| Grade 8 | Grade 9 |
|
||||
| Grade 9 | Youth |
|
||||
| Youth | Graduated |
|
||||
|
||||
The system should store this mapping in configuration or a dedicated table, not hard-code it inside controller logic.
|
||||
|
||||
## 11. Recommended Data Model Updates
|
||||
|
||||
### student_promotion_records
|
||||
|
||||
Recommended fields:
|
||||
|
||||
- promotion_id
|
||||
- student_id
|
||||
- parent_id
|
||||
- current_school_year
|
||||
- next_school_year
|
||||
- current_level_id
|
||||
- promoted_level_id
|
||||
- promotion_status
|
||||
- passed_current_level
|
||||
- enrollment_required
|
||||
- enrollment_status
|
||||
- enrollment_id
|
||||
- parent_notified_at
|
||||
- enrollment_deadline
|
||||
- enrollment_completed_at
|
||||
- promotion_finalized_at
|
||||
- created_at
|
||||
- updated_at
|
||||
|
||||
### enrollment_applications
|
||||
|
||||
Recommended fields:
|
||||
|
||||
- enrollment_id
|
||||
- student_id
|
||||
- parent_id
|
||||
- school_year
|
||||
- requested_level_id
|
||||
- source_promotion_id
|
||||
- application_status
|
||||
- submitted_at
|
||||
- completed_at
|
||||
- required_documents_status
|
||||
- payment_status
|
||||
- created_at
|
||||
- updated_at
|
||||
|
||||
### promotion_conditions
|
||||
|
||||
Recommended condition type:
|
||||
|
||||
- parent_enrollment_required
|
||||
|
||||
Example:
|
||||
|
||||
- condition_type: parent_enrollment_required
|
||||
- description: Parent must complete enrollment for the promoted level.
|
||||
- status: pending
|
||||
- deadline: enrollment deadline date
|
||||
|
||||
## 12. Parent Portal Requirements
|
||||
|
||||
The parent portal should show the parent a clear action.
|
||||
|
||||
Example message:
|
||||
|
||||
“Your child has passed the current level and is eligible for promotion to [Promoted Level]. Please complete enrollment for the new school year by [Deadline].”
|
||||
|
||||
The parent should be able to:
|
||||
|
||||
- View the promoted level
|
||||
- Start enrollment
|
||||
- Confirm student information
|
||||
- Upload required documents
|
||||
- Pay required enrollment fee, if applicable
|
||||
- Submit enrollment
|
||||
- View enrollment status
|
||||
|
||||
## 13. Admin Panel Requirements
|
||||
|
||||
The admin panel should allow staff to view:
|
||||
|
||||
- Students eligible for promotion
|
||||
- Students awaiting parent enrollment
|
||||
- Students with enrollment started
|
||||
- Students promoted and enrolled
|
||||
- Students not enrolled for next year
|
||||
- Students repeating the level
|
||||
- Students on hold
|
||||
|
||||
Admin users should also be able to:
|
||||
|
||||
- Set enrollment deadlines
|
||||
- Send reminders
|
||||
- View enrollment completion status
|
||||
- Export pending enrollment lists
|
||||
- View promotion history
|
||||
|
||||
## 14. Reminder Process
|
||||
|
||||
The system should send reminders to parents who have not completed enrollment.
|
||||
|
||||
Suggested reminders:
|
||||
|
||||
- First reminder after student becomes eligible
|
||||
- Second reminder halfway before deadline
|
||||
- Final reminder before deadline
|
||||
- Expiration notice after deadline
|
||||
|
||||
Each reminder should be logged.
|
||||
|
||||
## 15. Deadline Handling
|
||||
|
||||
If the parent does not complete enrollment before the deadline, the student should be marked as:
|
||||
|
||||
**Not Enrolled for Next Year**
|
||||
|
||||
This means:
|
||||
|
||||
- The student passed the current level.
|
||||
- The student was eligible for promotion.
|
||||
- The parent did not complete enrollment.
|
||||
- The student should not be counted as enrolled for the next school year.
|
||||
|
||||
## 16. Reports
|
||||
|
||||
The system should generate reports for:
|
||||
|
||||
- Students eligible for promotion
|
||||
- Students awaiting parent enrollment
|
||||
- Students with enrollment started
|
||||
- Students promoted and enrolled
|
||||
- Students not enrolled for next year
|
||||
- Students repeating the level
|
||||
- Students on hold
|
||||
- Promotion summary by current level
|
||||
- Enrollment completion summary
|
||||
- Parent enrollment pending list
|
||||
|
||||
## 17. Permissions
|
||||
|
||||
Access should be controlled by role.
|
||||
|
||||
### Teacher
|
||||
|
||||
Can submit academic results and promotion recommendations.
|
||||
|
||||
### Academic Coordinator
|
||||
|
||||
Can review academic eligibility and promotion readiness.
|
||||
|
||||
### Parent or Guardian
|
||||
|
||||
Can complete enrollment for the promoted level.
|
||||
|
||||
### Registrar or Admin Staff
|
||||
|
||||
Can view promotion and enrollment status, send reminders, and export reports.
|
||||
|
||||
### Administrator
|
||||
|
||||
Can configure levels, promotion rules, deadlines, and system settings.
|
||||
|
||||
## 18. Audit Trail
|
||||
|
||||
Every promotion and enrollment action should be logged.
|
||||
|
||||
Audit log should track:
|
||||
|
||||
- User who made the action
|
||||
- Date and time
|
||||
- Student affected
|
||||
- Old value
|
||||
- New value
|
||||
- Action type
|
||||
- Notes, if any
|
||||
|
||||
Important audited actions:
|
||||
|
||||
- Academic result submitted
|
||||
- Student marked eligible for promotion
|
||||
- Parent notified
|
||||
- Enrollment started
|
||||
- Enrollment completed
|
||||
- Student marked promoted and enrolled
|
||||
- Student marked not enrolled for next year
|
||||
- Manual status changes
|
||||
|
||||
## 19. Implementation Phases
|
||||
|
||||
### Phase 1: Promotion Rules and Level Mapping
|
||||
|
||||
- Create level progression table.
|
||||
- Create promotion status rules.
|
||||
- Define final levels.
|
||||
- Define repeat-level behavior.
|
||||
|
||||
### Phase 2: Eligibility Engine
|
||||
|
||||
- Build academic eligibility check.
|
||||
- Generate promotion eligibility records.
|
||||
- Mark eligible students as awaiting parent enrollment.
|
||||
|
||||
### Phase 3: Parent Enrollment Flow
|
||||
|
||||
- Build parent enrollment action.
|
||||
- Allow parent to confirm information.
|
||||
- Allow parent to upload documents, if needed.
|
||||
- Allow parent to pay registration fee or deposit, if applicable.
|
||||
- Mark enrollment complete automatically when required steps are done.
|
||||
|
||||
### Phase 4: Record Update
|
||||
|
||||
- Create next-year enrollment record after parent enrollment completion.
|
||||
- Assign promoted level only in the new school-year record.
|
||||
- Preserve current-year academic history.
|
||||
|
||||
### Phase 5: Notifications and Reports
|
||||
|
||||
- Notify parents when students become eligible.
|
||||
- Send reminders before deadline.
|
||||
- Generate pending enrollment reports.
|
||||
- Generate promoted and enrolled reports.
|
||||
|
||||
## 20. Final Rule
|
||||
|
||||
The system should follow this rule:
|
||||
|
||||
**Passed Current Level + Parent Completed Enrollment = Promoted and Enrolled**
|
||||
|
||||
Therefore:
|
||||
|
||||
**Passed ≠ Enrolled**
|
||||
|
||||
**Eligible for Promotion ≠ Promoted and Enrolled**
|
||||
|
||||
**Promoted and Enrolled = Student passed current level + Parent completed enrollment**
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,615 @@
|
||||
# Website Admin Menu Management Plan for Company Users
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Build a website admin system that allows platform administrators to manage menu items shown to company users.
|
||||
|
||||
The menu should be controlled centrally by website admins. Companies should automatically receive default menu items based on their subscription plan. Website admins should also be able to add extra menu items for individual companies when needed.
|
||||
|
||||
Company admins should not manage menus unless this is introduced later as a separate feature.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Concept
|
||||
|
||||
The final menu shown to a company user should come from two sources:
|
||||
|
||||
```text
|
||||
Final User Menu = Subscription Default Menu Items + Company-Specific Menu Items
|
||||
```
|
||||
|
||||
Where:
|
||||
|
||||
```text
|
||||
Subscription Default Menu Items:
|
||||
Managed by website admins and assigned based on subscription plan.
|
||||
|
||||
Company-Specific Menu Items:
|
||||
Managed by website admins and assigned to one company or selected companies.
|
||||
```
|
||||
|
||||
This creates centralized control and avoids letting every company modify the navigation structure independently.
|
||||
|
||||
---
|
||||
|
||||
## 3. Admin Roles
|
||||
|
||||
The system should support website-level admin roles.
|
||||
|
||||
Suggested roles:
|
||||
|
||||
```text
|
||||
Super Admin:
|
||||
- Full access to all menu management features
|
||||
- Can create, edit, delete, enable, and disable menu items
|
||||
- Can manage subscription menu templates
|
||||
- Can assign custom menu items to specific companies
|
||||
|
||||
Website Admin:
|
||||
- Can manage menu items
|
||||
- Can assign menu items to companies
|
||||
- Cannot change billing or subscription rules unless allowed
|
||||
|
||||
Support Admin:
|
||||
- Can view company menus
|
||||
- Can preview menus for troubleshooting
|
||||
- Cannot create or edit menu items
|
||||
|
||||
Company User:
|
||||
- Can only see the final menu generated for their company and role
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Subscription-Based Default Menus
|
||||
|
||||
Each subscription plan should have a menu template managed by website admins.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Basic Plan:
|
||||
- Dashboard
|
||||
- Profile
|
||||
- Reports
|
||||
|
||||
Pro Plan:
|
||||
- Dashboard
|
||||
- Profile
|
||||
- Reports
|
||||
- Team Management
|
||||
- Advanced Analytics
|
||||
|
||||
Enterprise Plan:
|
||||
- Dashboard
|
||||
- Profile
|
||||
- Reports
|
||||
- Team Management
|
||||
- Advanced Analytics
|
||||
- Integrations
|
||||
- Audit Logs
|
||||
```
|
||||
|
||||
When a company is assigned to a subscription plan, the system automatically uses that plan’s menu template.
|
||||
|
||||
Website admins should be able to:
|
||||
|
||||
```text
|
||||
- Create subscription menu templates
|
||||
- Edit default menu items for a subscription
|
||||
- Reorder default menu items
|
||||
- Enable or disable menu items
|
||||
- Mark menu items as required
|
||||
- Assign menu items to one or more subscription plans
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Company-Specific Menu Items
|
||||
|
||||
Website admins should be able to add menu items for a specific company without changing the global subscription template.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Company A has Pro Plan:
|
||||
- Gets all Pro default menu items
|
||||
- Also gets custom item: Finance Portal
|
||||
|
||||
Company B has Pro Plan:
|
||||
- Gets all Pro default menu items
|
||||
- Does not get Finance Portal
|
||||
```
|
||||
|
||||
This allows exceptions without changing the subscription plan for every company.
|
||||
|
||||
---
|
||||
|
||||
## 6. Menu Item Types
|
||||
|
||||
The system should support these menu item types:
|
||||
|
||||
```text
|
||||
Internal Page:
|
||||
Links to a page inside the application.
|
||||
|
||||
External Link:
|
||||
Links to an outside tool or website.
|
||||
|
||||
Parent Menu:
|
||||
A dropdown or folder that contains child items.
|
||||
|
||||
Section Label:
|
||||
A visual grouping label.
|
||||
|
||||
Divider:
|
||||
A visual separator.
|
||||
```
|
||||
|
||||
Each menu item should include:
|
||||
|
||||
```text
|
||||
- Name / label
|
||||
- Type
|
||||
- Route or URL
|
||||
- Icon
|
||||
- Parent menu item
|
||||
- Display order
|
||||
- Status: Active or Inactive
|
||||
- Open behavior: Same tab or new tab
|
||||
- Required or optional
|
||||
- Subscription availability
|
||||
- Company assignment
|
||||
- Role visibility
|
||||
- Created by
|
||||
- Updated by
|
||||
- Created date
|
||||
- Updated date
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Website Admin Features
|
||||
|
||||
The website admin panel should include a **Menu Management** section.
|
||||
|
||||
Website admins should be able to:
|
||||
|
||||
```text
|
||||
- View all menu items
|
||||
- Create new menu items
|
||||
- Edit existing menu items
|
||||
- Enable or disable menu items
|
||||
- Reorder menu items
|
||||
- Assign menu items to subscription plans
|
||||
- Assign menu items to individual companies
|
||||
- Assign menu items to user roles
|
||||
- Preview a company user’s menu
|
||||
- View menu history and audit logs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Menu Visibility Rules
|
||||
|
||||
The system should calculate visibility using this order:
|
||||
|
||||
```text
|
||||
1. Subscription entitlement
|
||||
2. Company-specific assignment
|
||||
3. User role
|
||||
4. User status
|
||||
5. Menu item status
|
||||
```
|
||||
|
||||
A user should see a menu item only if:
|
||||
|
||||
```text
|
||||
- The item is active
|
||||
- The user belongs to a company
|
||||
- The company has access through subscription or direct assignment
|
||||
- The user’s role is allowed to see it
|
||||
- The user account is active
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Important Rule: Menu Visibility Is Not Security
|
||||
|
||||
The menu only controls what users see in navigation.
|
||||
|
||||
It must not be used as the only access control layer.
|
||||
|
||||
Backend APIs and protected pages must still check:
|
||||
|
||||
```text
|
||||
- User authentication
|
||||
- Company access
|
||||
- Role permissions
|
||||
- Subscription access
|
||||
- Feature entitlement
|
||||
```
|
||||
|
||||
A hidden menu item does not mean the page is secure. Users can still guess URLs, share links, or hit APIs directly.
|
||||
|
||||
---
|
||||
|
||||
## 10. Subscription Change Behavior
|
||||
|
||||
When a company upgrades or downgrades, the menu should update automatically.
|
||||
|
||||
### Upgrade Example
|
||||
|
||||
```text
|
||||
Company moves from Basic to Pro:
|
||||
- Pro default menu items become visible
|
||||
- Existing company-specific menu items remain unchanged
|
||||
```
|
||||
|
||||
### Downgrade Example
|
||||
|
||||
```text
|
||||
Company moves from Pro to Basic:
|
||||
- Pro-only default menu items become hidden
|
||||
- Company-specific items remain only if they do not depend on Pro-only features
|
||||
```
|
||||
|
||||
Do not delete menu history or company-specific assignments during subscription changes.
|
||||
|
||||
Instead, mark unavailable items as:
|
||||
|
||||
```text
|
||||
Unavailable due to subscription
|
||||
```
|
||||
|
||||
This allows website admins to understand why an item is not visible.
|
||||
|
||||
---
|
||||
|
||||
## 11. Database Model
|
||||
|
||||
### subscription_plans
|
||||
|
||||
```text
|
||||
id
|
||||
name
|
||||
description
|
||||
status
|
||||
created_at
|
||||
updated_at
|
||||
```
|
||||
|
||||
### menu_items
|
||||
|
||||
```text
|
||||
id
|
||||
label
|
||||
item_type
|
||||
route_or_url
|
||||
icon
|
||||
parent_id
|
||||
display_order
|
||||
open_in_new_tab
|
||||
is_required
|
||||
status
|
||||
created_by
|
||||
updated_by
|
||||
created_at
|
||||
updated_at
|
||||
```
|
||||
|
||||
This table stores all platform-managed menu items.
|
||||
|
||||
### subscription_menu_items
|
||||
|
||||
```text
|
||||
id
|
||||
subscription_plan_id
|
||||
menu_item_id
|
||||
display_order
|
||||
status
|
||||
created_at
|
||||
updated_at
|
||||
```
|
||||
|
||||
This table maps menu items to subscription plans.
|
||||
|
||||
### company_menu_items
|
||||
|
||||
```text
|
||||
id
|
||||
company_id
|
||||
menu_item_id
|
||||
display_order
|
||||
status
|
||||
created_at
|
||||
updated_at
|
||||
```
|
||||
|
||||
This table maps extra website-admin-assigned menu items to specific companies.
|
||||
|
||||
### menu_item_role_visibility
|
||||
|
||||
```text
|
||||
id
|
||||
menu_item_id
|
||||
role_id
|
||||
created_at
|
||||
updated_at
|
||||
```
|
||||
|
||||
This table controls which roles can see each item.
|
||||
|
||||
### company_subscriptions
|
||||
|
||||
```text
|
||||
id
|
||||
company_id
|
||||
subscription_plan_id
|
||||
status
|
||||
start_date
|
||||
end_date
|
||||
created_at
|
||||
updated_at
|
||||
```
|
||||
|
||||
### menu_audit_logs
|
||||
|
||||
```text
|
||||
id
|
||||
admin_user_id
|
||||
action_type
|
||||
entity_type
|
||||
entity_id
|
||||
old_value
|
||||
new_value
|
||||
created_at
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Menu Generation Logic
|
||||
|
||||
When a company user logs in, the backend should generate the menu.
|
||||
|
||||
Steps:
|
||||
|
||||
```text
|
||||
1. Identify the user.
|
||||
2. Identify the user’s company.
|
||||
3. Get the company’s active subscription.
|
||||
4. Load menu items assigned to that subscription.
|
||||
5. Load menu items assigned directly to the company.
|
||||
6. Merge both sets.
|
||||
7. Remove duplicates.
|
||||
8. Filter by user role.
|
||||
9. Remove inactive items.
|
||||
10. Sort by display order.
|
||||
11. Return the final menu.
|
||||
```
|
||||
|
||||
Example logic:
|
||||
|
||||
```text
|
||||
subscriptionMenu = getMenuBySubscription(company.subscription_plan_id)
|
||||
companyMenu = getMenuByCompany(company.id)
|
||||
|
||||
finalMenu = merge(subscriptionMenu, companyMenu)
|
||||
finalMenu = removeDuplicates(finalMenu)
|
||||
finalMenu = filterByRole(finalMenu, user.role)
|
||||
finalMenu = filterActiveItems(finalMenu)
|
||||
finalMenu = sortByDisplayOrder(finalMenu)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Duplicate Handling
|
||||
|
||||
If the same menu item exists in both the subscription menu and the company-specific menu, the system should show it once.
|
||||
|
||||
Recommended priority:
|
||||
|
||||
```text
|
||||
Company-specific assignment overrides subscription assignment for display order only.
|
||||
```
|
||||
|
||||
This means:
|
||||
|
||||
```text
|
||||
- The item appears once
|
||||
- Website admins can customize order for that company
|
||||
- The item still keeps the same underlying permissions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Admin UI Requirements
|
||||
|
||||
The website admin menu management screen should include:
|
||||
|
||||
```text
|
||||
- Menu item list
|
||||
- Create menu item button
|
||||
- Edit menu item form
|
||||
- Status toggle
|
||||
- Subscription assignment selector
|
||||
- Company assignment selector
|
||||
- Role visibility selector
|
||||
- Drag-and-drop ordering
|
||||
- Preview by company
|
||||
- Preview by role
|
||||
- Audit history
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. Preview Feature
|
||||
|
||||
Website admins should be able to preview the menu before saving or while troubleshooting.
|
||||
|
||||
Preview options:
|
||||
|
||||
```text
|
||||
- Select company
|
||||
- Select user role
|
||||
- View final generated menu
|
||||
- See why each item is visible or hidden
|
||||
```
|
||||
|
||||
The “why visible/hidden” detail is important.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Advanced Analytics:
|
||||
Visible because company has Pro subscription.
|
||||
|
||||
Audit Logs:
|
||||
Hidden because company has Basic subscription.
|
||||
|
||||
Finance Portal:
|
||||
Visible because item is assigned directly to this company.
|
||||
```
|
||||
|
||||
This will save debugging time and reduce support overhead.
|
||||
|
||||
---
|
||||
|
||||
## 16. Validation Rules
|
||||
|
||||
The system should enforce:
|
||||
|
||||
```text
|
||||
- Menu label is required
|
||||
- Route or URL is required unless item is a parent menu
|
||||
- External URLs must use HTTPS
|
||||
- Display order must be valid
|
||||
- Parent item must exist
|
||||
- Child item cannot be its own parent
|
||||
- Required system items cannot be disabled without Super Admin access
|
||||
- Menu item cannot be assigned to an inactive subscription
|
||||
- Menu item cannot be assigned to a deleted company
|
||||
- Duplicate route under the same parent should be prevented
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 17. Audit Logging
|
||||
|
||||
Every website admin action should be logged.
|
||||
|
||||
Track:
|
||||
|
||||
```text
|
||||
- Admin user
|
||||
- Action type
|
||||
- Affected menu item
|
||||
- Affected company, if any
|
||||
- Affected subscription, if any
|
||||
- Old value
|
||||
- New value
|
||||
- Timestamp
|
||||
```
|
||||
|
||||
Important actions to log:
|
||||
|
||||
```text
|
||||
- Created menu item
|
||||
- Edited menu item
|
||||
- Disabled menu item
|
||||
- Enabled menu item
|
||||
- Assigned item to subscription
|
||||
- Removed item from subscription
|
||||
- Assigned item to company
|
||||
- Removed item from company
|
||||
- Changed display order
|
||||
- Changed role visibility
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 18. Recommended MVP Scope
|
||||
|
||||
The first version should include:
|
||||
|
||||
```text
|
||||
- Website admin menu item CRUD
|
||||
- Assign menu items to subscription plans
|
||||
- Assign extra menu items to specific companies
|
||||
- Role-based visibility
|
||||
- Active/inactive status
|
||||
- Backend menu generation API
|
||||
- Audit logging
|
||||
- Company and role preview
|
||||
```
|
||||
|
||||
Avoid these in the MVP unless absolutely necessary:
|
||||
|
||||
```text
|
||||
- User-specific menu visibility
|
||||
- Scheduled publishing
|
||||
- Custom company-managed menu editing
|
||||
- Approval workflows
|
||||
- Menu analytics
|
||||
```
|
||||
|
||||
User-specific visibility should be avoided in the first version because it adds significant permission complexity.
|
||||
|
||||
---
|
||||
|
||||
## 19. API Endpoints
|
||||
|
||||
Suggested admin endpoints:
|
||||
|
||||
```text
|
||||
GET /admin/menu-items
|
||||
POST /admin/menu-items
|
||||
GET /admin/menu-items/{id}
|
||||
PUT /admin/menu-items/{id}
|
||||
PATCH /admin/menu-items/{id}/status
|
||||
DELETE /admin/menu-items/{id}
|
||||
|
||||
POST /admin/menu-items/{id}/subscriptions
|
||||
DELETE /admin/menu-items/{id}/subscriptions/{subscriptionPlanId}
|
||||
|
||||
POST /admin/menu-items/{id}/companies
|
||||
DELETE /admin/menu-items/{id}/companies/{companyId}
|
||||
|
||||
POST /admin/menu-preview
|
||||
GET /admin/menu-audit-logs
|
||||
```
|
||||
|
||||
Suggested user endpoint:
|
||||
|
||||
```text
|
||||
GET /me/menu
|
||||
```
|
||||
|
||||
The user endpoint should return only the final menu available to the logged-in user.
|
||||
|
||||
---
|
||||
|
||||
## 20. Success Criteria
|
||||
|
||||
The feature is successful if:
|
||||
|
||||
```text
|
||||
- Website admins can manage all menu items centrally.
|
||||
- Companies receive menu items automatically based on subscription.
|
||||
- Website admins can add menu items to individual companies.
|
||||
- Users only see menu items available to their company and role.
|
||||
- Subscription upgrades and downgrades update menus correctly.
|
||||
- Hidden menu items do not create security gaps.
|
||||
- Admin changes are logged.
|
||||
- Support admins can preview and debug company menus.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 21. Key Product Decision
|
||||
|
||||
Menu ownership belongs to the platform, not the company.
|
||||
|
||||
That means the core system should not include company-admin menu controls. Company-specific customization should be handled by website admins through direct company assignment.
|
||||
|
||||
This keeps the model simpler, safer, and easier to support.
|
||||
Reference in New Issue
Block a user