Compare commits

..

36 Commits

Author SHA1 Message Date
leonm e16e3f17ec feat: try fix
CI / 🧪 Tests Laravel (push) Successful in 9m13s
CI / 🐳 Build & Push Images (push) Successful in 1m55s
2026-08-17 20:50:30 +02:00
leonm 411bdce002 feat: fix seeder
CI / 🧪 Tests Laravel (push) Failing after 8m41s
CI / 🐳 Build & Push Images (push) Has been skipped
2026-08-17 16:48:27 +02:00
leonm 2d794ff0c9 feat: auth with google
CI / 🧪 Tests Laravel (push) Successful in 3m9s
CI / 🐳 Build & Push Images (push) Successful in 47s
2026-08-17 15:13:21 +02:00
leonm a037e99cff ci: harden Composer downloads against rate limits
CI / 🧪 Tests Laravel (push) Successful in 2m41s
CI / 🐳 Build & Push Images (push) Successful in 2m32s
2026-08-17 10:35:49 +02:00
leonm 3ee3024de9 feat: try fix ci/cd
CI / 🧪 Tests Laravel (push) Failing after 1m28s
CI / 🐳 Build & Push Images (push) Has been skipped
2026-08-17 10:25:15 +02:00
leonm 5b07d9db4e feat: remove immutabilité from ci/cd
CI / 🧪 Tests Laravel (push) Failing after 1m6s
CI / 🐳 Build & Push Images (push) Has been skipped
2026-08-17 10:11:09 +02:00
leonm ddc80956a0 feat: google login
CI / 🧪 Tests Laravel (push) Failing after 9s
CI / 🐳 Build & Push Images (push) Has been skipped
2026-08-17 10:06:35 +02:00
leonm c0688044e8 feat: upgrade laravel from 12 to 13
CI / 🧪 Tests Laravel (push) Failing after 1m3s
CI / 🐳 Build & Push Images (push) Has been skipped
2026-08-16 17:36:34 +02:00
leonm 7aa5c4e659 ci: prevent edits to existing migrations
CI / 🧪 Tests Laravel (push) Successful in 2m24s
CI / 🐳 Build & Push Images (push) Successful in 1m5s
2026-08-16 13:16:15 +02:00
leonm f752c28d99 feat: followers and following
CI / 🧪 Tests Laravel (push) Successful in 2m27s
CI / 🐳 Build & Push Images (push) Successful in 43s
2026-08-14 14:17:29 +02:00
leonm 238a044d98 feat: seeder
CI / 🧪 Tests Laravel (push) Successful in 2m33s
CI / 🐳 Build & Push Images (push) Successful in 49s
2026-08-14 13:43:35 +02:00
leonm 8b60283eeb feat: translation codes
CI / 🧪 Tests Laravel (push) Successful in 2m34s
CI / 🐳 Build & Push Images (push) Successful in 2m26s
2026-08-14 12:46:29 +02:00
leonm 69372a49ed feat: history and calculate needs for user
CI / 🧪 Tests Laravel (push) Failing after 2m18s
CI / 🐳 Build & Push Images (push) Has been skipped
2026-08-14 12:45:37 +02:00
leonm ea5a5737ec feat: remove horizon
CI / 🧪 Tests Laravel (push) Successful in 2m15s
CI / 🐳 Build & Push Images (push) Successful in 1m9s
2026-08-13 16:58:13 +02:00
leonm 8ef1a3cff8 feat: try fix ci cd
CI / 🧪 Tests Laravel (push) Successful in 2m13s
CI / 🐳 Build & Push Images (push) Successful in 1m5s
2026-08-13 14:30:46 +02:00
leonm 334d94e712 feat: backup cron
CI / 🧪 Tests Laravel (push) Successful in 2m8s
CI / 🐳 Build & Push Image (push) Successful in 35s
2026-08-13 14:20:28 +02:00
leonm fd59e627cc feat: change server
CI / 🧪 Tests Laravel (push) Successful in 2m19s
CI / 🐳 Build & Push Image (push) Successful in 35s
2026-08-13 10:37:02 +02:00
leonm 5702ad88e4 feat: laravel prompts
CI / 🧪 Tests Laravel (push) Successful in 2m31s
CI / 🐳 Build & Push Image (push) Successful in 39s
2026-08-13 09:45:00 +02:00
leonm 29e85589b0 feat: revenue cat
CI / 🧪 Tests Laravel (push) Successful in 2m24s
CI / 🐳 Build & Push Image (push) Successful in 1m13s
2026-08-12 16:00:11 +02:00
leonm 31633cfd2c feat: name
CI / 🧪 Tests Laravel (push) Successful in 2m16s
CI / 🐳 Build & Push Image (push) Successful in 38s
2026-08-12 10:08:59 +02:00
leonm eef021fc1f feat: moderation
CI / 🧪 Tests Laravel (push) Successful in 2m11s
CI / 🐳 Build & Push Image (push) Successful in 40s
2026-08-12 09:46:28 +02:00
leonm df4f523089 feat: add persistent mobile notifications
CI / 🧪 Tests Laravel (push) Successful in 2m7s
CI / 🐳 Build & Push Image (push) Successful in 42s
2026-08-11 16:46:05 +02:00
leonm bd1b4bba20 refactor: hardcode Bowli mobile scheme
CI / 🧪 Tests Laravel (push) Successful in 2m8s
CI / 🐳 Build & Push Image (push) Successful in 31s
2026-08-11 16:20:27 +02:00
leonm 2f2bac9d3a feat: paginate public profile meals
CI / 🧪 Tests Laravel (push) Successful in 2m12s
CI / 🐳 Build & Push Image (push) Successful in 38s
2026-08-11 16:06:58 +02:00
leonm cef3e29ddc feat: add reciprocal user blocking
CI / 🧪 Tests Laravel (push) Successful in 2m6s
CI / 🐳 Build & Push Image (push) Successful in 39s
2026-08-11 15:58:31 +02:00
leonm 6b757c37be feat: accept terms
CI / 🧪 Tests Laravel (push) Successful in 2m7s
CI / 🐳 Build & Push Image (push) Successful in 38s
2026-08-07 16:10:25 +02:00
leonm 68560655f3 feat: clean credits users
CI / 🧪 Tests Laravel (push) Successful in 2m0s
CI / 🐳 Build & Push Image (push) Successful in 35s
2026-08-07 15:32:40 +02:00
leonm eac86afa53 feat: create user command
CI / 🧪 Tests Laravel (push) Successful in 2m7s
CI / 🐳 Build & Push Image (push) Successful in 37s
2026-08-07 15:01:50 +02:00
leonm 909e32523d feat: mails
CI / 🧪 Tests Laravel (push) Successful in 2m6s
CI / 🐳 Build & Push Image (push) Successful in 38s
2026-08-07 14:54:27 +02:00
leonm 792a6c1b05 feat: fix test proxy
CI / 🧪 Tests Laravel (push) Successful in 2m2s
CI / 🐳 Build & Push Image (push) Successful in 37s
2026-08-07 13:35:47 +02:00
leonm 52818e50fb feat: trusproxies
CI / 🧪 Tests Laravel (push) Failing after 2m9s
CI / 🐳 Build & Push Image (push) Has been skipped
2026-08-07 13:28:25 +02:00
leonm 2ece4b6e3e feat: subscriptions
CI / 🧪 Tests Laravel (push) Successful in 2m48s
CI / 🐳 Build & Push Image (push) Successful in 3m0s
2026-08-07 11:53:46 +02:00
leonm 99cb0e63e6 feat: build assets
CI / 🧪 Tests Laravel (push) Successful in 2m2s
CI / 🐳 Build & Push Image (push) Successful in 21s
2026-07-16 17:05:00 +02:00
leonm 573718b38f feat: cd
CI / 🧪 Tests Laravel (push) Successful in 1m57s
CI / 🐳 Build & Push Image (push) Successful in 1m34s
2026-07-16 16:08:18 +02:00
leonm 3ed951b506 feat: php version and franken php
CI / 🧪 Tests Laravel (push) Successful in 2m0s
CI / 🐳 Build & Push Image (push) Failing after 6s
2026-07-16 15:47:15 +02:00
leonm edb7f6d2a1 feat: fix dockerfile
CI / 🧪 Tests Laravel (push) Successful in 1m55s
CI / 🐳 Build & Push Image (push) Failing after 6s
2026-07-16 15:44:03 +02:00
265 changed files with 12115 additions and 6638 deletions
+1
View File
@@ -7,3 +7,4 @@ storage/framework/cache/data
.env
.env.*
public/storage
public/build
+44 -16
View File
@@ -1,10 +1,14 @@
APP_NAME=Starter
APP_NAME=Bowli
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_MOBILE_SCHEME=bowly
APP_PORT=8003
IMAGE_TAG=
POSTGRES_VERSION=15-alpine
REDIS_VERSION=7.4-alpine
MEILISEARCH_VERSION=1.49.0
ADMINER_VERSION=5.5.0-standalone
APP_LOCALE=fr
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
@@ -49,30 +53,54 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
MAIL_MAILER=resend
MAIL_FROM_ADDRESS=noreply@leonmorival.xyz
MAIL_FROM_NAME="Bowli"
RESEND_API_KEY=
EXPO_ACCESS_TOKEN=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=
APPLE_CLIENT_ID=com.meal.daily
# Comma-separated when several Apple bundle or service IDs are accepted.
APPLE_CLIENT_IDS=
APPLE_TEAM_ID=
APPLE_KEY_ID=
# Base64-encoded contents of the Sign in with Apple .p8 private key.
APPLE_PRIVATE_KEY_BASE64=
# Alternative to APPLE_PRIVATE_KEY_BASE64: raw PEM with escaped \n characters.
APPLE_PRIVATE_KEY=
STRAVA_CLIENT_ID=
STRAVA_CLIENT_SECRET=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_SESSION_TOKEN=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
# Production database backups (use a private bucket or a private prefix)
BACKUP_CRON="0 3 * * *"
BACKUP_LOCAL_RETENTION_DAYS=7
BACKUP_RUN_ON_START=true
BACKUP_S3_BUCKET=bowli-backup
BACKUP_S3_PREFIX=database-backups/postgres
BACKUP_S3_ENDPOINT=
TZ=Europe/Paris
OLLAMA_API_KEY=
GEMINI_API_KEY=
OPENAI_API_KEY=
CONTENT_MODERATION_ENABLED=false
CONTENT_MODERATION_MODEL=omni-moderation-latest
CONTENT_MODERATION_TIMEOUT=12
AI_MEAL_USER_NAME="Daily Meal AI"
AI_MEAL_USER_NAME="Bowli AI"
AI_MEAL_USER_EMAIL=ai@daily-meal.local
AI_MEAL_IMAGE_PATH=meal-posts/ai-generated
AI_MEAL_IMAGE_QUALITY=medium
@@ -85,8 +113,8 @@ MEILISEARCH_KEY=masterKey
VITE_APP_NAME="${APP_NAME}"
#Stripe
STRIPE_KEY=your-stripe-key
STRIPE_SECRET=your-stripe-secret
STRIPE_WEBHOOK_SECRET=your-stripe-webhook-secret
CASHIER_CURRENCY=eur
# RevenueCat (secret server key and exact webhook Authorization header)
REVENUECAT_SECRET_KEY=
REVENUECAT_WEBHOOK_AUTHORIZATION=
REVENUECAT_SUBSCRIPTION_ENTITLEMENT="bowli Pro"
REVENUECAT_CERTIFICATION_ENTITLEMENT=certification
+82
View File
@@ -0,0 +1,82 @@
APP_NAME=Bowli
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=https://dailyapi.leonmorival.xyz
APP_LOCALE=fr
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=fr_FR
LOG_CHANNEL=stderr
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=warning
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=
DB_USERNAME=
DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=true
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax
CACHE_STORE=redis
QUEUE_CONNECTION=redis
REDIS_CLIENT=predis
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=eu-north-1
AWS_BUCKET=bowli
AWS_URL=
AWS_ENDPOINT=
AWS_USE_PATH_STYLE_ENDPOINT=false
MAIL_MAILER=resend
MAIL_FROM_ADDRESS=noreply@leonmorival.xyz
MAIL_FROM_NAME="Bowli"
RESEND_API_KEY=
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://meilisearch:7700
MEILISEARCH_KEY=
MEILISEARCH_VERSION=1.49.0
GEMINI_API_KEY=
OPENAI_API_KEY=
CONTENT_MODERATION_ENABLED=true
CONTENT_MODERATION_MODEL=omni-moderation-latest
CONTENT_MODERATION_TIMEOUT=12
AI_MEAL_USER_NAME="Bowli AI"
AI_MEAL_USER_EMAIL=ai@daily-meal.local
AI_MEAL_IMAGE_PATH=meal-posts/ai-generated
AI_MEAL_IMAGE_QUALITY=medium
AI_MEAL_TEXT_TIMEOUT=90
AI_MEAL_IMAGE_TIMEOUT=120
EXPO_ACCESS_TOKEN=
STRAVA_CLIENT_ID=
STRAVA_CLIENT_SECRET=
STRAVA_SCOPE=read,activity:read
REVENUECAT_SECRET_KEY=
REVENUECAT_WEBHOOK_AUTHORIZATION=
REVENUECAT_SUBSCRIPTION_ENTITLEMENT="bowli Pro"
REVENUECAT_CERTIFICATION_ENTITLEMENT=certification
HEALTH_SECRET_TOKEN=
SCHEDULE_HEARTBEAT_URL=
IMAGE_TAG=latest
ADMINER_PORT=8080
+26 -5
View File
@@ -13,6 +13,9 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configuration SSH
run: |
mkdir -p ~/.ssh
@@ -23,6 +26,27 @@ jobs:
-H "${{ secrets.DEPLOY_HOST }}" \
>> ~/.ssh/known_hosts
- name: Synchronisation de la configuration Docker
run: |
ssh \
-i ~/.ssh/id_ed25519 \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \
"mkdir -p /data/stacks/bowli-api/docker/database-backup"
scp \
-i ~/.ssh/id_ed25519 \
docker-compose.prod.yml \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/data/stacks/bowli-api/docker-compose.yml
scp \
-i ~/.ssh/id_ed25519 \
docker/database-backup/restore-production \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/data/stacks/bowli-api/docker/database-backup/restore-production
ssh \
-i ~/.ssh/id_ed25519 \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} \
"chmod 755 /data/stacks/bowli-api/docker/database-backup/restore-production"
- name: 🚀 Deploy Docker
run: |
@@ -32,15 +56,12 @@ jobs:
set -e
echo "Projet"
cd /data/stacks/daily-meal-api
cd /data/stacks/bowli-api
echo "Pull nouvelle image"
docker compose pull
echo "Redémarrage"
docker compose up -d
echo "Migration Laravel"
docker compose exec -T app php artisan migrate --force
docker compose up -d --wait --wait-timeout 180
EOF
+49 -6
View File
@@ -9,6 +9,7 @@ on:
pull_request:
branches:
- main
- develop
jobs:
test:
name: 🧪 Tests Laravel
@@ -16,9 +17,11 @@ jobs:
env:
DB_CONNECTION: sqlite
DB_DATABASE: database/database.sqlite
COMPOSER_MAX_PARALLEL_HTTP: 4
steps:
- name: 📥 Checkout
uses: actions/checkout@v4
- name: 🐘 Setup PHP
uses: shivammathur/setup-php@v2
with:
@@ -26,18 +29,45 @@ jobs:
extensions: bcmath,ctype,curl,dom,fileinfo,intl,mbstring,openssl,pdo,tokenizer,xml,zip
coverage: none
- name: Configure Composer authentication
env:
COMPOSER_GITHUB_TOKEN: ${{ secrets.COMPOSER_GITHUB_TOKEN }}
run: |
if [ -n "$COMPOSER_GITHUB_TOKEN" ]; then
composer config \
--global \
--auth github-oauth.github.com \
"$COMPOSER_GITHUB_TOKEN"
fi
- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- name: Cache Composer
uses: actions/cache@v4
with:
path: ~/.composer/cache
key: composer-cache
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
- name: Install Composer dependencies
run: |
composer install \
attempt=1
until composer install \
--no-interaction \
--prefer-dist \
--no-progress
--no-progress; do
if [ "$attempt" -ge 4 ]; then
exit 1
fi
delay=$((attempt * 15))
echo "Composer install failed (attempt $attempt/4), retrying in ${delay}s..."
sleep "$delay"
attempt=$((attempt + 1))
done
- name: ⚙️ Prepare Laravel
run: |
@@ -54,7 +84,7 @@ jobs:
docker:
name: 🐳 Build & Push Image
name: 🐳 Build & Push Images
needs: test
if: ${{ github.ref == 'refs/heads/main' }}
runs-on: ubuntu-latest
@@ -69,13 +99,26 @@ jobs:
-p "${{ secrets.REGISTRY_PASSWORD }}"
- name: 🏗️ Build image
env:
COMPOSER_GITHUB_TOKEN: ${{ secrets.COMPOSER_GITHUB_TOKEN }}
run: |
docker build \
--secret id=composer_github_token,env=COMPOSER_GITHUB_TOKEN \
-t git.leonmorival.xyz/leonm/daily-meal-api:latest \
-t git.leonmorival.xyz/leonm/daily-meal-api:${{ github.sha }} \
.
- name: 📤 Push image
- name: 🏗️ Build database backup image
run: |
docker build \
-f docker/database-backup/Dockerfile \
-t git.leonmorival.xyz/leonm/daily-meal-postgres-tools:latest \
-t git.leonmorival.xyz/leonm/daily-meal-postgres-tools:${{ github.sha }} \
docker/database-backup
- name: 📤 Push images
run: |
docker push git.leonmorival.xyz/leonm/daily-meal-api:latest
docker push git.leonmorival.xyz/leonm/daily-meal-api:${{ github.sha }}
docker push git.leonmorival.xyz/leonm/daily-meal-postgres-tools:latest
docker push git.leonmorival.xyz/leonm/daily-meal-postgres-tools:${{ github.sha }}
+4
View File
@@ -23,3 +23,7 @@ Homestead.json
Homestead.yaml
Thumbs.db
.env.prod
**/caddy
frankenphp
frankenphp-worker.php
-1
View File
@@ -13,7 +13,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
- filament/filament (FILAMENT) - v5
- laravel/ai (AI) - v0
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- laravel/scout (SCOUT) - v11
+73 -48
View File
@@ -1,61 +1,86 @@
# --- Étape de Build (Composer) ---
FROM composer:2 AS composer-bin
FROM dunglas/frankenphp:php8.4-alpine AS php
FROM dunglas/frankenphp:1.3-php8.4-alpine AS composer-build
WORKDIR /app
WORKDIR /var/www/html
COPY --from=composer-bin /usr/bin/composer /usr/bin/composer
RUN install-php-extensions intl zip bcmath pdo_pgsql pcntl redis
COPY . .
RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
# --- Étape de Build (Node) ---
FROM node:20-alpine AS node-build
WORKDIR /var/www/html
COPY package*.json ./
RUN npm ci
COPY --from=composer-build /var/www/html/vendor ./vendor
COPY . .
RUN npm run build
# --- Étape Finale (Production) ---
FROM dunglas/frankenphp:1.3-php8.4-alpine
# On installe les extensions pour le fonctionnement de l'app en prod
RUN apk add --no-cache \
libpq-dev \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
icu-dev
RUN install-php-extensions \
intl \
bcmath \
pdo_pgsql \
gd \
zip \
opcache \
pcntl \
redis
zip \
intl
WORKDIR /var/www/html
COPY --from=composer-build /var/www/html /var/www/html
COPY --from=node-build /var/www/html/public/build /var/www/html/public/build
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Permissions (crucial pour Laravel)
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
ENV APP_ENV=production
ENV APP_RUNTIME=Laravel\FrankenPHP\Runtime
COPY composer.json composer.lock ./
HEALTHCHECK NONE
RUN --mount=type=cache,target=/tmp/composer-cache \
--mount=type=secret,id=composer_github_token,required=false \
set -eu; \
if [ -s /run/secrets/composer_github_token ]; then \
export COMPOSER_AUTH="{\"github-oauth\":{\"github.com\":\"$(cat /run/secrets/composer_github_token)\"}}"; \
fi; \
export COMPOSER_CACHE_DIR=/tmp/composer-cache; \
export COMPOSER_MAX_PARALLEL_HTTP=4; \
attempt=1; \
until composer install \
--no-dev \
--no-interaction \
--prefer-dist \
--optimize-autoloader \
--no-scripts; do \
if [ "$attempt" -ge 4 ]; then \
exit 1; \
fi; \
delay=$((attempt * 15)); \
echo "Composer install failed (attempt $attempt/4), retrying in ${delay}s..."; \
sleep "$delay"; \
attempt=$((attempt + 1)); \
done
CMD ["sh", "-c", "php artisan migrate --force && php artisan storage:link && php artisan optimize && php artisan view:cache && php artisan event:cache && frankenphp run --config /etc/caddy/Caddyfile"]
COPY . .
RUN composer dump-autoload --optimize
FROM node:22-alpine AS frontend
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
COPY --from=php /app/vendor ./vendor
RUN npm run build
FROM php AS production
COPY --from=frontend /app/public/build ./public/build
RUN mkdir -p \
storage/app/public \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
bootstrap/cache \
&& chown -R www-data:www-data storage bootstrap/cache
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/entrypoint.sh"]
+195
View File
@@ -0,0 +1,195 @@
# Daily Meal API
API Laravel de Daily Meal.
## Docker en production
Le fichier source du dépôt est `docker-compose.prod.yml`. Sur le serveur, il est
déployé sous le nom `docker-compose.yml`. Les commandes de ce guide sont donc
exécutées depuis le dossier de l'application sans option `-f`.
### Configuration
Le Compose charge la configuration applicative depuis le `.env` de production.
Les variables Laravel ne sont pas répétées dans le fichier Compose afin de
conserver une seule source de vérité.
Exemple des variables propres à l'infrastructure :
```dotenv
IMAGE_TAG=2026-08-13.1
APP_ENV=production
APP_DEBUG=false
LOG_CHANNEL=stderr
LOG_LEVEL=warning
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=bowli
DB_USERNAME=bowli
DB_PASSWORD=change-me
REDIS_HOST=redis
QUEUE_CONNECTION=redis
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://meilisearch:7700
MEILISEARCH_KEY=change-me
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=change-me
AWS_SECRET_ACCESS_KEY=change-me
AWS_DEFAULT_REGION=eu-west-3
```
`IMAGE_TAG` est obligatoire. Utiliser un tag de release immuable permet de savoir
exactement quelle version est déployée et évite l'utilisation accidentelle de
`latest`.
### Volumes et réseau externes
PostgreSQL, Redis, Meilisearch et les sauvegardes locales utilisent des volumes
externes. Ils restent présents quand le projet Compose est recréé.
À effectuer une seule fois sur un nouveau serveur :
```bash
docker volume create bemeal_pgsql_data
docker volume create bemeal_redis_data
docker volume create bemeal_meilisearch_data
docker volume create bemeal_database_backups
docker network create proxy
```
Ces commandes sont idempotentes. Le réseau `proxy` peut déjà avoir été créé par
le reverse proxy. Ne jamais supprimer `bemeal_pgsql_data` sans avoir validé une
sauvegarde.
### Premier démarrage
```bash
docker compose config -q
docker compose pull app db-backup postgres redis meilisearch
docker compose up -d
docker compose ps
```
L'image `db-backup` contient `pg_dump`, `pg_restore`, AWS CLI, le cron et les
scripts de `docker/database-backup`. Elle est construite et publiée dans le
registre par la CI. Le serveur la télécharge avec `docker compose pull` et n'a
donc pas besoin de posséder le code source ni de construire une image localement.
### Mettre à jour l'application
Après avoir changé `IMAGE_TAG` dans `.env` :
```bash
docker compose config -q
docker compose pull app db-backup
docker compose up -d --remove-orphans
docker compose ps
```
Si les migrations ne sont pas déjà gérées par le pipeline ou l'image :
```bash
docker compose exec app php artisan migrate --force
```
## Sauvegardes PostgreSQL vers S3
Le service `db-backup` lance une sauvegarde au démarrage, puis chaque jour à
03:00. Chaque archive est compressée, contrôlée avec `pg_restore`, accompagnée
d'un checksum SHA-256 puis envoyée vers S3 avec le chiffrement AES-256. Les
copies locales sont conservées 7 jours par défaut.
```dotenv
BACKUP_CRON="0 3 * * *"
BACKUP_RUN_ON_START=true
BACKUP_LOCAL_RETENTION_DAYS=7
BACKUP_S3_BUCKET=bowli-backup
BACKUP_S3_PREFIX=database-backups/postgres
BACKUP_S3_ENDPOINT=
TZ=Europe/Paris
```
Ces valeurs sont les valeurs par défaut et peuvent être omises du `.env`. Le
bucket `bowli-backup` doit rester entièrement privé. `BACKUP_S3_ENDPOINT` reste
vide avec AWS S3.
### Autorisations IAM minimales
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListDatabaseBackups",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::bowli-backup",
"Condition": {
"StringLike": {
"s3:prefix": [
"database-backups/postgres",
"database-backups/postgres/*"
]
}
}
},
{
"Sid": "ManageDatabaseBackupObjects",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject"],
"Resource": "arn:aws:s3:::bowli-backup/database-backups/postgres/*"
}
]
}
```
Le conteneur ne supprime pas les sauvegardes distantes. Configurer une règle de
cycle de vie S3 sur ce préfixe, par exemple après 30 ou 90 jours.
### Sauvegarde manuelle et contrôle
```bash
docker compose exec db-backup /usr/local/bin/backup-database manual
docker compose ps db-backup
docker compose logs --tail=100 db-backup
```
## Restaurer une sauvegarde
Pour restaurer la sauvegarde S3 la plus récente :
```bash
./docker/database-backup/restore-production latest-s3
```
Autres sources acceptées :
```bash
# Dernière sauvegarde du volume local
./docker/database-backup/restore-production latest-local
# Sauvegarde S3 précise
./docker/database-backup/restore-production \
s3://bowli-backup/database-backups/postgres/20260813T030000Z-bowli-scheduled.dump
```
La restauration demande une confirmation explicite. Elle arrête l'application
et les workers, crée une sauvegarde de sécurité, vérifie l'archive, restaure dans
une base temporaire puis échange les bases. L'ancienne base est conservée sous
un nom daté pour permettre un rollback.
Les services redémarrent uniquement si toute l'opération réussit. En cas
d'échec, ils restent arrêtés afin d'éviter d'écrire dans une base potentiellement
incomplète.
Pour utiliser exceptionnellement un autre nom de fichier Compose :
```bash
COMPOSE_FILE=docker-compose.prod.yml \
./docker/database-backup/restore-production latest-s3
```
@@ -0,0 +1,79 @@
<?php
namespace App\Actions;
use App\Enums\NutritionPlanSource;
use App\Enums\WeeklyReviewStatus;
use App\Models\NutritionPlan;
use App\Models\WeeklyReview;
use Illuminate\Support\Facades\DB;
class AcceptWeeklyReviewAdjustment
{
public function execute(WeeklyReview $weeklyReview): NutritionPlan
{
return DB::transaction(function () use ($weeklyReview): NutritionPlan {
$lockedReview = WeeklyReview::query()->lockForUpdate()->findOrFail($weeklyReview->getKey());
abort_if($lockedReview->accepted_at !== null, 409, 'WEEKLY_ADJUSTMENT_ALREADY_ACCEPTED');
abort_unless(
$lockedReview->status === WeeklyReviewStatus::ADJUSTMENT_RECOMMENDED
&& $lockedReview->recommended_calorie_adjustment !== 0,
409,
'WEEKLY_ADJUSTMENT_UNAVAILABLE',
);
$user = $lockedReview->user()->lockForUpdate()->firstOrFail();
$currentPlan = $user->nutritionPlans()->where('is_active', true)->latest('effective_from')->firstOrFail();
abort_unless(
$lockedReview->nutrition_plan_id === $currentPlan->getKey(),
409,
'WEEKLY_ADJUSTMENT_STALE',
);
$now = now();
$targetCalories = max(1200, $currentPlan->calories + $lockedReview->recommended_calorie_adjustment);
$proteins = (int) round($currentPlan->proteins);
$fats = (int) round($currentPlan->fats);
$carbs = max(0, (int) round(($targetCalories - ($proteins * 4) - ($fats * 9)) / 4));
$calories = ($proteins * 4) + ($carbs * 4) + ($fats * 9);
abort_if(
$calories === $currentPlan->calories,
409,
'WEEKLY_ADJUSTMENT_UNAVAILABLE',
);
$currentPlan->update([
'is_active' => false,
'effective_until' => $now,
]);
$nextPlan = $user->nutritionPlans()->create([
'source' => NutritionPlanSource::WEEKLY_ADJUSTMENT,
'formula_version' => $currentPlan->formula_version,
'calories' => $calories,
'proteins' => $proteins,
'carbs' => $carbs,
'fats' => $fats,
'resting_metabolism' => $currentPlan->resting_metabolism,
'maintenance_calories' => $currentPlan->maintenance_calories,
'calorie_adjustment_percent' => ($calories - $currentPlan->maintenance_calories) / max(1, $currentPlan->maintenance_calories),
'calculation_details' => [
...$currentPlan->calculation_details,
'weeklyReviewId' => $lockedReview->getKey(),
'weeklyAdjustmentCalories' => $lockedReview->recommended_calorie_adjustment,
],
'effective_from' => $now,
]);
$lockedReview->update([
'accepted_at' => $now,
'accepted_nutrition_plan_id' => $nextPlan->getKey(),
]);
return $nextPlan;
}, attempts: 3);
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace App\Actions;
use App\Enums\SocialProvider;
use App\Models\SocialAccount;
use App\Models\User;
use App\Services\AppleOAuthTokenClient;
use App\Services\SocialIdentityResolver;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\User as SocialiteUser;
class AuthenticateSocialUser
{
public function __construct(
private SocialIdentityResolver $identityResolver,
private AppleOAuthTokenClient $appleOAuthTokenClient,
) {}
/**
* @param array<string, mixed> $data
*/
public function execute(array $data): User
{
$provider = SocialProvider::from((string) $data['provider']);
$identity = $this->identityResolver->resolve(
$provider,
(string) $data['idToken'],
isset($data['nonce']) ? (string) $data['nonce'] : null,
);
$this->ensureUsableIdentity($identity);
$email = strtolower(trim((string) $identity->getEmail()));
$providerRefreshToken = $provider === SocialProvider::Apple
? $this->appleOAuthTokenClient->exchangeAuthorizationCode(
(string) ($data['authorizationCode'] ?? ''),
(string) $identity->getId(),
)
: null;
try {
return Cache::lock('social-auth:user:'.hash('sha256', $email), 10)
->block(5, fn (): User => DB::transaction(function () use ($data, $email, $provider, $providerRefreshToken, $identity): User {
$providerUserId = (string) $identity->getId();
$socialAccount = SocialAccount::query()
->where('provider', $provider)
->where('provider_user_id', $providerUserId)
->lockForUpdate()
->first();
if ($socialAccount) {
$user = $socialAccount->user;
abort_if(! $user, 409, 'SOCIAL_ACCOUNT_CONFLICT');
if ($providerRefreshToken !== null) {
$socialAccount->update([
'provider_refresh_token' => $providerRefreshToken,
]);
}
return $this->prepareUserForLogin($user, $data);
}
$user = User::query()
->where('email', $email)
->lockForUpdate()
->first();
if (! $user) {
abort_if(($data['termsAccepted'] ?? false) !== true, 422, 'TERMS_ACCEPTANCE_REQUIRED');
$user = User::create([
'email' => $email,
'email_verified_at' => now(),
'locale' => $data['locale'] ?? 'en',
'name' => $this->uniqueUsername($this->preferredName($provider, $identity, $data), $email),
'password' => null,
'terms_accepted_at' => now(),
]);
} else {
abort_if($user->isSuspended(), 403, 'ACCOUNT_SUSPENDED');
if (! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
}
}
$user->socialAccounts()->create([
'provider' => $provider,
'provider_refresh_token' => $providerRefreshToken,
'provider_user_id' => $providerUserId,
]);
return $this->prepareUserForLogin($user, $data);
}, 3));
} catch (LockTimeoutException) {
abort(409, 'SOCIAL_AUTH_IN_PROGRESS');
}
}
private function ensureUsableIdentity(SocialiteUser $identity): void
{
$providerUserId = $identity->getId();
$email = $identity->getEmail();
$rawIdentity = $identity->getRaw();
$emailVerified = filter_var(
$rawIdentity['email_verified'] ?? $rawIdentity['verified_email'] ?? false,
FILTER_VALIDATE_BOOL,
);
abort_if(! is_string($providerUserId) || trim($providerUserId) === '', 401, 'SOCIAL_TOKEN_INVALID');
abort_if(! is_string($email) || filter_var($email, FILTER_VALIDATE_EMAIL) === false, 401, 'SOCIAL_TOKEN_INVALID');
abort_if($emailVerified !== true, 401, 'SOCIAL_EMAIL_NOT_VERIFIED');
}
/**
* @param array<string, mixed> $data
*/
private function prepareUserForLogin(User $user, array $data): User
{
abort_if($user->isSuspended(), 403, 'ACCOUNT_SUSPENDED');
if (isset($data['locale']) && $user->locale !== $data['locale']) {
$user->forceFill(['locale' => $data['locale']])->save();
}
return $user->fresh();
}
/**
* @param array<string, mixed> $data
*/
private function preferredName(
SocialProvider $provider,
SocialiteUser $identity,
array $data,
): ?string {
if ($provider === SocialProvider::Apple && isset($data['name']) && is_string($data['name'])) {
return $data['name'];
}
return $identity->getName();
}
private function uniqueUsername(?string $preferredName, string $email): string
{
$source = filled($preferredName) ? $preferredName : Str::before($email, '@');
$base = Str::of((string) $source)
->ascii()
->lower()
->replaceMatches('/[^a-z0-9_.-]+/', '.')
->trim('.-_')
->limit(24, '')
->toString();
if (mb_strlen($base) < 3) {
$base = 'user';
}
if (! User::withTrashed()->where('name', $base)->exists()) {
return $base;
}
do {
$candidate = $base.'-'.Str::lower(Str::random(6));
} while (User::withTrashed()->where('name', $candidate)->exists());
return $candidate;
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Actions;
use App\Models\User;
use App\Models\UserBlock;
use Illuminate\Support\Facades\DB;
class BlockUser
{
public function execute(User $blocker, User $blocked): UserBlock
{
return DB::transaction(function () use ($blocker, $blocked): UserBlock {
$userBlock = UserBlock::query()->firstOrCreate([
'blocker_id' => $blocker->getKey(),
'blocked_id' => $blocked->getKey(),
]);
$blocker->following()->detach($blocked->getKey());
$blocker->followers()->detach($blocked->getKey());
return $userBlock;
});
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Actions;
use App\Enums\NutritionPlanSource;
use App\Models\User;
class CompleteNutritionOnboarding
{
public function __construct(private RecalculateNutritionPlan $recalculateNutritionPlan) {}
/**
* @param array<string, mixed> $data
*/
public function execute(User $user, array $data): User
{
if ($user->hasCompletedNutritionOnboarding()) {
return $user->loadMissing(['activeNutritionPlan', 'latestWeightEntry']);
}
return $this->recalculateNutritionPlan->execute(
user: $user,
data: $data,
source: NutritionPlanSource::ONBOARDING,
completeOnboarding: true,
);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Actions;
use App\Enums\NutritionPlanSource;
use App\Enums\PacePreference;
use App\Enums\PhysicalActivityLevel;
use App\Enums\UserSex;
use App\Enums\WeightGoal;
use App\Models\User;
use App\Services\NutritionPlanCalculator;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
class RecalculateNutritionPlan
{
public function __construct(private NutritionPlanCalculator $calculator) {}
/**
* @param array<string, mixed> $data
*/
public function execute(
User $user,
array $data,
NutritionPlanSource $source,
bool $completeOnboarding = false,
): User {
return DB::transaction(function () use ($user, $data, $source, $completeOnboarding): User {
$lockedUser = User::query()->lockForUpdate()->findOrFail($user->getKey());
$now = now();
if ($completeOnboarding && $lockedUser->hasCompletedNutritionOnboarding()) {
return $lockedUser->load(['activeNutritionPlan', 'latestWeightEntry']);
}
$lockedUser->update([
'height' => $data['heightCm'],
'target_weight' => $data['targetWeightKg'] ?? null,
'date_of_birth' => $data['dateOfBirth'],
'sex' => $data['sex'],
'physical_activity_level' => $data['physicalActivityLevel'],
'weight_goal' => $data['weightGoal'],
'pace_preference' => $data['pacePreference'],
...($completeOnboarding ? [
'onboarding_completed_at' => $now,
'nutrition_estimate_accepted_at' => $now,
] : []),
]);
$weightKg = (float) $data['weightKg'];
$latestWeight = $lockedUser->weightEntries()->latest('measured_at')->first();
if ($latestWeight === null || abs($latestWeight->weight_kg - $weightKg) >= 0.01) {
$lockedUser->weightEntries()->create([
'weight_kg' => $weightKg,
'measured_at' => $now,
]);
}
$lockedUser->nutritionPlans()
->where('is_active', true)
->update([
'is_active' => false,
'effective_until' => $now,
]);
$calculation = $this->calculator->calculate(
weightKg: $weightKg,
heightCm: (int) $data['heightCm'],
dateOfBirth: CarbonImmutable::parse($data['dateOfBirth']),
sex: UserSex::from($data['sex']),
activityLevel: PhysicalActivityLevel::from($data['physicalActivityLevel']),
weightGoal: WeightGoal::from($data['weightGoal']),
pacePreference: PacePreference::from($data['pacePreference']),
);
$lockedUser->nutritionPlans()->create([
...$calculation,
'source' => $source,
'is_active' => true,
'effective_from' => $now,
]);
return $lockedUser->fresh()->load(['activeNutritionPlan', 'latestWeightEntry']);
}, attempts: 3);
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Actions;
use App\Enums\IdentityVerificationStatus;
use App\Models\IdentityVerificationRequest;
use App\Models\User;
use App\Notifications\CertificationReviewedNotification;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class ReviewIdentityVerification
{
public function execute(
IdentityVerificationRequest $verificationRequest,
IdentityVerificationStatus $status,
User $reviewer,
?string $rejectionReason = null,
): IdentityVerificationRequest {
abort_unless($reviewer->isAdmin(), 403);
abort_unless(
in_array($status, [IdentityVerificationStatus::APPROVED, IdentityVerificationStatus::REJECTED], true),
422,
);
abort_if(
$status === IdentityVerificationStatus::REJECTED && blank($rejectionReason),
422,
'CERTIFICATION_REJECTION_REASON_REQUIRED',
);
$reviewedRequest = DB::transaction(function () use (
$rejectionReason,
$reviewer,
$status,
$verificationRequest,
): IdentityVerificationRequest {
$lockedRequest = IdentityVerificationRequest::query()
->whereKey($verificationRequest->getKey())
->lockForUpdate()
->firstOrFail();
abort_unless($lockedRequest->status === IdentityVerificationStatus::PENDING, 409);
$lockedRequest->update([
'status' => $status,
'reviewed_at' => now(),
'reviewed_by' => $reviewer->getKey(),
'rejection_reason' => $status === IdentityVerificationStatus::REJECTED
? trim((string) $rejectionReason)
: null,
]);
if ($status === IdentityVerificationStatus::APPROVED) {
$lockedRequest->user()->update([
'account_verified_at' => now(),
]);
}
return $lockedRequest->fresh(['user', 'reviewer']);
});
$this->deleteReviewedDocument($reviewedRequest);
$reviewedRequest->user->notify(
(new CertificationReviewedNotification($reviewedRequest))
->locale($reviewedRequest->user->preferredLocale()),
);
return $reviewedRequest->fresh(['user', 'reviewer']);
}
private function deleteReviewedDocument(IdentityVerificationRequest $verificationRequest): void
{
if (! $verificationRequest->hasDocument()) {
return;
}
try {
Storage::disk($verificationRequest->document_disk)
->delete($verificationRequest->document_path);
$verificationRequest->update([
'document_path' => null,
'document_deleted_at' => now(),
]);
} catch (\Throwable $exception) {
report($exception);
}
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Actions;
use App\Enums\SocialProvider;
use App\Models\User;
use App\Services\AppleOAuthTokenClient;
use Illuminate\Contracts\Encryption\DecryptException;
class RevokeSocialAuthorizations
{
public function __construct(private AppleOAuthTokenClient $appleOAuthTokenClient) {}
public function execute(User $user): void
{
$appleAccount = $user->socialAccounts()
->where('provider', SocialProvider::Apple)
->first();
if (! $appleAccount) {
return;
}
try {
$refreshToken = $appleAccount->provider_refresh_token;
} catch (DecryptException) {
abort(503, 'SOCIAL_REVOCATION_FAILED');
}
if (! is_string($refreshToken) || $refreshToken === '') {
abort(409, 'SOCIAL_REVOCATION_TOKEN_MISSING');
}
$this->appleOAuthTokenClient->revoke($refreshToken);
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Actions;
use App\Enums\IdentityVerificationStatus;
use App\Models\IdentityVerificationRequest;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Throwable;
class SubmitIdentityVerification
{
public function execute(User $user, UploadedFile $document): IdentityVerificationRequest
{
return Cache::lock('identity-verification-submit:'.$user->getKey(), 30)
->block(5, fn (): IdentityVerificationRequest => $this->store($user, $document));
}
private function store(User $user, UploadedFile $document): IdentityVerificationRequest
{
$existingRequest = $user->identityVerificationRequest()->first();
if ($existingRequest?->status === IdentityVerificationStatus::PENDING) {
throw new ConflictHttpException('CERTIFICATION_ALREADY_PENDING');
}
$disk = (string) config('billing.identity_verification.disk', 'identity_documents');
$path = null;
try {
$path = Storage::disk($disk)->putFileAs(
'submissions',
$document,
Str::ulid().'.'.$this->extensionFor($document),
['visibility' => 'private'],
);
if (! is_string($path) || $path === '') {
throw new \RuntimeException('The identity document could not be stored.');
}
$verificationRequest = IdentityVerificationRequest::query()->updateOrCreate(
['user_id' => $user->getKey()],
[
'status' => IdentityVerificationStatus::PENDING,
'document_disk' => $disk,
'document_path' => $path,
'document_mime_type' => $document->getMimeType(),
'document_size' => $document->getSize(),
'submitted_at' => now(),
'reviewed_at' => null,
'reviewed_by' => null,
'rejection_reason' => null,
'document_deleted_at' => null,
],
);
} catch (Throwable $exception) {
if ($path) {
Storage::disk($disk)->delete($path);
}
throw $exception;
}
if ($existingRequest?->hasDocument() && $existingRequest->document_path !== $path) {
Storage::disk($existingRequest->document_disk)->delete($existingRequest->document_path);
}
return $verificationRequest;
}
private function extensionFor(UploadedFile $document): string
{
return match ($document->getMimeType()) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'application/pdf' => 'pdf',
default => throw new \InvalidArgumentException('Unsupported identity document type.'),
};
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Console\Commands;
use App\Enums\UserRole;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use function Laravel\Prompts\error;
use function Laravel\Prompts\intro;
use function Laravel\Prompts\outro;
use function Laravel\Prompts\password;
use function Laravel\Prompts\select;
use function Laravel\Prompts\text;
class CreateUser extends Command
{
protected $signature = 'user:create
{--name= : The unique user name}
{--email= : The unique email address}
{--role= : The user role: user, admin, or moderator}
{--locale=fr : The user locale}
{--verified : Mark the email address as verified}';
protected $description = 'Create an application user.';
public function handle(): int
{
intro('Create a Bowli user');
$name = trim((string) ($this->option('name') ?: text(
label: 'User name',
placeholder: 'e.g. jane.doe',
required: true,
hint: '3 to 32 characters: letters, numbers, dots, hyphens, or underscores.',
)));
$email = strtolower(trim((string) ($this->option('email') ?: text(
label: 'Email address',
placeholder: 'e.g. jane@example.com',
required: true,
))));
$role = (string) ($this->option('role') ?: select(
label: 'Role',
options: collect(UserRole::cases())
->mapWithKeys(fn (UserRole $role): array => [$role->value => ucfirst($role->value)])
->all(),
default: UserRole::USER->value,
hint: 'Choose the permissions granted to this user.',
));
$locale = strtolower(trim((string) $this->option('locale')));
$attributesValidator = Validator::make([
'name' => $name,
'email' => $email,
'role' => $role,
'locale' => $locale,
], [
'name' => ['required', 'string', 'min:3', 'max:32', 'regex:/^[a-zA-Z0-9_.-]+$/', 'unique:users,name'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'role' => ['required', Rule::enum(UserRole::class)],
'locale' => ['required', Rule::in(config('app.supported_locales', ['fr', 'en']))],
]);
if ($attributesValidator->fails()) {
foreach ($attributesValidator->errors()->all() as $error) {
error($error);
}
return self::FAILURE;
}
$password = password(
label: 'Password',
required: true,
hint: 'At least 8 characters with uppercase, lowercase, number, and symbol.',
);
$passwordConfirmation = password(
label: 'Confirm password',
required: true,
);
$passwordValidator = Validator::make([
'password' => $password,
'password_confirmation' => $passwordConfirmation,
], [
'password' => [
'required',
'confirmed',
Password::min(8)
->mixedCase()
->letters()
->numbers()
->symbols(),
],
]);
if ($passwordValidator->fails()) {
foreach ($passwordValidator->errors()->all() as $error) {
error($error);
}
return self::FAILURE;
}
$user = User::query()->create([
'name' => $name,
'email' => $email,
'email_verified_at' => $this->option('verified') ? now() : null,
'locale' => $locale,
'password' => Hash::make($password),
'role' => UserRole::from($role),
]);
outro("User {$user->email} created with ID {$user->getKey()} and role {$user->role->value}.");
return self::SUCCESS;
}
}
@@ -55,8 +55,9 @@ class SendEngagementReminderNotifications extends Command
private function eligibleUsersQuery(): Builder
{
return User::query()
->select(['id', 'locale', 'suspended_at'])
->select(['id', 'locale', 'suspended_at', 'engagement_reminders_enabled'])
->whereNull('suspended_at')
->where('engagement_reminders_enabled', true)
->whereHas('deviceTokens')
->whereDoesntHave('mealPosts', fn (Builder $query): Builder => $query
->where('created_at', '>=', now()->subDay()));
-38
View File
@@ -1,38 +0,0 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum CreditLedgerEntryType: string implements HasColor, HasLabel
{
case FREE_GRANT = 'free_grant';
case PURCHASE = 'purchase';
case SUBSCRIPTION_RENEWAL = 'subscription_renewal';
case USAGE = 'usage';
case REFUND = 'refund';
case ADJUSTMENT = 'adjustment';
public function getLabel(): string
{
return match ($this) {
self::FREE_GRANT => __('enums.credit_ledger_entry_type.free_grant'),
self::PURCHASE => __('enums.credit_ledger_entry_type.purchase'),
self::SUBSCRIPTION_RENEWAL => __('enums.credit_ledger_entry_type.subscription_renewal'),
self::USAGE => __('enums.credit_ledger_entry_type.usage'),
self::REFUND => __('enums.credit_ledger_entry_type.refund'),
self::ADJUSTMENT => __('enums.credit_ledger_entry_type.adjustment'),
};
}
public function getColor(): string
{
return match ($this) {
self::FREE_GRANT, self::PURCHASE, self::SUBSCRIPTION_RENEWAL => 'success',
self::USAGE => 'warning',
self::REFUND => 'info',
self::ADJUSTMENT => 'gray',
};
}
}
-28
View File
@@ -1,28 +0,0 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum CreditProductType: string implements HasColor, HasLabel
{
case MONTHLY = 'monthly';
case ONE_TIME = 'one_time';
public function getLabel(): string
{
return match ($this) {
self::MONTHLY => __('enums.credit_product_type.monthly'),
self::ONE_TIME => __('enums.credit_product_type.one_time'),
};
}
public function getColor(): string
{
return match ($this) {
self::MONTHLY => 'info',
self::ONE_TIME => 'success',
};
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum IdentityVerificationStatus: string implements HasColor, HasLabel
{
case PENDING = 'pending';
case APPROVED = 'approved';
case REJECTED = 'rejected';
public function getLabel(): string
{
return match ($this) {
self::PENDING => __('enums.identity_verification_status.pending'),
self::APPROVED => __('enums.identity_verification_status.approved'),
self::REJECTED => __('enums.identity_verification_status.rejected'),
};
}
public function getColor(): string
{
return match ($this) {
self::PENDING => 'warning',
self::APPROVED => 'success',
self::REJECTED => 'danger',
};
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum NutritionPlanSource: string
{
case ONBOARDING = 'onboarding';
case PROFILE_RECALCULATION = 'profile_recalculation';
case WEEKLY_ADJUSTMENT = 'weekly_adjustment';
}
-39
View File
@@ -1,39 +0,0 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum PaymentTransactionStatus: string implements HasColor, HasLabel
{
case PENDING = 'pending';
case PAID = 'paid';
case CREDITED = 'credited';
case FAILED = 'failed';
case IGNORED = 'ignored';
case ERROR = 'error';
public function getLabel(): string
{
return match ($this) {
self::PENDING => __('enums.payment_transaction_status.pending'),
self::PAID => __('enums.payment_transaction_status.paid'),
self::CREDITED => __('enums.payment_transaction_status.credited'),
self::FAILED => __('enums.payment_transaction_status.failed'),
self::IGNORED => __('enums.payment_transaction_status.ignored'),
self::ERROR => __('enums.payment_transaction_status.error'),
};
}
public function getColor(): string
{
return match ($this) {
self::PENDING => 'gray',
self::PAID => 'info',
self::CREDITED => 'success',
self::FAILED, self::ERROR => 'danger',
self::IGNORED => 'warning',
};
}
}
-28
View File
@@ -1,28 +0,0 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum PaymentTransactionType: string implements HasColor, HasLabel
{
case CHECKOUT = 'checkout';
case SUBSCRIPTION_INVOICE = 'subscription_invoice';
public function getLabel(): string
{
return match ($this) {
self::CHECKOUT => __('enums.payment_transaction_type.checkout'),
self::SUBSCRIPTION_INVOICE => __('enums.payment_transaction_type.subscription_invoice'),
};
}
public function getColor(): string
{
return match ($this) {
self::CHECKOUT => 'success',
self::SUBSCRIPTION_INVOICE => 'info',
};
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum SocialProvider: string
{
case Google = 'google';
case Apple = 'apple';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum WeeklyReviewStatus: string
{
case INSUFFICIENT_DATA = 'insufficient_data';
case ON_TRACK = 'on_track';
case ADJUSTMENT_RECOMMENDED = 'adjustment_recommended';
}
@@ -1,78 +0,0 @@
<?php
namespace App\Filament\Resources\CreditProducts;
use App\Filament\Resources\CreditProducts\Pages\CreateCreditProduct;
use App\Filament\Resources\CreditProducts\Pages\EditCreditProduct;
use App\Filament\Resources\CreditProducts\Pages\ListCreditProducts;
use App\Filament\Resources\CreditProducts\Schemas\CreditProductForm;
use App\Filament\Resources\CreditProducts\Tables\CreditProductsTable;
use App\Models\CreditProduct;
use App\Services\StripeCreditProductSyncer;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class CreditProductResource extends Resource
{
protected static ?string $model = CreditProduct::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCreditCard;
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationLabel(): string
{
return __('admin.credit_products.navigation.label');
}
public static function getModelLabel(): string
{
return __('admin.credit_products.navigation.singular');
}
public static function getPluralModelLabel(): string
{
return __('admin.credit_products.navigation.plural');
}
public static function form(Schema $schema): Schema
{
return CreditProductForm::configure($schema);
}
public static function table(Table $table): Table
{
return CreditProductsTable::configure($table);
}
public static function syncStripeAction(): Action
{
return Action::make('syncStripe')
->label(__('admin.credit_products.actions.sync_stripe'))
->icon(Heroicon::OutlinedArrowPath)
->visible(fn (CreditProduct $record): bool => blank($record->stripe_price_id))
->requiresConfirmation()
->action(function (CreditProduct $record): void {
app(StripeCreditProductSyncer::class)->sync($record);
Notification::make()
->title(__('admin.credit_products.actions.sync_stripe_success'))
->success()
->send();
});
}
public static function getPages(): array
{
return [
'index' => ListCreditProducts::route('/'),
'create' => CreateCreditProduct::route('/create'),
'edit' => EditCreditProduct::route('/{record}/edit'),
];
}
}
@@ -1,16 +0,0 @@
<?php
namespace App\Filament\Resources\CreditProducts\Pages;
use App\Filament\Resources\CreditProducts\CreditProductResource;
use Filament\Resources\Pages\CreateRecord;
class CreateCreditProduct extends CreateRecord
{
protected static string $resource = CreditProductResource::class;
protected function afterCreate(): void
{
app(\App\Services\StripeCreditProductSyncer::class)->sync($this->record);
}
}
@@ -1,25 +0,0 @@
<?php
namespace App\Filament\Resources\CreditProducts\Pages;
use App\Filament\Resources\CreditProducts\CreditProductResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditCreditProduct extends EditRecord
{
protected static string $resource = CreditProductResource::class;
protected function getHeaderActions(): array
{
return [
CreditProductResource::syncStripeAction(),
DeleteAction::make(),
];
}
protected function afterSave(): void
{
app(\App\Services\StripeCreditProductSyncer::class)->sync($this->record);
}
}
@@ -1,19 +0,0 @@
<?php
namespace App\Filament\Resources\CreditProducts\Pages;
use App\Filament\Resources\CreditProducts\CreditProductResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListCreditProducts extends ListRecords
{
protected static string $resource = CreditProductResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -1,73 +0,0 @@
<?php
namespace App\Filament\Resources\CreditProducts\Schemas;
use App\Enums\CreditProductType;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class CreditProductForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make(__('admin.credit_products.sections.product'))
->columns(2)
->schema([
TextInput::make('name')
->label(__('admin.credit_products.fields.name'))
->required()
->maxLength(255),
Select::make('type')
->label(__('admin.credit_products.fields.type'))
->options(CreditProductType::class)
->default(CreditProductType::ONE_TIME->value)
->required(),
TextInput::make('credits')
->label(__('admin.credit_products.fields.credits'))
->integer()
->minValue(1)
->required(),
TextInput::make('amount')
->label(__('admin.credit_products.fields.amount'))
->helperText(__('admin.credit_products.helpers.amount'))
->integer()
->minValue(1)
->required(),
TextInput::make('currency')
->label(__('admin.credit_products.fields.currency'))
->default('eur')
->required()
->maxLength(3),
TextInput::make('sort_order')
->label(__('admin.credit_products.fields.sort_order'))
->integer()
->default(0)
->required(),
Textarea::make('description')
->label(__('admin.credit_products.fields.description'))
->rows(3)
->columnSpanFull(),
Toggle::make('is_active')
->label(__('admin.credit_products.fields.is_active'))
->default(true),
]),
Section::make(__('admin.credit_products.sections.stripe'))
->columns(2)
->schema([
TextInput::make('stripe_product_id')
->label(__('admin.credit_products.fields.stripe_product_id'))
->maxLength(255),
TextInput::make('stripe_price_id')
->label(__('admin.credit_products.fields.stripe_price_id'))
->unique(ignoreRecord: true)
->maxLength(255),
]),
]);
}
}
@@ -1,75 +0,0 @@
<?php
namespace App\Filament\Resources\CreditProducts\Tables;
use App\Enums\CreditProductType;
use App\Filament\Resources\CreditProducts\CreditProductResource;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
class CreditProductsTable
{
public static function configure(Table $table): Table
{
return $table
->defaultSort('sort_order')
->columns([
TextColumn::make('name')
->label(__('admin.credit_products.fields.name'))
->searchable()
->sortable(),
TextColumn::make('type')
->label(__('admin.credit_products.fields.type'))
->badge()
->sortable(),
TextColumn::make('credits')
->label(__('admin.credit_products.fields.credits'))
->numeric(decimalPlaces: 0)
->sortable(),
TextColumn::make('amount')
->label(__('admin.credit_products.fields.amount'))
->money(fn ($record): string => $record->currency, divideBy: 100)
->sortable(),
TextColumn::make('stripe_price_id')
->label(__('admin.credit_products.fields.stripe_price_id'))
->copyable()
->searchable()
->toggleable(),
IconColumn::make('is_active')
->label(__('admin.credit_products.fields.is_active'))
->boolean()
->sortable(),
TextColumn::make('sort_order')
->label(__('admin.credit_products.fields.sort_order'))
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('created_at')
->label(__('admin.credit_products.fields.created_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('type')
->label(__('admin.credit_products.fields.type'))
->options(CreditProductType::class),
TernaryFilter::make('is_active')
->label(__('admin.credit_products.fields.is_active')),
])
->recordActions([
CreditProductResource::syncStripeAction(),
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Filament\Resources\IdentityVerificationRequests;
use App\Filament\Resources\IdentityVerificationRequests\Pages\ListIdentityVerificationRequests;
use App\Filament\Resources\IdentityVerificationRequests\Pages\ViewIdentityVerificationRequest;
use App\Filament\Resources\IdentityVerificationRequests\Schemas\IdentityVerificationRequestInfolist;
use App\Filament\Resources\IdentityVerificationRequests\Tables\IdentityVerificationRequestsTable;
use App\Models\IdentityVerificationRequest;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class IdentityVerificationRequestResource extends Resource
{
protected static ?string $model = IdentityVerificationRequest::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedIdentification;
protected static ?string $recordTitleAttribute = 'id';
public static function getNavigationLabel(): string
{
return __('admin.identity_verification_requests.navigation.label');
}
public static function getModelLabel(): string
{
return __('admin.identity_verification_requests.navigation.singular');
}
public static function getPluralModelLabel(): string
{
return __('admin.identity_verification_requests.navigation.plural');
}
public static function canCreate(): bool
{
return false;
}
public static function infolist(Schema $schema): Schema
{
return IdentityVerificationRequestInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return IdentityVerificationRequestsTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListIdentityVerificationRequests::route('/'),
'view' => ViewIdentityVerificationRequest::route('/{record}'),
];
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\IdentityVerificationRequests\Pages;
use App\Filament\Resources\IdentityVerificationRequests\IdentityVerificationRequestResource;
use Filament\Resources\Pages\ListRecords;
class ListIdentityVerificationRequests extends ListRecords
{
protected static string $resource = IdentityVerificationRequestResource::class;
protected function getHeaderActions(): array
{
return [];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\IdentityVerificationRequests\Pages;
use App\Filament\Resources\IdentityVerificationRequests\IdentityVerificationRequestResource;
use App\Filament\Resources\IdentityVerificationRequests\Tables\IdentityVerificationRequestsTable;
use Filament\Resources\Pages\ViewRecord;
class ViewIdentityVerificationRequest extends ViewRecord
{
protected static string $resource = IdentityVerificationRequestResource::class;
protected function getHeaderActions(): array
{
return [
IdentityVerificationRequestsTable::approveAction(),
IdentityVerificationRequestsTable::rejectAction(),
];
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Filament\Resources\IdentityVerificationRequests\Schemas;
use App\Models\IdentityVerificationRequest;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class IdentityVerificationRequestInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make(__('admin.identity_verification_requests.sections.request'))
->columns(3)
->schema([
TextEntry::make('id')
->label(__('admin.identity_verification_requests.fields.id'))
->copyable(),
TextEntry::make('status')
->label(__('admin.identity_verification_requests.fields.status'))
->badge(),
TextEntry::make('user.email')
->label(__('admin.identity_verification_requests.fields.user'))
->copyable(),
TextEntry::make('submitted_at')
->label(__('admin.identity_verification_requests.fields.submitted_at'))
->dateTime(),
TextEntry::make('reviewed_at')
->label(__('admin.identity_verification_requests.fields.reviewed_at'))
->dateTime()
->placeholder(__('admin.identity_verification_requests.placeholders.empty')),
TextEntry::make('reviewer.email')
->label(__('admin.identity_verification_requests.fields.reviewed_by'))
->placeholder(__('admin.identity_verification_requests.placeholders.empty')),
TextEntry::make('document')
->label(__('admin.identity_verification_requests.fields.document'))
->state(__('admin.identity_verification_requests.actions.open_document'))
->url(fn (IdentityVerificationRequest $record): ?string => $record->temporaryDocumentUrl())
->openUrlInNewTab()
->visible(fn (IdentityVerificationRequest $record): bool => $record->hasDocument()),
TextEntry::make('document_deleted_at')
->label(__('admin.identity_verification_requests.fields.document_deleted_at'))
->dateTime()
->placeholder(__('admin.identity_verification_requests.placeholders.empty')),
TextEntry::make('rejection_reason')
->label(__('admin.identity_verification_requests.fields.rejection_reason'))
->placeholder(__('admin.identity_verification_requests.placeholders.empty'))
->columnSpanFull(),
]),
]);
}
}
@@ -0,0 +1,98 @@
<?php
namespace App\Filament\Resources\IdentityVerificationRequests\Tables;
use App\Actions\ReviewIdentityVerification;
use App\Enums\IdentityVerificationStatus;
use App\Models\IdentityVerificationRequest;
use App\Models\User;
use Filament\Actions\Action;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Textarea;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class IdentityVerificationRequestsTable
{
public static function configure(Table $table): Table
{
return $table
->defaultSort('submitted_at', 'desc')
->columns([
TextColumn::make('submitted_at')
->label(__('admin.identity_verification_requests.fields.submitted_at'))
->dateTime()
->sortable(),
TextColumn::make('status')
->label(__('admin.identity_verification_requests.fields.status'))
->badge()
->sortable(),
TextColumn::make('user.email')
->label(__('admin.identity_verification_requests.fields.user'))
->searchable(),
TextColumn::make('reviewed_at')
->label(__('admin.identity_verification_requests.fields.reviewed_at'))
->dateTime()
->sortable()
->placeholder(__('admin.identity_verification_requests.placeholders.empty')),
])
->filters([
SelectFilter::make('status')
->label(__('admin.identity_verification_requests.fields.status'))
->options(IdentityVerificationStatus::class),
])
->recordActions([
ViewAction::make(),
self::approveAction(),
self::rejectAction(),
]);
}
public static function approveAction(): Action
{
return Action::make('approve')
->label(__('admin.identity_verification_requests.actions.approve'))
->color('success')
->requiresConfirmation()
->visible(fn (IdentityVerificationRequest $record): bool => $record->status === IdentityVerificationStatus::PENDING)
->action(function (IdentityVerificationRequest $record): void {
app(ReviewIdentityVerification::class)->execute(
$record,
IdentityVerificationStatus::APPROVED,
self::reviewer(),
);
});
}
public static function rejectAction(): Action
{
return Action::make('reject')
->label(__('admin.identity_verification_requests.actions.reject'))
->color('danger')
->schema([
Textarea::make('reason')
->label(__('admin.identity_verification_requests.fields.rejection_reason'))
->required()
->maxLength(2000),
])
->visible(fn (IdentityVerificationRequest $record): bool => $record->status === IdentityVerificationStatus::PENDING)
->action(function (array $data, IdentityVerificationRequest $record): void {
app(ReviewIdentityVerification::class)->execute(
$record,
IdentityVerificationStatus::REJECTED,
self::reviewer(),
$data['reason'],
);
});
}
private static function reviewer(): User
{
$reviewer = auth()->user();
abort_unless($reviewer instanceof User, 403);
return $reviewer;
}
}
@@ -1,11 +0,0 @@
<?php
namespace App\Filament\Resources\PaymentTransactions\Pages;
use App\Filament\Resources\PaymentTransactions\PaymentTransactionResource;
use Filament\Resources\Pages\ListRecords;
class ListPaymentTransactions extends ListRecords
{
protected static string $resource = PaymentTransactionResource::class;
}
@@ -1,11 +0,0 @@
<?php
namespace App\Filament\Resources\PaymentTransactions\Pages;
use App\Filament\Resources\PaymentTransactions\PaymentTransactionResource;
use Filament\Resources\Pages\ViewRecord;
class ViewPaymentTransaction extends ViewRecord
{
protected static string $resource = PaymentTransactionResource::class;
}
@@ -1,61 +0,0 @@
<?php
namespace App\Filament\Resources\PaymentTransactions;
use App\Filament\Resources\PaymentTransactions\Pages\ListPaymentTransactions;
use App\Filament\Resources\PaymentTransactions\Pages\ViewPaymentTransaction;
use App\Filament\Resources\PaymentTransactions\Schemas\PaymentTransactionInfolist;
use App\Filament\Resources\PaymentTransactions\Tables\PaymentTransactionsTable;
use App\Models\PaymentTransaction;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class PaymentTransactionResource extends Resource
{
protected static ?string $model = PaymentTransaction::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedReceiptPercent;
protected static ?string $recordTitleAttribute = 'id';
public static function getNavigationLabel(): string
{
return __('admin.payment_transactions.navigation.label');
}
public static function getModelLabel(): string
{
return __('admin.payment_transactions.navigation.singular');
}
public static function getPluralModelLabel(): string
{
return __('admin.payment_transactions.navigation.plural');
}
public static function canCreate(): bool
{
return false;
}
public static function infolist(Schema $schema): Schema
{
return PaymentTransactionInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return PaymentTransactionsTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListPaymentTransactions::route('/'),
'view' => ViewPaymentTransaction::route('/{record}'),
];
}
}
@@ -1,116 +0,0 @@
<?php
namespace App\Filament\Resources\PaymentTransactions\Schemas;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class PaymentTransactionInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make(__('admin.payment_transactions.sections.summary'))
->columns(3)
->schema([
TextEntry::make('id')
->label(__('admin.payment_transactions.fields.id'))
->copyable(),
TextEntry::make('status')
->label(__('admin.payment_transactions.fields.status'))
->badge(),
TextEntry::make('type')
->label(__('admin.payment_transactions.fields.type'))
->badge(),
TextEntry::make('user.email')
->label(__('admin.payment_transactions.fields.user'))
->placeholder(__('admin.payment_transactions.placeholders.empty'))
->copyable(),
TextEntry::make('creditProduct.name')
->label(__('admin.payment_transactions.fields.credit_product'))
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('amount')
->label(__('admin.payment_transactions.fields.amount'))
->money(fn ($record): string => $record->currency ?: 'eur', divideBy: 100)
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('credits_expected')
->label(__('admin.payment_transactions.fields.credits_expected'))
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('credits_granted')
->label(__('admin.payment_transactions.fields.credits_granted')),
TextEntry::make('processed_at')
->label(__('admin.payment_transactions.fields.processed_at'))
->dateTime()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('invoice_email_sent_at')
->label(__('admin.payment_transactions.fields.invoice_email_sent_at'))
->dateTime()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
]),
Section::make(__('admin.payment_transactions.sections.stripe'))
->columns(2)
->schema([
TextEntry::make('stripe_event_type')
->label(__('admin.payment_transactions.fields.stripe_event_type'))
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('stripe_event_id')
->label(__('admin.payment_transactions.fields.stripe_event_id'))
->copyable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('stripe_customer_id')
->label(__('admin.payment_transactions.fields.stripe_customer_id'))
->copyable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('stripe_checkout_session_id')
->label(__('admin.payment_transactions.fields.stripe_checkout_session_id'))
->copyable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('stripe_invoice_id')
->label(__('admin.payment_transactions.fields.stripe_invoice_id'))
->copyable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('stripe_payment_intent_id')
->label(__('admin.payment_transactions.fields.stripe_payment_intent_id'))
->copyable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('stripe_subscription_id')
->label(__('admin.payment_transactions.fields.stripe_subscription_id'))
->copyable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('stripe_price_id')
->label(__('admin.payment_transactions.fields.stripe_price_id'))
->copyable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('invoice_url')
->label(__('admin.payment_transactions.fields.invoice_url'))
->copyable()
->url(fn ($state): ?string => $state)
->openUrlInNewTab()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextEntry::make('invoice_pdf_url')
->label(__('admin.payment_transactions.fields.invoice_pdf_url'))
->copyable()
->url(fn ($state): ?string => $state)
->openUrlInNewTab()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
]),
Section::make(__('admin.payment_transactions.sections.error'))
->schema([
TextEntry::make('error_message')
->label(__('admin.payment_transactions.fields.error_message'))
->placeholder(__('admin.payment_transactions.placeholders.empty'))
->columnSpanFull(),
]),
Section::make(__('admin.payment_transactions.sections.payload'))
->schema([
TextEntry::make('payload')
->label(__('admin.payment_transactions.fields.payload'))
->formatStateUsing(fn (mixed $state): string => json_encode($state ?: [], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}')
->fontFamily('mono')
->columnSpanFull(),
]),
]);
}
}
@@ -1,93 +0,0 @@
<?php
namespace App\Filament\Resources\PaymentTransactions\Tables;
use App\Enums\PaymentTransactionStatus;
use App\Enums\PaymentTransactionType;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class PaymentTransactionsTable
{
public static function configure(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->columns([
TextColumn::make('created_at')
->label(__('admin.payment_transactions.fields.created_at'))
->dateTime()
->sortable(),
TextColumn::make('status')
->label(__('admin.payment_transactions.fields.status'))
->badge()
->sortable(),
TextColumn::make('type')
->label(__('admin.payment_transactions.fields.type'))
->badge()
->sortable(),
TextColumn::make('user.email')
->label(__('admin.payment_transactions.fields.user'))
->searchable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextColumn::make('creditProduct.name')
->label(__('admin.payment_transactions.fields.credit_product'))
->searchable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextColumn::make('amount')
->label(__('admin.payment_transactions.fields.amount'))
->money(fn ($record): string => $record->currency ?: 'eur', divideBy: 100)
->sortable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextColumn::make('credits_expected')
->label(__('admin.payment_transactions.fields.credits_expected'))
->numeric(decimalPlaces: 0)
->sortable()
->placeholder(__('admin.payment_transactions.placeholders.empty')),
TextColumn::make('credits_granted')
->label(__('admin.payment_transactions.fields.credits_granted'))
->numeric(decimalPlaces: 0)
->sortable(),
TextColumn::make('stripe_event_type')
->label(__('admin.payment_transactions.fields.stripe_event_type'))
->toggleable(),
TextColumn::make('stripe_checkout_session_id')
->label(__('admin.payment_transactions.fields.stripe_checkout_session_id_short'))
->copyable()
->searchable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('stripe_invoice_id')
->label(__('admin.payment_transactions.fields.stripe_invoice_id_short'))
->copyable()
->searchable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('stripe_price_id')
->label(__('admin.payment_transactions.fields.stripe_price_id'))
->copyable()
->searchable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('error_message')
->label(__('admin.payment_transactions.fields.error_message'))
->limit(60)
->toggleable(),
TextColumn::make('invoice_email_sent_at')
->label(__('admin.payment_transactions.fields.invoice_email_sent_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('status')
->label(__('admin.payment_transactions.fields.status'))
->options(PaymentTransactionStatus::class),
SelectFilter::make('type')
->label(__('admin.payment_transactions.fields.type'))
->options(PaymentTransactionType::class),
])
->recordActions([
ViewAction::make(),
]);
}
}
@@ -2,12 +2,7 @@
namespace App\Filament\Resources\Users\Schemas;
use App\Enums\PacePreference;
use App\Enums\PhysicalActivityLevel;
use App\Enums\UserRole;
use App\Enums\UserSex;
use App\Enums\WeightGoal;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
@@ -63,86 +58,13 @@ class UserForm
->autocomplete('new-password'),
DateTimePicker::make('email_verified_at')
->label(__('admin.users.fields.email_verified_at')),
DateTimePicker::make('account_verified_at')
->label(__('admin.users.fields.account_verified_at')),
]),
Section::make(__('admin.users.sections.profile'))
->columns(2)
->schema([
Textarea::make('bio')
->label(__('admin.users.fields.bio'))
->rows(4)
->columnSpanFull(),
TextInput::make('height')
->label(__('admin.users.fields.height'))
->integer()
->minValue(0)
->maxValue(300)
->suffix('cm'),
DatePicker::make('date_of_birth')
->label(__('admin.users.fields.date_of_birth'))
->minDate('1900-01-01')
->maxDate(now()->subDay()),
Select::make('sex')
->label(__('admin.users.fields.sex'))
->options(UserSex::class)
->default(UserSex::UNKNOWN->value)
->required(),
]),
Section::make(__('admin.users.sections.nutrition_goals'))
->columns(4)
->schema([
TextInput::make('daily_calorie_goal')
->label(__('admin.users.fields.daily_calorie_goal'))
->integer()
->minValue(0)
->maxValue(100000)
->suffix('kcal')
->default(2000)
->required(),
TextInput::make('daily_protein_goal')
->label(__('admin.users.fields.daily_protein_goal'))
->numeric()
->minValue(0)
->maxValue(10000)
->suffix('g')
->default(120)
->required(),
TextInput::make('daily_carbs_goal')
->label(__('admin.users.fields.daily_carbs_goal'))
->numeric()
->minValue(0)
->maxValue(10000)
->suffix('g')
->default(250)
->required(),
TextInput::make('daily_fats_goal')
->label(__('admin.users.fields.daily_fats_goal'))
->numeric()
->minValue(0)
->maxValue(10000)
->suffix('g')
->default(70)
->required(),
]),
Section::make(__('admin.users.sections.activity'))
->columns(3)
->schema([
Select::make('physical_activity_level')
->label(__('admin.users.fields.physical_activity_level'))
->options(PhysicalActivityLevel::class)
->default(PhysicalActivityLevel::SEDENTARY->value)
->required(),
Select::make('weight_goal')
->label(__('admin.users.fields.weight_goal'))
->options(WeightGoal::class)
->default(WeightGoal::LOSE_WEIGHT->value)
->required(),
Select::make('pace_preference')
->label(__('admin.users.fields.pace_preference'))
->options(PacePreference::class)
->default(PacePreference::SLOW->value)
->required(),
]),
]);
}
@@ -35,6 +35,14 @@ class UserInfolist
->label(__('admin.users.fields.account_verified_at'))
->dateTime()
->placeholder(__('admin.users.placeholders.not_verified')),
TextEntry::make('certification_purchased_at')
->label(__('admin.users.fields.certification_purchased_at'))
->dateTime()
->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('subscription_expires_at')
->label(__('admin.users.fields.subscription_expires_at'))
->dateTime()
->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('suspended_at')
->label(__('admin.users.fields.suspended_at'))
->dateTime()
@@ -59,6 +67,16 @@ class UserInfolist
->numeric(decimalPlaces: 0)
->suffix(' cm')
->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('latestWeightEntry.weight_kg')
->label(__('admin.users.fields.current_weight'))
->numeric(maxDecimalPlaces: 2)
->suffix(' kg')
->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('target_weight')
->label(__('admin.users.fields.target_weight'))
->numeric(maxDecimalPlaces: 2)
->suffix(' kg')
->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('date_of_birth')
->label(__('admin.users.fields.date_of_birth'))
->date()
@@ -67,25 +85,29 @@ class UserInfolist
->label(__('admin.users.fields.sex'))
->badge(),
]),
Section::make(__('admin.users.sections.nutrition_goals'))
Section::make(__('admin.users.sections.nutrition_plan'))
->columns(4)
->schema([
TextEntry::make('daily_calorie_goal')
->label(__('admin.users.fields.daily_calorie_goal'))
TextEntry::make('activeNutritionPlan.calories')
->label(__('admin.users.fields.calorie_goal'))
->numeric(decimalPlaces: 0)
->suffix(' kcal'),
TextEntry::make('daily_protein_goal')
->label(__('admin.users.fields.daily_protein_goal'))
->suffix(' kcal')
->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('activeNutritionPlan.proteins')
->label(__('admin.users.fields.protein_goal'))
->numeric(maxDecimalPlaces: 2)
->suffix(' g'),
TextEntry::make('daily_carbs_goal')
->label(__('admin.users.fields.daily_carbs_goal'))
->suffix(' g')
->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('activeNutritionPlan.carbs')
->label(__('admin.users.fields.carbs_goal'))
->numeric(maxDecimalPlaces: 2)
->suffix(' g'),
TextEntry::make('daily_fats_goal')
->label(__('admin.users.fields.daily_fats_goal'))
->suffix(' g')
->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('activeNutritionPlan.fats')
->label(__('admin.users.fields.fats_goal'))
->numeric(maxDecimalPlaces: 2)
->suffix(' g'),
->suffix(' g')
->placeholder(__('admin.users.placeholders.empty')),
]),
Section::make(__('admin.users.sections.activity'))
->columns(3)
@@ -98,7 +120,12 @@ class UserInfolist
->badge(),
TextEntry::make('pace_preference')
->label(__('admin.users.fields.pace_preference'))
->badge(),
->badge()
->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('onboarding_completed_at')
->label(__('admin.users.fields.onboarding_completed_at'))
->dateTime()
->placeholder(__('admin.users.placeholders.empty')),
]),
Section::make(__('admin.users.sections.metadata'))
->columns(2)
@@ -51,6 +51,12 @@ class UsersTable
->sortable()
->placeholder(__('admin.users.placeholders.not_verified'))
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('subscription_expires_at')
->label(__('admin.users.fields.subscription_expires_at'))
->dateTime()
->sortable()
->placeholder(__('admin.users.placeholders.empty'))
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('suspended_at')
->label(__('admin.users.fields.suspended_at'))
->dateTime()
@@ -89,29 +95,25 @@ class UsersTable
->badge()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('daily_calorie_goal')
->label(__('admin.users.fields.daily_calorie_goal'))
TextColumn::make('activeNutritionPlan.calories')
->label(__('admin.users.fields.calorie_goal'))
->numeric(decimalPlaces: 0)
->suffix(' kcal')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('daily_protein_goal')
->label(__('admin.users.fields.daily_protein_goal'))
TextColumn::make('activeNutritionPlan.proteins')
->label(__('admin.users.fields.protein_goal'))
->numeric(maxDecimalPlaces: 2)
->suffix(' g')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('daily_carbs_goal')
->label(__('admin.users.fields.daily_carbs_goal'))
TextColumn::make('activeNutritionPlan.carbs')
->label(__('admin.users.fields.carbs_goal'))
->numeric(maxDecimalPlaces: 2)
->suffix(' g')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('daily_fats_goal')
->label(__('admin.users.fields.daily_fats_goal'))
TextColumn::make('activeNutritionPlan.fats')
->label(__('admin.users.fields.fats_goal'))
->numeric(maxDecimalPlaces: 2)
->suffix(' g')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('bio')
->label(__('admin.users.fields.bio'))
+44 -49
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Actions\RevokeSocialAuthorizations;
use App\Http\Requests\EmailVerificationNotificationRequest;
use App\Http\Requests\ForgotPasswordRequest;
use App\Http\Requests\LoginRequest;
@@ -11,6 +12,7 @@ use App\Http\Requests\UpdatePasswordRequest;
use App\Http\Requests\UpdateUserRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Services\RevenueCatService;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Auth\Events\Verified;
use Illuminate\Http\JsonResponse;
@@ -42,7 +44,7 @@ class AuthController extends Controller
$this->startWebSession($request, $user);
return response()->json([
'message' => __('api.auth.registered_unverified'),
'code' => 'REGISTERED_UNVERIFIED',
'user' => new UserResource($user),
], 201);
}
@@ -72,7 +74,7 @@ class AuthController extends Controller
}
return response()->json([
'message' => __('api.auth.registered_unverified'),
'code' => 'REGISTERED_UNVERIFIED',
'token' => $this->createMobileToken($user, $request->validated('device_name')),
'user' => new UserResource($user),
], 201);
@@ -100,7 +102,7 @@ class AuthController extends Controller
if (! hash_equals($hash, sha1($user->getEmailForVerification()))) {
if ($request->expectsJson()) {
return response()->json([
'message' => __('api.auth.invalid_verification_link'),
'code' => 'INVALID_VERIFICATION_LINK',
], 403);
}
@@ -117,9 +119,9 @@ class AuthController extends Controller
if ($request->expectsJson()) {
return response()->json([
'message' => $alreadyVerified
? __('api.auth.already_verified')
: __('api.auth.verified'),
'code' => $alreadyVerified
? 'EMAIL_ALREADY_VERIFIED'
: 'EMAIL_VERIFIED',
'user' => new UserResource($user->fresh()),
]);
}
@@ -140,7 +142,7 @@ class AuthController extends Controller
}
return response()->json([
'message' => __('api.auth.verification_sent_if_unverified'),
'code' => 'VERIFICATION_EMAIL_SENT_IF_UNVERIFIED',
]);
}
@@ -151,12 +153,12 @@ class AuthController extends Controller
if ($status === Password::RESET_THROTTLED) {
return response()->json([
'message' => __($status),
'code' => 'PASSWORD_RESET_THROTTLED',
], 429);
}
return response()->json([
'message' => __('api.auth.password_reset_link_sent'),
'code' => 'PASSWORD_RESET_LINK_SENT',
]);
}
@@ -166,12 +168,12 @@ class AuthController extends Controller
if ($status !== Password::PASSWORD_RESET) {
return response()->json([
'message' => __($status),
'code' => 'PASSWORD_RESET_FAILED',
], 422);
}
return response()->json([
'message' => __('api.auth.password_reset'),
'code' => 'PASSWORD_RESET',
]);
}
@@ -227,7 +229,7 @@ class AuthController extends Controller
}
return response()->json([
'message' => __('api.auth.logout'),
'code' => 'LOGGED_OUT',
]);
}
@@ -236,7 +238,7 @@ class AuthController extends Controller
$user = auth()->user();
if (! $user) {
return response()->json(['message' => __('api.auth.unauthenticated')], 401);
return response()->json(['code' => 'UNAUTHENTICATED'], 401);
}
return new UserResource($user);
@@ -246,10 +248,9 @@ class AuthController extends Controller
{
$user = $request->user();
abort_if($user->isSuspended(), 403, __('api.auth.suspended'));
abort_if($user->isSuspended(), 403, 'ACCOUNT_SUSPENDED');
$data = $request->validated();
$nutritionGoals = $data['nutritionGoals'] ?? null;
if ($request->hasFile('avatar')) {
if ($user->avatar_url) {
@@ -262,13 +263,6 @@ class AuthController extends Controller
$userAttributes = $this->userAttributesFromRequestData($data);
if (is_array($nutritionGoals)) {
$userAttributes['daily_calorie_goal'] = $nutritionGoals['calories'];
$userAttributes['daily_protein_goal'] = $nutritionGoals['proteins'];
$userAttributes['daily_carbs_goal'] = $nutritionGoals['carbs'];
$userAttributes['daily_fats_goal'] = $nutritionGoals['fats'];
}
if (! empty($userAttributes)) {
$user->update($userAttributes);
}
@@ -280,7 +274,7 @@ class AuthController extends Controller
{
$user = $request->user();
abort_if($user->isSuspended(), 403, __('api.auth.suspended'));
abort_if($user->isSuspended(), 403, 'ACCOUNT_SUSPENDED');
$currentAccessToken = $user->currentAccessToken();
@@ -298,19 +292,31 @@ class AuthController extends Controller
}
return response()->json([
'message' => __('api.auth.password_updated'),
'code' => 'PASSWORD_UPDATED',
]);
}
public function destroy(Request $request): JsonResponse
{
public function destroy(
Request $request,
RevenueCatService $revenueCat,
RevokeSocialAuthorizations $revokeSocialAuthorizations,
): JsonResponse {
$user = $request->user();
$identityVerificationRequest = $user->identityVerificationRequest()->first();
$storagePaths = collect([$user->avatar_url])
->merge($user->mealPosts()->withTrashed()->pluck('image_url'))
->filter(fn (?string $path): bool => $this->isPublicStoragePath($path))
->values()
->all();
$revokeSocialAuthorizations->execute($user);
try {
$revenueCat->deleteCustomer($user);
} catch (Throwable $exception) {
report($exception);
}
DB::transaction(function () use ($user): void {
$user->tokens()->delete();
$user->notifications()->delete();
@@ -330,6 +336,11 @@ class AuthController extends Controller
Storage::disk()->delete($storagePaths);
}
if ($identityVerificationRequest?->hasDocument()) {
Storage::disk($identityVerificationRequest->document_disk)
->delete($identityVerificationRequest->document_path);
}
if ($request->hasSession()) {
Auth::guard('web')->logout();
$request->session()->invalidate();
@@ -337,7 +348,7 @@ class AuthController extends Controller
}
return response()->json([
'message' => __('api.auth.deleted'),
'code' => 'ACCOUNT_DELETED',
]);
}
@@ -354,7 +365,7 @@ class AuthController extends Controller
$userAttributes['email'] = strtolower($data['email']);
$userAttributes['password'] = Hash::make($data['password']);
$userAttributes['avatar_url'] = $avatarPath;
$userAttributes['terms_accepted_at'] = now();
$user = User::create($userAttributes);
try {
@@ -369,7 +380,7 @@ class AuthController extends Controller
}
return response()->json([
'message' => __('api.auth.verification_email_failed'),
'code' => 'VERIFICATION_EMAIL_FAILED',
], 503);
}
@@ -383,15 +394,15 @@ class AuthController extends Controller
{
$user = User::where('email', strtolower((string) $data['email']))->first();
if (! $user || ! Hash::check((string) $data['password'], $user->password)) {
if (! $user || ! is_string($user->password) || ! Hash::check((string) $data['password'], $user->password)) {
return response()->json([
'message' => __('api.auth.invalid_credentials'),
'code' => 'INVALID_CREDENTIALS',
], 401);
}
if ($user->isSuspended()) {
return response()->json([
'message' => __('api.auth.suspended'),
'code' => 'ACCOUNT_SUSPENDED',
], 403);
}
@@ -401,7 +412,7 @@ class AuthController extends Controller
if (! $user->hasVerifiedEmail()) {
return response()->json([
'message' => __('api.auth.email_not_verified'),
'code' => 'EMAIL_NOT_VERIFIED',
], 403);
}
@@ -467,25 +478,9 @@ class AuthController extends Controller
'name',
'avatar_url',
'locale',
'height',
'bio',
'sex',
'date_of_birth',
]);
$attributeMap = [
'physicalActivityLevel' => 'physical_activity_level',
'weightGoal' => 'weight_goal',
'pacePreference' => 'pace_preference',
'dateOfBirth' => 'date_of_birth',
];
foreach ($attributeMap as $requestKey => $attribute) {
if (array_key_exists($requestKey, $data)) {
$attributes[$attribute] = $data[$requestKey];
}
}
return $attributes;
}
}
-131
View File
@@ -1,131 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Enums\CreditProductType;
use App\Http\Resources\CreditProductResource;
use App\Models\CreditProduct;
use App\Services\MobileDeepLink;
use App\Services\PaymentTransactionRecorder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class BillingController extends Controller
{
public function products(): AnonymousResourceCollection
{
$products = CreditProduct::query()
->active()
->whereNotNull('stripe_price_id')
->orderBy('sort_order')
->orderBy('amount')
->get();
return CreditProductResource::collection($products);
}
public function checkout(
Request $request,
CreditProduct $creditProduct,
PaymentTransactionRecorder $transactions,
): JsonResponse {
abort_unless($creditProduct->is_active, 404);
abort_unless(filled($creditProduct->stripe_price_id), 404);
$user = $request->user();
$this->ensureStripeCustomer($user);
$metadata = [
'credit_product_id' => $creditProduct->getKey(),
'credit_product_type' => $creditProduct->type->value,
'credits' => (string) $creditProduct->credits,
];
$sessionOptions = [
'success_url' => $this->redirectUrl('success'),
'cancel_url' => $this->redirectUrl('cancel'),
'metadata' => $metadata,
'client_reference_id' => $creditProduct->getKey(),
];
if ($creditProduct->type === CreditProductType::MONTHLY) {
if ($user->subscribed('default')) {
return response()->json([
'message' => __('api.billing.active_subscription_exists'),
], 409);
}
$checkout = $user
->newSubscription('default', $creditProduct->stripe_price_id)
->withMetadata($metadata)
->checkout($sessionOptions);
} else {
$sessionOptions['invoice_creation'] = [
'enabled' => true,
'invoice_data' => [
'metadata' => $metadata,
],
];
$checkout = $user->checkout([
$creditProduct->stripe_price_id => 1,
], $sessionOptions);
}
$session = $checkout->asStripeCheckoutSession();
$transactions->recordCheckoutSession(
user: $user,
product: $creditProduct,
stripeCheckoutSessionId: $session->id,
stripeCustomerId: $session->customer,
amount: $session->amount_total,
currency: $session->currency,
);
return response()->json([
'id' => $session->id,
'url' => $session->url,
]);
}
public function portal(Request $request): JsonResponse
{
$user = $request->user();
$this->ensureStripeCustomer($user);
if (! $user->subscribed('default')) {
return response()->json([
'message' => __('api.billing.no_active_subscription'),
], 409);
}
$portalUrl = $user->billingPortalUrl($this->redirectUrl('portal'));
return response()->json([
'url' => $portalUrl,
]);
}
public function redirectToMobileApp(string $status): RedirectResponse
{
abort_unless(in_array($status, ['success', 'cancel', 'portal'], true), 404);
return redirect()->away(MobileDeepLink::to("billing/{$status}"));
}
private function ensureStripeCustomer(mixed $user): void
{
if (! is_object($user) || ! method_exists($user, 'createOrGetStripeCustomer')) {
return;
}
$user->createOrGetStripeCustomer();
}
private function redirectUrl(string $status): string
{
return route('billing.web-return', ['status' => $status]);
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Actions\SubmitIdentityVerification;
use App\Enums\IdentityVerificationStatus;
use App\Http\Requests\StoreIdentityVerificationRequest;
use App\Http\Resources\IdentityVerificationResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CertificationController extends Controller
{
public function show(Request $request): JsonResponse
{
$user = $request->user();
$verificationRequest = $user->identityVerificationRequest()->first();
return response()->json([
'data' => [
'entitlementPurchased' => $user->hasPurchasedCertification(),
'accountVerified' => $user->account_verified_at !== null,
'canSubmitIdentity' => $user->hasPurchasedCertification()
&& $user->account_verified_at === null
&& $verificationRequest?->status !== IdentityVerificationStatus::PENDING,
'verification' => $verificationRequest
? (new IdentityVerificationResource($verificationRequest))->resolve($request)
: null,
],
]);
}
public function store(
StoreIdentityVerificationRequest $request,
SubmitIdentityVerification $submitIdentityVerification,
): JsonResponse {
$submitIdentityVerification->execute(
$request->user(),
$request->file('document'),
);
return $this->show($request);
}
}
@@ -24,9 +24,9 @@ class DeviceTokenController extends Controller
);
return response()->json([
'message' => $deviceToken->wasRecentlyCreated
? __('api.device_tokens.created')
: __('api.device_tokens.updated'),
'code' => $deviceToken->wasRecentlyCreated
? 'DEVICE_TOKEN_CREATED'
: 'DEVICE_TOKEN_UPDATED',
'deviceToken' => [
'id' => $deviceToken->id,
'expoPushToken' => $deviceToken->expo_push_token,
@@ -40,7 +40,7 @@ class DeviceTokenController extends Controller
auth()->user()->notify(new TestNotification);
return response()->json([
'message' => __('api.device_tokens.notification_sent'),
'code' => 'TEST_NOTIFICATION_SENT',
]);
}
+22 -6
View File
@@ -6,6 +6,7 @@ use App\Enums\FollowStatus;
use App\Http\Requests\FollowRequest;
use App\Http\Resources\FollowResource;
use App\Models\User;
use App\Notifications\NewFollowerNotification;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -18,17 +19,26 @@ class FollowController extends Controller
if ($authUser->id === $user->id) {
return response()->json([
'message' => __('api.follows.cannot_follow_self')
'code' => 'CANNOT_FOLLOW_SELF',
], 422);
}
abort_if($authUser->hasBlockingRelationshipWith($user), 404);
// Default to Accepted as is_private doesn't exist in the DB yet
$status = FollowStatus::Accepted;
$authUser->following()->syncWithPivotValues([$user->id], ['status' => $status], false);
$changes = $authUser->following()->syncWithPivotValues([$user->id], ['status' => $status], false);
if ($changes['attached'] !== []) {
$user->notify(
(new NewFollowerNotification($authUser))
->locale($user->preferredLocale()),
);
}
return response()->json([
'message' => __('api.follows.followed'),
'code' => 'USER_FOLLOWED',
'status' => $status,
]);
}
@@ -38,27 +48,31 @@ class FollowController extends Controller
$request->user()->following()->detach($user->id);
return response()->json([
'message' => __('api.follows.unfollowed'),
'code' => 'USER_UNFOLLOWED',
]);
}
public function accept(FollowRequest $request, User $user): JsonResponse
{
abort_if($request->user()->hasBlockingRelationshipWith($user), 404);
$updated = $request->user()->followers()->updateExistingPivot($user->id, [
'status' => FollowStatus::Accepted,
]);
return response()->json([
'message' => $updated ? __('api.follows.accepted') : __('api.follows.not_found'),
'code' => $updated ? 'FOLLOW_REQUEST_ACCEPTED' : 'FOLLOW_REQUEST_NOT_FOUND',
]);
}
public function reject(FollowRequest $request, User $user): JsonResponse
{
abort_if($request->user()->hasBlockingRelationshipWith($user), 404);
$detached = $request->user()->followers()->detach($user->id);
return response()->json([
'message' => $detached ? __('api.follows.rejected') : __('api.follows.not_found'),
'code' => $detached ? 'FOLLOW_REQUEST_REJECTED' : 'FOLLOW_REQUEST_NOT_FOUND',
]);
}
@@ -70,6 +84,7 @@ class FollowController extends Controller
$followers = $user->followers()
->wherePivot('status', FollowStatus::Accepted)
->withoutBlockingRelationshipWith($request->user())
->paginate($perPage);
return FollowResource::collection($followers);
@@ -83,6 +98,7 @@ class FollowController extends Controller
$following = $user->following()
->wherePivot('status', FollowStatus::Accepted)
->withoutBlockingRelationshipWith($request->user())
->paginate($perPage);
return FollowResource::collection($following);
@@ -27,7 +27,7 @@ class LegalDocumentController extends Controller
{
$document = LegalDocument::publicForSlug($slug);
abort_if(! $document, 404, __('api.legal_documents.not_found'));
abort_if(! $document, 404, 'LEGAL_DOCUMENT_NOT_FOUND');
return new LegalDocumentResource($document);
}
@@ -7,7 +7,6 @@ use App\Enums\AiUsageType;
use App\Enums\IngredientUnit;
use App\Enums\MealPostType;
use App\Http\Requests\MealImageAnalysisRequest;
use App\Services\AiCreditService;
use App\Services\AiUsageRecorder;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Http\JsonResponse;
@@ -20,19 +19,15 @@ class MealImageAnalysisController extends Controller
{
public function store(
MealImageAnalysisRequest $request,
AiCreditService $aiCredits,
AiUsageRecorder $aiUsageRecorder,
): JsonResponse {
$user = $request->user();
$image = $request->file('image');
$input = $this->analysisInput($image);
if (! $aiCredits->consume($user)) {
if (! $user->canAnalyzeMeals()) {
return response()->json([
'message' => __('api.meal_image_analysis.insufficient_credits'),
'credits' => [
'balance' => $user->ai_credits_balance,
],
'code' => 'MEAL_ANALYSIS_SUBSCRIPTION_REQUIRED',
], 402);
}
@@ -45,8 +40,6 @@ class MealImageAnalysisController extends Controller
} catch (Throwable $exception) {
report($exception);
$aiCredits->refund($user);
$aiUsageRecorder->failed(
user: $user,
type: AiUsageType::MEAL_IMAGE_ANALYSIS,
@@ -55,7 +48,7 @@ class MealImageAnalysisController extends Controller
);
return response()->json([
'message' => __('api.meal_image_analysis.failed'),
'code' => 'MEAL_ANALYSIS_FAILED',
], 502);
}
@@ -79,9 +72,6 @@ class MealImageAnalysisController extends Controller
return response()->json([
'data' => $draft,
'credits' => [
'balance' => $user->fresh()->ai_credits_balance,
],
]);
}
+17 -6
View File
@@ -137,7 +137,7 @@ class MealPostController extends Controller
public function show(Request $request, MealPosts $mealPost): MealPostsResource
{
$this->abortIfUnavailable($mealPost);
$this->abortIfUnavailable($request, $mealPost);
return new MealPostsResource($this->loadMealPostForResponse($mealPost, $request));
}
@@ -173,7 +173,7 @@ class MealPostController extends Controller
public function like(Request $request, MealPosts $mealPost): MealPostsResource
{
$this->abortIfUnavailable($mealPost);
$this->abortIfUnavailable($request, $mealPost);
$mealPost->likedByUsers()->syncWithoutDetaching([$request->user()->getKey()]);
@@ -182,7 +182,7 @@ class MealPostController extends Controller
public function unlike(Request $request, MealPosts $mealPost): MealPostsResource
{
$this->abortIfUnavailable($mealPost);
$this->abortIfUnavailable($request, $mealPost);
$mealPost->likedByUsers()->detach($request->user()->getKey());
@@ -204,10 +204,16 @@ class MealPostController extends Controller
abort_unless($mealPost->user_id === $request->user()->getKey(), 404);
}
private function abortIfUnavailable(MealPosts $mealPost): void
private function abortIfUnavailable(Request $request, MealPosts $mealPost): void
{
$mealPost->loadMissing('user');
$owner = $mealPost->user;
abort_if(
$mealPost->isHidden() || $mealPost->user()->whereNotNull('suspended_at')->exists(),
$mealPost->isHidden()
|| $owner === null
|| $owner->isSuspended()
|| $request->user()?->hasBlockingRelationshipWith($owner),
404,
);
}
@@ -222,7 +228,12 @@ class MealPostController extends Controller
{
return MealPosts::query()
->whereNull('hidden_at')
->whereHas('user', fn (Builder $query): Builder => $query->whereNull('suspended_at'))
->whereHas(
'user',
fn (Builder $query): Builder => $query
->whereNull('suspended_at')
->withoutBlockingRelationshipWith($request->user())
)
->with('user:id,name,avatar_url,account_verified_at');
}
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers;
use App\Actions\AuthenticateSocialUser;
use App\Enums\SocialProvider;
use App\Http\Requests\MobileSocialLoginRequest;
use App\Http\Requests\MobileSocialNonceRequest;
use App\Http\Resources\UserResource;
use App\Services\SocialAuthenticationNonce;
use Illuminate\Http\JsonResponse;
class MobileSocialAuthController extends Controller
{
public function nonce(
MobileSocialNonceRequest $request,
SocialAuthenticationNonce $socialAuthenticationNonce,
): JsonResponse {
$provider = SocialProvider::from($request->validated('provider'));
return response()->json([
'nonce' => $socialAuthenticationNonce->issue($provider),
]);
}
public function store(
MobileSocialLoginRequest $request,
AuthenticateSocialUser $authenticateSocialUser,
): JsonResponse {
$data = $request->validated();
$user = $authenticateSocialUser->execute($data);
$deviceName = $data['device_name'] ?? null;
$tokenName = is_string($deviceName) && trim($deviceName) !== ''
? trim($deviceName)
: 'mobile';
return response()->json([
'token' => $user->createToken($tokenName)->plainTextToken,
'user' => new UserResource($user),
]);
}
}
@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\NotificationResource;
use App\Notifications\MobileNotification;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class NotificationController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$perPage = max(1, min($request->integer('per_page', 20), 60));
$notifications = $request->user()
->notifications()
->whereIn('type', MobileNotification::TYPES)
->latest()
->paginate($perPage);
return NotificationResource::collection($notifications)->additional([
'unreadCount' => $this->unreadCountFor($request),
]);
}
public function unreadCount(Request $request): JsonResponse
{
return response()->json([
'data' => [
'count' => $this->unreadCountFor($request),
],
]);
}
public function markAsRead(Request $request, string $notification): NotificationResource
{
$databaseNotification = $request->user()
->notifications()
->whereIn('type', MobileNotification::TYPES)
->findOrFail($notification);
$databaseNotification->markAsRead();
return NotificationResource::make($databaseNotification->fresh());
}
public function markAllAsRead(Request $request): JsonResponse
{
$updated = $request->user()
->unreadNotifications()
->whereIn('type', MobileNotification::TYPES)
->update(['read_at' => now()]);
return response()->json([
'data' => [
'updated' => $updated,
],
'code' => 'NOTIFICATIONS_MARKED_AS_READ',
]);
}
private function unreadCountFor(Request $request): int
{
return $request->user()
->unreadNotifications()
->whereIn('type', MobileNotification::TYPES)
->count();
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\UpdateNotificationPreferencesRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NotificationPreferenceController extends Controller
{
public function show(Request $request): JsonResponse
{
return response()->json([
'data' => $this->preferences($request),
]);
}
public function update(UpdateNotificationPreferencesRequest $request): JsonResponse
{
$user = $request->user();
$validated = $request->validated();
$user->forceFill([
'social_notifications_enabled' => $validated['socialNotificationsEnabled']
?? $user->social_notifications_enabled,
'engagement_reminders_enabled' => $validated['engagementRemindersEnabled']
?? $user->engagement_reminders_enabled,
])->save();
return response()->json([
'data' => $this->preferences($request),
'code' => 'NOTIFICATION_PREFERENCES_UPDATED',
]);
}
/** @return array{socialNotificationsEnabled: bool, engagementRemindersEnabled: bool} */
private function preferences(Request $request): array
{
return [
'socialNotificationsEnabled' => (bool) $request->user()->social_notifications_enabled,
'engagementRemindersEnabled' => (bool) $request->user()->engagement_reminders_enabled,
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers;
use App\Actions\CompleteNutritionOnboarding;
use App\Http\Requests\CompleteNutritionOnboardingRequest;
use App\Http\Resources\UserResource;
class NutritionOnboardingController extends Controller
{
public function __invoke(
CompleteNutritionOnboardingRequest $request,
CompleteNutritionOnboarding $completeNutritionOnboarding,
): UserResource {
return new UserResource(
$completeNutritionOnboarding->execute($request->user(), $request->validated())
);
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\NutritionPlanResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class NutritionPlanController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
return NutritionPlanResource::collection(
$request->user()->nutritionPlans()->latest('effective_from')->limit(50)->get()
);
}
public function current(Request $request): NutritionPlanResource
{
$plan = $request->user()->nutritionPlans()->where('is_active', true)->latest('effective_from')->firstOrFail();
return new NutritionPlanResource($plan);
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers;
use App\Actions\RecalculateNutritionPlan;
use App\Enums\NutritionPlanSource;
use App\Http\Requests\UpdateNutritionProfileRequest;
use App\Http\Resources\UserResource;
class NutritionProfileController extends Controller
{
public function __invoke(
UpdateNutritionProfileRequest $request,
RecalculateNutritionPlan $recalculateNutritionPlan,
): UserResource {
return new UserResource($recalculateNutritionPlan->execute(
user: $request->user(),
data: $request->validated(),
source: NutritionPlanSource::PROFILE_RECALCULATION,
));
}
}
+25 -9
View File
@@ -7,6 +7,7 @@ use App\Http\Resources\PostReviewsResource;
use App\Models\MealPosts;
use App\Models\PostReviews;
use App\Notifications\MealPostCommentedNotification;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -16,11 +17,15 @@ class PostReviewsController extends Controller
{
public function index(Request $request, MealPosts $mealPost): AnonymousResourceCollection
{
$this->abortIfUnavailable($mealPost);
$this->abortIfUnavailable($request, $mealPost);
$perPage = max(1, min($request->integer('per_page', 15), 100));
$reviews = $mealPost->reviews()
->whereHas(
'user',
fn (Builder $query): Builder => $query->withoutBlockingRelationshipWith($request->user())
)
->with('user:id,name,avatar_url')
->latest()
->paginate($perPage)
@@ -31,7 +36,7 @@ class PostReviewsController extends Controller
public function store(PostReviewsRequest $request, MealPosts $mealPost): JsonResponse
{
$this->abortIfUnavailable($mealPost);
$this->abortIfUnavailable($request, $mealPost);
$userId = $request->user()->getKey();
@@ -41,7 +46,7 @@ class PostReviewsController extends Controller
->where('user_id', $userId)
->exists(),
409,
__('api.post_reviews.already_exists')
'MEAL_REVIEW_ALREADY_EXISTS'
);
$postReview = PostReviews::create([
@@ -60,7 +65,7 @@ class PostReviewsController extends Controller
public function mine(Request $request, MealPosts $mealPost): JsonResponse|PostReviewsResource
{
$this->abortIfUnavailable($mealPost);
$this->abortIfUnavailable($request, $mealPost);
$postReview = $mealPost->reviews()
->where('user_id', $request->user()->getKey())
@@ -74,10 +79,15 @@ class PostReviewsController extends Controller
return new PostReviewsResource($postReview);
}
public function show(PostReviews $postReview): PostReviewsResource
public function show(Request $request, PostReviews $postReview): PostReviewsResource
{
$postReview->loadMissing('mealPost.user');
$this->abortIfUnavailable($postReview->mealPost);
$postReview->loadMissing('mealPost.user', 'user');
$this->abortIfUnavailable($request, $postReview->mealPost);
abort_if(
$postReview->user === null
|| $request->user()->hasBlockingRelationshipWith($postReview->user),
404,
);
return new PostReviewsResource($postReview->loadMissing(
'mealPost.ingredients',
@@ -110,10 +120,16 @@ class PostReviewsController extends Controller
abort_unless($postReview->user_id === $request->user()->getKey(), 404);
}
private function abortIfUnavailable(MealPosts $mealPost): void
private function abortIfUnavailable(Request $request, MealPosts $mealPost): void
{
$mealPost->loadMissing('user');
$owner = $mealPost->user;
abort_if(
$mealPost->isHidden() || $mealPost->user()->whereNotNull('suspended_at')->exists(),
$mealPost->isHidden()
|| $owner === null
|| $owner->isSuspended()
|| $request->user()?->hasBlockingRelationshipWith($owner),
404,
);
}
@@ -15,14 +15,17 @@ class PublicUserProfileController extends Controller
public function show(Request $request, User $user): JsonResponse
{
abort_if($user->isSuspended(), 404);
abort_if($request->user()?->hasBlockingRelationshipWith($user), 404);
$perPage = max(1, min($request->integer('per_page', 18), 60));
$meals = $user->mealPosts()
->where('visibility', MealPostVisibility::Public->value)
->whereNull('hidden_at')
->with('user:id,name,avatar_url,account_verified_at')
->latest('eaten_at')
->limit(18)
->get();
->latest('id')
->paginate($perPage)
->withQueryString();
return response()->json([
'data' => [
@@ -53,7 +56,13 @@ class PublicUserProfileController extends Controller
->whereIn('status', [FollowStatus::Accepted, FollowStatus::Pending])
->exists()
: false,
'meals' => MealPostsResource::collection($meals)->resolve($request),
'meals' => MealPostsResource::collection($meals->getCollection())->resolve($request),
'mealsPagination' => [
'currentPage' => $meals->currentPage(),
'lastPage' => $meals->lastPage(),
'perPage' => $meals->perPage(),
'total' => $meals->total(),
],
],
]);
}
+3 -3
View File
@@ -26,7 +26,7 @@ class ReportController extends Controller
);
return (new ReportResource($report))
->additional(['message' => __('api.reports.created')])
->additional(['code' => 'REPORT_CREATED'])
->response()
->setStatusCode(201);
}
@@ -44,7 +44,7 @@ class ReportController extends Controller
);
return (new ReportResource($report))
->additional(['message' => __('api.reports.created')])
->additional(['code' => 'REPORT_CREATED'])
->response()
->setStatusCode(201);
}
@@ -62,7 +62,7 @@ class ReportController extends Controller
);
return (new ReportResource($report))
->additional(['message' => __('api.reports.created')])
->additional(['code' => 'REPORT_CREATED'])
->response()
->setStatusCode(201);
}
@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\UserResource;
use App\Models\RevenueCatWebhookEvent;
use App\Models\User;
use App\Notifications\PaymentFailedNotification;
use App\Services\RevenueCatService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
class RevenueCatController extends Controller
{
public function sync(Request $request, RevenueCatService $revenueCat): UserResource
{
return new UserResource($revenueCat->sync($request->user()));
}
public function webhook(Request $request, RevenueCatService $revenueCat): JsonResponse
{
$expectedAuthorization = trim((string) config('billing.revenuecat.webhook_authorization'));
$providedAuthorization = (string) $request->header('Authorization');
abort_if(
$expectedAuthorization === '' || ! hash_equals($expectedAuthorization, $providedAuthorization),
401,
);
$event = $request->input('event');
abort_unless(is_array($event), 422);
$eventId = $this->stringValue($event['id'] ?? null);
$eventType = $this->stringValue($event['type'] ?? null);
abort_unless($eventId && $eventType, 422);
$webhookEvent = RevenueCatWebhookEvent::query()->firstOrCreate([
'event_id' => $eventId,
], [
'type' => $eventType,
'app_user_id' => $this->stringValue($event['app_user_id'] ?? null),
'product_id' => $this->stringValue($event['product_id'] ?? null),
'entitlement_ids' => $this->stringList($event['entitlement_ids'] ?? []),
'environment' => $this->stringValue($event['environment'] ?? null),
'payload' => $event,
]);
if ($webhookEvent->processed_at !== null) {
return response()->json(['received' => true]);
}
$user = $this->userFromEvent($event);
if ($user) {
$revenueCat->sync($user);
if ($eventType === 'BILLING_ISSUE') {
$user->notify(
(new PaymentFailedNotification)->locale($user->preferredLocale()),
);
}
}
$webhookEvent->forceFill(['processed_at' => now()])->save();
return response()->json(['received' => true]);
}
/**
* @param array<string, mixed> $event
*/
private function userFromEvent(array $event): ?User
{
$identifiers = collect([
$event['app_user_id'] ?? null,
$event['original_app_user_id'] ?? null,
])
->merge(Arr::wrap($event['aliases'] ?? []))
->merge(Arr::wrap($event['transferred_to'] ?? []))
->filter(fn (mixed $identifier): bool => is_string($identifier) && $identifier !== '')
->unique()
->values();
return User::query()->whereIn('id', $identifiers)->first();
}
/**
* @return list<string>
*/
private function stringList(mixed $value): array
{
return collect(Arr::wrap($value))
->filter(fn (mixed $item): bool => is_string($item) && $item !== '')
->values()
->all();
}
private function stringValue(mixed $value): ?string
{
return is_string($value) && $value !== '' ? $value : null;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers;
use App\Enums\MealPostVisibility;
use App\Http\Requests\SearchRequest;
use App\Http\Resources\MealPostsResource;
use App\Http\Resources\SearchUserResource;
use App\Models\MealPosts;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class SearchController extends Controller
{
public function __invoke(SearchRequest $request): AnonymousResourceCollection
{
$query = (string) $request->validated('q');
$perPage = (int) ($request->validated('per_page') ?? 20);
if ($request->validated('type') === 'users') {
$users = User::query()
->whereKeyNot($request->user()->getKey())
->whereNull('suspended_at')
->withoutBlockingRelationshipWith($request->user())
->where(function (Builder $builder) use ($query): void {
$builder
->whereLike('name', "%{$query}%", caseSensitive: false)
->orWhereLike('bio', "%{$query}%", caseSensitive: false);
})
->orderByRaw('CASE WHEN LOWER(name) = LOWER(?) THEN 0 WHEN LOWER(name) LIKE LOWER(?) THEN 1 ELSE 2 END', [
$query,
"{$query}%",
])
->orderBy('name')
->paginate($perPage)
->withQueryString();
return SearchUserResource::collection($users);
}
$meals = MealPosts::query()
->where('visibility', MealPostVisibility::Public)
->whereNull('hidden_at')
->whereHas(
'user',
fn (Builder $builder): Builder => $builder
->whereNull('suspended_at')
->withoutBlockingRelationshipWith($request->user())
)
->where(function (Builder $builder) use ($query): void {
$builder
->whereLike('title', "%{$query}%", caseSensitive: false)
->orWhereLike('caption', "%{$query}%", caseSensitive: false)
->orWhereHas(
'ingredients',
fn (Builder $ingredientQuery): Builder => $ingredientQuery
->whereLike('ingredient', "%{$query}%", caseSensitive: false)
);
})
->with('user:id,name,avatar_url,account_verified_at')
->latest('eaten_at')
->paginate($perPage)
->withQueryString();
return MealPostsResource::collection($meals);
}
}
+6 -6
View File
@@ -51,13 +51,13 @@ class StravaController extends Controller
if (! is_array($state) || $state['user_id'] !== $request->user()->getKey()) {
return response()->json([
'message' => __('api.strava.state_expired'),
'code' => 'STRAVA_STATE_EXPIRED',
], 422);
}
if (isset($data['error'])) {
return response()->json([
'message' => __('api.strava.refused'),
'code' => 'STRAVA_AUTHORIZATION_REFUSED',
], 422);
}
@@ -115,14 +115,14 @@ class StravaController extends Controller
report($exception);
return response()->json([
'message' => __('api.strava.disconnect_failed'),
'code' => 'STRAVA_DISCONNECT_FAILED',
], 502);
}
} catch (ConnectionException $exception) {
report($exception);
return response()->json([
'message' => __('api.strava.disconnect_failed'),
'code' => 'STRAVA_DISCONNECT_FAILED',
], 502);
}
@@ -137,13 +137,13 @@ class StravaController extends Controller
if (! $connection) {
return response()->json([
'message' => __('api.strava.missing_connection'),
'code' => 'STRAVA_CONNECTION_MISSING',
], 422);
}
if (! $this->canReadActivities($connection->scopes ?? [])) {
return response()->json([
'message' => __('api.strava.missing_activity_scope'),
'code' => 'STRAVA_ACTIVITY_SCOPE_MISSING',
], 422);
}
@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers;
use App\Actions\BlockUser;
use App\Http\Resources\BlockedUserResource;
use App\Models\User;
use App\Models\UserBlock;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
class UserBlockController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$perPage = max(1, min($request->integer('per_page', 15), 100));
$blockedUsers = $request->user()
->blockedUsers()
->orderByPivotDesc('created_at')
->paginate($perPage)
->withQueryString();
return BlockedUserResource::collection($blockedUsers);
}
public function store(Request $request, User $user, BlockUser $blockUser): JsonResponse
{
abort_if($request->user()->is($user), 422, 'CANNOT_BLOCK_SELF');
$blockUser->execute($request->user(), $user);
return response()->json([
'code' => 'USER_BLOCKED',
]);
}
public function destroy(Request $request, User $user): Response
{
UserBlock::query()
->where('blocker_id', $request->user()->getKey())
->where('blocked_id', $user->getKey())
->delete();
return response()->noContent();
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers;
use App\Actions\AcceptWeeklyReviewAdjustment;
use App\Http\Resources\NutritionPlanResource;
use App\Http\Resources\WeeklyReviewResource;
use App\Models\WeeklyReview;
use App\Services\WeeklyReviewService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class WeeklyReviewController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
return WeeklyReviewResource::collection(
$request->user()->weeklyReviews()->latest('period_start')->limit(52)->get()
);
}
public function current(Request $request, WeeklyReviewService $weeklyReviewService): JsonResponse
{
return (new WeeklyReviewResource($weeklyReviewService->generate($request->user())))
->response()
->setStatusCode(200);
}
public function accept(
Request $request,
WeeklyReview $weeklyReview,
AcceptWeeklyReviewAdjustment $acceptWeeklyReviewAdjustment,
): NutritionPlanResource {
abort_unless($weeklyReview->user_id === $request->user()->getKey(), 404);
return new NutritionPlanResource($acceptWeeklyReviewAdjustment->execute($weeklyReview));
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ListWeightEntriesRequest;
use App\Http\Requests\StoreWeightEntryRequest;
use App\Http\Resources\WeightEntryResource;
use App\Models\WeightEntry;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
class WeightEntryController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(ListWeightEntriesRequest $request): AnonymousResourceCollection
{
$from = now()->subDays($request->integer('days', 365))->startOfDay();
$entries = $request->user()
->weightEntries()
->where('measured_at', '>=', $from)
->latest('measured_at')
->get();
return WeightEntryResource::collection($entries);
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreWeightEntryRequest $request): WeightEntryResource
{
$entry = $request->user()->weightEntries()->create([
'weight_kg' => $request->validated('weightKg'),
'measured_at' => CarbonImmutable::parse($request->validated('measuredAt') ?? now()),
]);
return new WeightEntryResource($entry);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Request $request, WeightEntry $weightEntry): Response
{
abort_unless($weightEntry->user_id === $request->user()->getKey(), 404);
$weightEntry->delete();
return response()->noContent();
}
}
@@ -17,7 +17,7 @@ class EnsureAccountIsNotSuspended
{
if ($request->user()?->isSuspended()) {
return response()->json([
'message' => __('api.auth.suspended'),
'code' => 'ACCOUNT_SUSPENDED',
], 403);
}
@@ -22,7 +22,7 @@ class EnsureEmailIsVerified
($request->user() instanceof MustVerifyEmail &&
! $request->user()->hasVerifiedEmail())) {
return $request->expectsJson()
? abort(403, __('api.auth.email_not_verified'))
? abort(403, 'EMAIL_NOT_VERIFIED')
: Redirect::guest(URL::route($redirectToRoute ?: 'verification.notice'));
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureNutritionOnboardingCompleted
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (! $request->user()?->hasCompletedNutritionOnboarding()) {
return response()->json([
'code' => 'ONBOARDING_REQUIRED',
], 409);
}
return $next($request);
}
}
@@ -0,0 +1,82 @@
<?php
namespace App\Http\Requests;
use App\Enums\PacePreference;
use App\Enums\PhysicalActivityLevel;
use App\Enums\UserSex;
use App\Enums\WeightGoal;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class CompleteNutritionOnboardingRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'dateOfBirth' => [
'required',
'date_format:Y-m-d',
'before_or_equal:'.now()->subYears(18)->toDateString(),
'after_or_equal:'.now()->subYears(100)->toDateString(),
],
'sex' => ['required', Rule::in([UserSex::MAN->value, UserSex::WOMAN->value])],
'heightCm' => ['required', 'integer', 'min:120', 'max:230'],
'weightKg' => ['required', 'numeric', 'min:35', 'max:300'],
'targetWeightKg' => ['nullable', 'numeric', 'min:35', 'max:300'],
'physicalActivityLevel' => ['required', Rule::enum(PhysicalActivityLevel::class)],
'weightGoal' => ['required', Rule::enum(WeightGoal::class)],
'pacePreference' => ['required', Rule::enum(PacePreference::class)],
'nutritionEstimateAccepted' => ['required', 'accepted'],
];
}
public function after(): array
{
return [
function (Validator $validator): void {
$weight = $this->float('weightKg');
$targetWeight = $this->input('targetWeightKg');
$goal = $this->string('weightGoal')->toString();
if ($targetWeight === null || $targetWeight === '') {
return;
}
$targetWeight = (float) $targetWeight;
if ($goal === WeightGoal::LOSE_WEIGHT->value && $targetWeight >= $weight) {
$validator->errors()->add('targetWeightKg', 'NUTRITION_TARGET_WEIGHT_MUST_BE_LOWER');
}
if ($goal === WeightGoal::GAIN_WEIGHT->value && $targetWeight <= $weight) {
$validator->errors()->add('targetWeightKg', 'NUTRITION_TARGET_WEIGHT_MUST_BE_HIGHER');
}
},
];
}
public function messages(): array
{
return [
'dateOfBirth.before_or_equal' => 'NUTRITION_ADULTS_ONLY',
'dateOfBirth.after_or_equal' => 'NUTRITION_BIRTH_DATE_OUT_OF_RANGE',
'sex.in' => 'NUTRITION_METABOLIC_SEX_REQUIRED',
'nutritionEstimateAccepted.accepted' => 'NUTRITION_ESTIMATE_ACCEPTANCE_REQUIRED',
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Concerns;
use App\Services\ContentModerationService;
use Illuminate\Contracts\Validation\Validator;
trait ModeratesUserContent
{
/**
* @param array<int, mixed> $texts
* @param array<int, mixed> $images
* @return array<int, callable>
*/
protected function moderationChecks(array $texts, array $images = []): array
{
return [function (Validator $validator) use ($texts, $images): void {
if ($validator->errors()->isNotEmpty()) {
return;
}
app(ContentModerationService::class)->ensureAcceptable($texts, $images);
}];
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ListWeightEntriesRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()?->hasCompletedNutritionOnboarding() === true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'days' => ['sometimes', 'integer', 'min:7', 'max:3650'],
];
}
}
+40 -1
View File
@@ -6,18 +6,24 @@ use App\Enums\DietType;
use App\Enums\IngredientUnit;
use App\Enums\MealPostType;
use App\Enums\MealPostVisibility;
use App\Http\Requests\Concerns\ModeratesUserContent;
use App\Models\MealPosts;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class MealPostsRequest extends FormRequest
{
use ModeratesUserContent;
public function rules(): array
{
$isCreating = $this->isMethod('post');
return [
'image' => [$isCreating ? 'required_without:image_url' : 'sometimes', 'nullable', 'image', 'max:4096'],
'image_url' => [$isCreating ? 'required_without:image' : 'sometimes', 'nullable', 'string', 'max:2048'],
'image_url' => [$isCreating ? 'required_without:image' : 'sometimes', 'nullable', 'url:http,https', 'max:2048'],
'caption' => ['nullable', 'string', 'max:1000'],
'calories' => ['nullable', 'integer', 'min:0'],
'proteins' => ['nullable', 'numeric', 'min:0'],
@@ -42,6 +48,39 @@ class MealPostsRequest extends FormRequest
return true;
}
public function after(): array
{
$mealPost = $this->route('mealPost');
$currentVisibility = $mealPost instanceof MealPosts
? $mealPost->visibility
: MealPostVisibility::Private;
$visibility = MealPostVisibility::tryFrom((string) $this->input('visibility')) ?? $currentVisibility;
if ($visibility !== MealPostVisibility::Public) {
return [];
}
$isBecomingPublic = $mealPost instanceof MealPosts
&& $mealPost->visibility !== MealPostVisibility::Public;
$ingredients = $this->has('ingredients')
? collect($this->input('ingredients', []))->pluck('ingredient')->all()
: ($isBecomingPublic ? $mealPost->ingredients()->pluck('ingredient')->all() : []);
$existingImageUrl = $isBecomingPublic && filled($mealPost->image_url)
? (Str::startsWith($mealPost->image_url, ['http://', 'https://'])
? $mealPost->image_url
: asset(Storage::url($mealPost->image_url)))
: null;
return $this->moderationChecks(
[
$this->has('title') ? $this->input('title') : ($isBecomingPublic ? $mealPost->title : null),
$this->has('caption') ? $this->input('caption') : ($isBecomingPublic ? $mealPost->caption : null),
$ingredients,
],
[$this->file('image'), $this->input('image_url'), $existingImageUrl],
);
}
public function messages(): array
{
return [
@@ -0,0 +1,62 @@
<?php
namespace App\Http\Requests;
use App\Enums\SocialProvider;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class MobileSocialLoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'authorizationCode' => [
'nullable',
Rule::requiredIf($this->input('provider') === SocialProvider::Apple->value),
'string',
'max:4096',
],
'deviceName' => ['sometimes', 'string', 'max:255'],
'device_name' => ['sometimes', 'string', 'max:255'],
'idToken' => ['required', 'string', 'max:10000'],
'intent' => ['required', Rule::in(['login', 'register'])],
'locale' => ['sometimes', 'string', Rule::in(config('app.supported_locales', ['fr', 'en']))],
'name' => ['sometimes', 'nullable', 'string', 'max:255'],
'nonce' => ['required', 'string', 'size:64', 'regex:/\A[a-f0-9]{64}\z/'],
'provider' => ['required', Rule::enum(SocialProvider::class)],
'termsAccepted' => ['required', 'boolean'],
];
}
protected function prepareForValidation(): void
{
if ($this->has('locale')) {
$locale = strtolower(str_replace('_', '-', (string) $this->input('locale')));
$this->merge([
'locale' => explode('-', $locale)[0],
]);
}
if ($this->has('deviceName') && ! $this->has('device_name')) {
$this->merge([
'device_name' => $this->input('deviceName'),
]);
}
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use App\Enums\SocialProvider;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class MobileSocialNonceRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'provider' => ['required', Rule::enum(SocialProvider::class)],
];
}
}
+8
View File
@@ -2,10 +2,13 @@
namespace App\Http\Requests;
use App\Http\Requests\Concerns\ModeratesUserContent;
use Illuminate\Foundation\Http\FormRequest;
class PostReviewsRequest extends FormRequest
{
use ModeratesUserContent;
public function rules(): array
{
$isCreating = $this->isMethod('post');
@@ -20,4 +23,9 @@ class PostReviewsRequest extends FormRequest
{
return true;
}
public function after(): array
{
return $this->moderationChecks([$this->input('comment')]);
}
}
+12 -9
View File
@@ -2,16 +2,15 @@
namespace App\Http\Requests;
use App\Enums\PacePreference;
use App\Enums\PhysicalActivityLevel;
use App\Enums\UserSex;
use App\Enums\WeightGoal;
use App\Http\Requests\Concerns\ModeratesUserContent;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
class RegisterRequest extends FormRequest
{
use ModeratesUserContent;
/**
* Determine if the user is authorized to make this request.
*/
@@ -50,13 +49,9 @@ class RegisterRequest extends FormRequest
->symbols()
->uncompromised(),
],
'termsAccepted' => ['required', 'accepted'],
'avatar' => ['sometimes', 'nullable', 'image', 'max:2048'],
'bio' => ['nullable', 'string'],
'physicalActivityLevel' => ['sometimes', Rule::enum(PhysicalActivityLevel::class)],
'weightGoal' => ['sometimes', Rule::enum(WeightGoal::class)],
'pacePreference' => ['sometimes', Rule::enum(PacePreference::class)],
'sex' => ['sometimes', Rule::enum(UserSex::class)],
'dateOfBirth' => ['sometimes', 'nullable', 'date_format:Y-m-d', 'before:today', 'after:1900-01-01'],
];
}
@@ -67,6 +62,14 @@ class RegisterRequest extends FormRequest
];
}
public function after(): array
{
return $this->moderationChecks(
[$this->input('name'), $this->input('bio')],
[$this->file('avatar')],
);
}
protected function prepareForValidation(): void
{
if ($this->has('locale')) {
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SearchRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'q' => ['required', 'string', 'min:2', 'max:100'],
'type' => ['required', Rule::in(['meals', 'users'])],
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:50'],
];
}
protected function prepareForValidation(): void
{
if ($this->has('q')) {
$this->merge([
'q' => trim((string) $this->input('q')),
]);
}
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\File;
class StoreIdentityVerificationRequest extends FormRequest
{
public function authorize(): bool
{
$user = $this->user();
return $user?->hasPurchasedCertification() === true
&& $user->account_verified_at === null;
}
public function rules(): array
{
return [
'document' => [
'required',
File::types(['jpg', 'jpeg', 'png', 'pdf'])->max('10mb'),
'mimetypes:image/jpeg,image/png,application/pdf',
],
];
}
public function messages(): array
{
return [
'document.required' => __('api.certification.document_required'),
'document.mimetypes' => __('api.certification.document_type'),
'document.max' => __('api.certification.document_max'),
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreWeightEntryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()?->hasCompletedNutritionOnboarding() === true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'weightKg' => ['required', 'numeric', 'min:35', 'max:300'],
'measuredAt' => ['sometimes', 'nullable', 'date', 'before_or_equal:now'],
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateNotificationPreferencesRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'socialNotificationsEnabled' => ['sometimes', 'required', 'boolean'],
'engagementRemindersEnabled' => ['sometimes', 'required', 'boolean'],
];
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Http\Requests;
class UpdateNutritionProfileRequest extends CompleteNutritionOnboardingRequest
{
public function rules(): array
{
$rules = parent::rules();
unset($rules['nutritionEstimateAccepted']);
return $rules;
}
}
+19 -19
View File
@@ -2,15 +2,14 @@
namespace App\Http\Requests;
use App\Enums\PacePreference;
use App\Enums\PhysicalActivityLevel;
use App\Enums\UserSex;
use App\Enums\WeightGoal;
use App\Http\Requests\Concerns\ModeratesUserContent;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateUserRequest extends FormRequest
{
use ModeratesUserContent;
public function authorize(): bool
{
return true;
@@ -19,31 +18,32 @@ class UpdateUserRequest extends FormRequest
public function rules(): array
{
return [
'name' => [
'sometimes',
'string',
'min:3',
'max:32',
'regex:/^[a-zA-Z0-9_.-]+$/',
Rule::unique('users', 'name')->ignore($this->user()),
],
'bio' => ['sometimes', 'nullable', 'string'],
'height' => ['sometimes', 'nullable', 'integer'],
'locale' => ['sometimes', 'string', Rule::in(config('app.supported_locales', ['fr', 'en']))],
'avatar' => ['sometimes', 'nullable', 'image', 'max:2048'],
'nutritionGoals' => ['sometimes', 'array'],
'nutritionGoals.calories' => ['required_with:nutritionGoals', 'integer', 'min:0', 'max:100000'],
'nutritionGoals.proteins' => ['required_with:nutritionGoals', 'numeric', 'min:0', 'max:10000'],
'nutritionGoals.carbs' => ['required_with:nutritionGoals', 'numeric', 'min:0', 'max:10000'],
'nutritionGoals.fats' => ['required_with:nutritionGoals', 'numeric', 'min:0', 'max:10000'],
'physicalActivityLevel' => ['sometimes', Rule::enum(PhysicalActivityLevel::class)],
'weightGoal' => ['sometimes', Rule::enum(WeightGoal::class)],
'pacePreference' => ['sometimes', Rule::enum(PacePreference::class)],
'sex' => ['sometimes', Rule::enum(UserSex::class)],
'dateOfBirth' => ['sometimes', 'nullable', 'date_format:Y-m-d', 'before:today', 'after:1900-01-01'],
];
}
public function after(): array
{
return $this->moderationChecks(
[$this->input('name'), $this->input('bio')],
[$this->file('avatar')],
);
}
public function messages(): array
{
return [
'avatar.max' => __('api.validation.image_max_2mb'),
'nutritionGoals.calories.integer' => __('api.validation.calories_integer'),
'nutritionGoals.*.required_with' => __('api.validation.nutrition_goals_required'),
'nutritionGoals.*.min' => __('api.validation.nutrition_goals_positive'),
'nutritionGoals.*.max' => __('api.validation.nutrition_goals_max'),
];
}
@@ -2,12 +2,17 @@
namespace App\Http\Resources;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
class CreditProductResource extends JsonResource
/** @mixin User */
class BlockedUserResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
@@ -15,12 +20,7 @@ class CreditProductResource extends JsonResource
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'type' => $this->type,
'credits' => $this->credits,
'amount' => $this->amount,
'currency' => $this->currency,
'formattedAmount' => $this->formattedAmount(),
'avatarUrl' => $this->avatar_url ? asset(Storage::url($this->avatar_url)) : null,
];
}
}
+4
View File
@@ -2,10 +2,12 @@
namespace App\Http\Resources;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
/** @mixin User */
class FollowResource extends JsonResource
{
/**
@@ -18,7 +20,9 @@ class FollowResource extends JsonResource
return [
'id' => $this->id,
'name' => $this->name,
'bio' => $this->bio,
'avatarUrl' => $this->avatar_url ? asset(Storage::url($this->avatar_url)) : null,
'accountVerified' => $this->account_verified_at !== null,
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class IdentityVerificationResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'status' => $this->status,
'submittedAt' => $this->submitted_at?->toISOString(),
'reviewedAt' => $this->reviewed_at?->toISOString(),
'rejectionReason' => $this->rejection_reason,
];
}
}
@@ -15,7 +15,6 @@ class LegalDocumentResource extends JsonResource
'id' => $this->id,
'slug' => $this->slug,
'type' => $this->type?->value,
'typeLabel' => $this->type?->getLabel(),
'version' => $this->version,
'title' => $this->title,
'content' => $this->content,
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class NotificationResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$data = is_array($this->data) ? $this->data : [];
return [
'id' => $this->id,
'type' => $data['type'] ?? $this->type,
'category' => $data['category'] ?? 'account',
'title' => $data['title'] ?? __('api.notifications.default_title'),
'body' => $data['body'] ?? '',
'url' => $data['url'] ?? null,
'readAt' => $this->read_at?->toISOString(),
'createdAt' => $this->created_at?->toISOString(),
];
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Resources;
use App\Models\NutritionPlan;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin NutritionPlan */
class NutritionPlanResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'source' => $this->source,
'formulaVersion' => $this->formula_version,
'calories' => $this->calories,
'proteins' => $this->proteins,
'carbs' => $this->carbs,
'fats' => $this->fats,
'restingMetabolism' => $this->resting_metabolism,
'maintenanceCalories' => $this->maintenance_calories,
'calorieAdjustmentPercent' => $this->calorie_adjustment_percent,
'calculationDetails' => $this->calculation_details,
'effectiveFrom' => $this->effective_from?->toISOString(),
'effectiveUntil' => $this->effective_until?->toISOString(),
'isActive' => $this->is_active,
];
}
}
-2
View File
@@ -17,10 +17,8 @@ class ReportResource extends JsonResource
return [
'id' => $this->id,
'reason' => $this->reason,
'reasonLabel' => $this->reason?->getLabel(),
'details' => $this->details,
'status' => $this->status,
'statusLabel' => $this->status?->getLabel(),
'reportableType' => $this->reportableType(),
'reportableId' => $this->reportable_id,
'moderationCaseId' => $this->moderation_case_id,
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
/** @mixin User */
class SearchUserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'bio' => $this->bio,
'avatarUrl' => $this->avatar_url ? asset(Storage::url($this->avatar_url)) : null,
'accountVerified' => $this->account_verified_at !== null,
];
}
}
+31 -12
View File
@@ -15,36 +15,55 @@ class UserResource extends JsonResource
*/
public function toArray(Request $request): array
{
$this->resource->loadMissing(['activeNutritionPlan', 'latestWeightEntry']);
$nutritionPlan = $this->activeNutritionPlan;
return [
'id' => $this->id, // à voir si suppression par la suite
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'locale' => $this->locale,
'emailVerified' => $this->hasVerifiedEmail(),
'emailVerifiedAt' => $this->email_verified_at?->toISOString(),
'height' => $this->height,
'currentWeight' => $this->latestWeightEntry?->weight_kg,
'targetWeight' => $this->target_weight,
'avatarUrl' => $this->avatar_url ? asset(Storage::url($this->avatar_url)) : null,
'bio' => $this->bio,
'role' => $this->role,
'accountVerified' => $this->account_verified_at !== null,
'aiCreditsBalance' => (int) $this->ai_credits_balance,
'hasActiveSubscription' => $this->subscribed('default'),
'certificationPurchased' => $this->hasPurchasedCertification(),
'canAnalyzeMeals' => $this->canAnalyzeMeals(),
'analysisAccessLevel' => $this->analysisAccessLevel(),
'trialEndsAt' => $this->analysisAccessLevel() === 'trial'
? $this->subscription_expires_at?->toISOString()
: null,
'subscriptionExpiresAt' => $this->subscription_expires_at?->toISOString(),
'subscriptionProductId' => $this->subscription_product_id,
'hasActiveSubscription' => $this->hasActiveSubscription(),
'suspendedAt' => $this->suspended_at?->toISOString(),
'physicalActivityLevel' => $this->physical_activity_level,
'physicalActivityLevelLabel' => $this->physical_activity_level?->getLabel(),
'weightGoal' => $this->weight_goal,
'weightGoalLabel' => $this->weight_goal?->getLabel(),
'pacePreference' => $this->pace_preference,
'pacePreferenceLabel' => $this->pace_preference?->getLabel(),
'sex' => $this->sex,
'sexLabel' => $this->sex?->getLabel(),
'dateOfBirth' => $this->date_of_birth?->toDateString(),
'nutritionGoals' => [
'calories' => (int) $this->daily_calorie_goal,
'proteins' => (float) $this->daily_protein_goal,
'carbs' => (float) $this->daily_carbs_goal,
'fats' => (float) $this->daily_fats_goal,
'onboardingStatus' => $this->hasCompletedNutritionOnboarding() ? 'completed' : 'required',
'missingOnboardingFields' => $this->hasCompletedNutritionOnboarding() ? [] : [
'dateOfBirth',
'sex',
'heightCm',
'weightKg',
'physicalActivityLevel',
'weightGoal',
'pacePreference',
],
'nutritionPlan' => NutritionPlanResource::make($nutritionPlan),
'nutritionGoals' => $nutritionPlan ? [
'calories' => $nutritionPlan->calories,
'proteins' => $nutritionPlan->proteins,
'carbs' => $nutritionPlan->carbs,
'fats' => $nutritionPlan->fats,
] : null,
];
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Resources;
use App\Models\WeeklyReview;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin WeeklyReview */
class WeeklyReviewResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'periodStart' => $this->period_start?->toDateString(),
'periodEnd' => $this->period_end?->toDateString(),
'averages' => [
'calories' => $this->average_calories,
'proteins' => $this->average_proteins,
'carbs' => $this->average_carbs,
'fats' => $this->average_fats,
],
'loggedDaysCount' => $this->logged_days_count,
'weightEntriesCount' => $this->weight_entries_count,
'weightTrendKg' => $this->weight_trend_kg,
'targetWeightTrendKg' => $this->target_weight_trend_kg,
'status' => $this->status,
'recommendedCalorieAdjustment' => $this->recommended_calorie_adjustment,
'messageCode' => $this->message_code,
'metrics' => $this->metrics,
'generatedAt' => $this->generated_at?->toISOString(),
'acceptedAt' => $this->accepted_at?->toISOString(),
'acceptedNutritionPlanId' => $this->accepted_nutrition_plan_id,
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources;
use App\Models\WeightEntry;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin WeightEntry */
class WeightEntryResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'weightKg' => $this->weight_kg,
'source' => $this->source,
'measuredAt' => $this->measured_at?->toISOString(),
'createdAt' => $this->created_at?->toISOString(),
];
}
}
@@ -1,386 +0,0 @@
<?php
namespace App\Listeners;
use App\Enums\CreditLedgerEntryType;
use App\Enums\CreditProductType;
use App\Models\CreditLedgerEntry;
use App\Models\CreditProduct;
use App\Models\User;
use App\Services\AiCreditService;
use App\Services\PaymentInvoiceEmailer;
use App\Services\PaymentTransactionRecorder;
use Laravel\Cashier\Cashier;
class GrantCreditsFromStripeWebhook
{
public function __construct(
private AiCreditService $credits,
private PaymentInvoiceEmailer $invoiceEmails,
private PaymentTransactionRecorder $transactions,
) {}
/**
* @param array{payload: array<string, mixed>} $event
*/
public function handle(object $event): void
{
$payload = $event->payload ?? [];
match ($payload['type'] ?? null) {
'checkout.session.completed' => $this->handleCheckoutSessionCompleted($payload),
'checkout.session.expired' => $this->handleCheckoutSessionExpired($payload),
'invoice.paid' => $this->handleInvoicePaid($payload),
'invoice.payment_succeeded' => $this->handleInvoicePaid($payload),
'invoice.payment_failed' => $this->handleInvoicePaymentFailed($payload),
default => null,
};
}
/**
* @param array<string, mixed> $payload
*/
private function handleCheckoutSessionCompleted(array $payload): void
{
$session = $payload['data']['object'] ?? [];
$eventId = $this->stringValue($payload['id'] ?? null);
$eventType = $this->stringValue($payload['type'] ?? null);
$sessionId = $this->stringValue($session['id'] ?? null);
if (! $sessionId) {
return;
}
if (($session['mode'] ?? null) !== 'payment' || ($session['payment_status'] ?? null) !== 'paid') {
$this->transactions->recordCheckoutSession(
user: $this->userFromCustomer($session['customer'] ?? null),
product: $this->productFromMetadata($session['metadata'] ?? null, CreditProductType::ONE_TIME),
stripeCheckoutSessionId: $sessionId,
status: \App\Enums\PaymentTransactionStatus::IGNORED,
stripeEventId: $eventId,
stripeEventType: $eventType,
stripeCustomerId: $this->stringValue($session['customer'] ?? null),
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
amount: $this->intValue($session['amount_total'] ?? null),
currency: $this->stringValue($session['currency'] ?? null),
errorMessage: 'Checkout session is not a paid one-time payment.',
payload: $session,
);
return;
}
$product = $this->productFromMetadata($session['metadata'] ?? null, CreditProductType::ONE_TIME);
$user = $this->userFromCustomer($session['customer'] ?? null);
if (! $product || ! $user) {
$this->transactions->recordCheckoutSession(
user: $user,
product: $product,
stripeCheckoutSessionId: $sessionId,
status: \App\Enums\PaymentTransactionStatus::ERROR,
stripeEventId: $eventId,
stripeEventType: $eventType,
stripeCustomerId: $this->stringValue($session['customer'] ?? null),
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
amount: $this->intValue($session['amount_total'] ?? null),
currency: $this->stringValue($session['currency'] ?? null),
errorMessage: $product
? 'Stripe customer does not match any user.'
: 'Credit product was not found from checkout metadata.',
payload: $session,
);
return;
}
$source = 'stripe_checkout_session:'.$sessionId;
$entry = $this->credits->grant(
user: $user,
credits: $product->credits,
type: CreditLedgerEntryType::PURCHASE,
product: $product,
source: $source,
stripeCheckoutSessionId: $sessionId,
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
metadata: [
'stripe_event_id' => $eventId,
],
);
$entry ??= CreditLedgerEntry::query()->where('source', $source)->first();
$invoice = $this->retrieveInvoice($this->stringValue($session['invoice'] ?? null));
$transaction = $this->transactions->recordCheckoutSession(
user: $user,
product: $product,
stripeCheckoutSessionId: $sessionId,
status: \App\Enums\PaymentTransactionStatus::CREDITED,
stripeEventId: $eventId,
stripeEventType: $eventType,
stripeCustomerId: $this->stringValue($session['customer'] ?? null),
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
amount: $this->intValue($session['amount_total'] ?? null),
currency: $this->stringValue($session['currency'] ?? null),
creditsGranted: $entry ? $product->credits : 0,
ledgerEntry: $entry,
invoiceUrl: $invoice ? $this->stringValue($invoice['hosted_invoice_url'] ?? null) : null,
invoicePdfUrl: $invoice ? $this->stringValue($invoice['invoice_pdf'] ?? null) : null,
payload: $session,
);
$this->invoiceEmails->sendIfAvailable($transaction);
}
/**
* @param array<string, mixed> $payload
*/
private function handleCheckoutSessionExpired(array $payload): void
{
$session = $payload['data']['object'] ?? [];
$sessionId = $this->stringValue($session['id'] ?? null);
if (! $sessionId) {
return;
}
$this->transactions->recordCheckoutSession(
user: $this->userFromCustomer($session['customer'] ?? null),
product: $this->productFromMetadata($session['metadata'] ?? null, CreditProductType::ONE_TIME),
stripeCheckoutSessionId: $sessionId,
status: \App\Enums\PaymentTransactionStatus::FAILED,
stripeEventId: $this->stringValue($payload['id'] ?? null),
stripeEventType: $this->stringValue($payload['type'] ?? null),
stripeCustomerId: $this->stringValue($session['customer'] ?? null),
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
amount: $this->intValue($session['amount_total'] ?? null),
currency: $this->stringValue($session['currency'] ?? null),
errorMessage: 'Checkout session expired before payment.',
payload: $session,
);
}
/**
* @param array<string, mixed> $payload
*/
private function handleInvoicePaid(array $payload): void
{
$invoice = $payload['data']['object'] ?? [];
$invoiceId = $this->stringValue($invoice['id'] ?? null);
$eventId = $this->stringValue($payload['id'] ?? null);
$eventType = $this->stringValue($payload['type'] ?? null);
if (! $invoiceId) {
return;
}
$user = $this->userFromCustomer($invoice['customer'] ?? null);
if (! $user) {
$this->transactions->recordInvoice(
user: null,
product: null,
stripeInvoiceId: $invoiceId,
stripePriceId: null,
status: \App\Enums\PaymentTransactionStatus::ERROR,
stripeEventId: $eventId,
stripeEventType: $eventType,
stripeCustomerId: $this->stringValue($invoice['customer'] ?? null),
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null),
amount: $this->intValue($invoice['amount_paid'] ?? null),
currency: $this->stringValue($invoice['currency'] ?? null),
errorMessage: 'Stripe customer does not match any user.',
payload: $invoice,
);
return;
}
$matchedLine = false;
foreach (($invoice['lines']['data'] ?? []) as $line) {
$priceId = $line['price']['id'] ?? null;
if (! $priceId) {
continue;
}
$product = CreditProduct::query()
->where('type', CreditProductType::MONTHLY)
->where('stripe_price_id', $priceId)
->first();
if (! $product) {
$this->transactions->recordInvoice(
user: $user,
product: null,
stripeInvoiceId: $invoiceId,
stripePriceId: $this->stringValue($priceId),
status: \App\Enums\PaymentTransactionStatus::ERROR,
stripeEventId: $eventId,
stripeEventType: $eventType,
stripeCustomerId: $this->stringValue($invoice['customer'] ?? null),
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null),
amount: $this->intValue($line['amount'] ?? $invoice['amount_paid'] ?? null),
currency: $this->stringValue($invoice['currency'] ?? null),
errorMessage: 'No monthly credit product found for Stripe price.',
payload: $invoice,
);
continue;
}
$matchedLine = true;
$source = 'stripe_invoice:'.$invoiceId.':price:'.$priceId;
$entry = $this->credits->grant(
user: $user,
credits: $product->credits,
type: CreditLedgerEntryType::SUBSCRIPTION_RENEWAL,
product: $product,
source: $source,
stripeInvoiceId: $invoiceId,
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null),
metadata: [
'billing_reason' => $invoice['billing_reason'] ?? null,
'stripe_event_id' => $eventId,
],
);
$entry ??= CreditLedgerEntry::query()->where('source', $source)->first();
$transaction = $this->transactions->recordInvoice(
user: $user,
product: $product,
stripeInvoiceId: $invoiceId,
stripePriceId: $this->stringValue($priceId),
status: \App\Enums\PaymentTransactionStatus::CREDITED,
stripeEventId: $eventId,
stripeEventType: $eventType,
stripeCustomerId: $this->stringValue($invoice['customer'] ?? null),
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null),
amount: $this->intValue($line['amount'] ?? $invoice['amount_paid'] ?? null),
currency: $this->stringValue($invoice['currency'] ?? null),
creditsGranted: $entry ? $product->credits : 0,
ledgerEntry: $entry,
invoiceUrl: $this->stringValue($invoice['hosted_invoice_url'] ?? null),
invoicePdfUrl: $this->stringValue($invoice['invoice_pdf'] ?? null),
payload: $invoice,
);
$this->invoiceEmails->sendIfAvailable($transaction);
}
if (! $matchedLine) {
$this->transactions->recordInvoice(
user: $user,
product: null,
stripeInvoiceId: $invoiceId,
stripePriceId: null,
status: \App\Enums\PaymentTransactionStatus::IGNORED,
stripeEventId: $eventId,
stripeEventType: $eventType,
stripeCustomerId: $this->stringValue($invoice['customer'] ?? null),
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null),
amount: $this->intValue($invoice['amount_paid'] ?? null),
currency: $this->stringValue($invoice['currency'] ?? null),
errorMessage: 'Invoice does not contain a monthly credit product line.',
payload: $invoice,
);
}
}
/**
* @param array<string, mixed> $payload
*/
private function handleInvoicePaymentFailed(array $payload): void
{
$invoice = $payload['data']['object'] ?? [];
$invoiceId = $this->stringValue($invoice['id'] ?? null);
if (! $invoiceId) {
return;
}
foreach (($invoice['lines']['data'] ?? [null]) as $line) {
$priceId = is_array($line) ? $this->stringValue($line['price']['id'] ?? null) : null;
$amount = is_array($line)
? $this->intValue($line['amount'] ?? $invoice['amount_due'] ?? null)
: $this->intValue($invoice['amount_due'] ?? null);
$product = $priceId
? CreditProduct::query()->where('stripe_price_id', $priceId)->first()
: null;
$this->transactions->recordInvoice(
user: $this->userFromCustomer($invoice['customer'] ?? null),
product: $product,
stripeInvoiceId: $invoiceId,
stripePriceId: $priceId,
status: \App\Enums\PaymentTransactionStatus::FAILED,
stripeEventId: $this->stringValue($payload['id'] ?? null),
stripeEventType: $this->stringValue($payload['type'] ?? null),
stripeCustomerId: $this->stringValue($invoice['customer'] ?? null),
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null),
amount: $amount,
currency: $this->stringValue($invoice['currency'] ?? null),
errorMessage: 'Invoice payment failed.',
payload: $invoice,
);
}
}
private function userFromCustomer(mixed $customer): ?User
{
if (! is_string($customer) || $customer === '') {
return null;
}
return User::query()->where('stripe_id', $customer)->first();
}
private function productFromMetadata(mixed $metadata, CreditProductType $type): ?CreditProduct
{
if (! is_array($metadata) || ! is_string($metadata['credit_product_id'] ?? null)) {
return null;
}
return CreditProduct::query()
->whereKey($metadata['credit_product_id'])
->where('type', $type)
->first();
}
private function stringValue(mixed $value): ?string
{
return is_string($value) && $value !== '' ? $value : null;
}
private function intValue(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
/**
* @return array<string, mixed>|null
*/
private function retrieveInvoice(?string $invoiceId): ?array
{
if (! $invoiceId) {
return null;
}
try {
return Cashier::stripe()
->invoices
->retrieve($invoiceId)
->toArray();
} catch (\Throwable) {
return null;
}
}
}
-65
View File
@@ -1,65 +0,0 @@
<?php
namespace App\Mail;
use App\Models\PaymentTransaction;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PaymentInvoiceMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public User $user,
public PaymentTransaction $transaction,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: trans('mail.payment_invoice.subject', [], $this->mailLocale()),
);
}
public function content(): Content
{
return new Content(
view: 'emails.payment-invoice',
with: [
'amount' => $this->formattedAmount(),
'credits' => $this->transaction->credits_granted ?: $this->transaction->credits_expected,
'invoicePdfUrl' => $this->transaction->invoice_pdf_url,
'invoiceUrl' => $this->transaction->invoice_url,
'locale' => $this->mailLocale(),
'productName' => $this->transaction->creditProduct?->name,
'userName' => $this->user->name,
],
);
}
public function attachments(): array
{
return [];
}
private function formattedAmount(): string
{
if ($this->transaction->amount === null) {
return trans('mail.payment_invoice.unknown_amount', [], $this->mailLocale());
}
return number_format($this->transaction->amount / 100, 2, ',', ' ')
.' '
.strtoupper($this->transaction->currency ?: 'eur');
}
private function mailLocale(): string
{
return $this->user->preferredLocale() ?? 'en';
}
}
-49
View File
@@ -1,49 +0,0 @@
<?php
namespace App\Models;
use App\Enums\CreditLedgerEntryType;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CreditLedgerEntry extends Model
{
/** @use HasFactory<\Database\Factories\CreditLedgerEntryFactory> */
use HasFactory, HasUlids;
protected $fillable = [
'user_id',
'credit_product_id',
'type',
'credits',
'balance_after',
'source',
'stripe_checkout_session_id',
'stripe_invoice_id',
'stripe_payment_intent_id',
'stripe_subscription_id',
'metadata',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function creditProduct(): BelongsTo
{
return $this->belongsTo(CreditProduct::class);
}
protected function casts(): array
{
return [
'type' => CreditLedgerEntryType::class,
'credits' => 'integer',
'balance_after' => 'integer',
'metadata' => 'array',
];
}
}

Some files were not shown because too many files have changed in this diff Show More