Compare commits
36 Commits
5cf0510aa5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e16e3f17ec | |||
| 411bdce002 | |||
| 2d794ff0c9 | |||
| a037e99cff | |||
| 3ee3024de9 | |||
| 5b07d9db4e | |||
| ddc80956a0 | |||
| c0688044e8 | |||
| 7aa5c4e659 | |||
| f752c28d99 | |||
| 238a044d98 | |||
| 8b60283eeb | |||
| 69372a49ed | |||
| ea5a5737ec | |||
| 8ef1a3cff8 | |||
| 334d94e712 | |||
| fd59e627cc | |||
| 5702ad88e4 | |||
| 29e85589b0 | |||
| 31633cfd2c | |||
| eef021fc1f | |||
| df4f523089 | |||
| bd1b4bba20 | |||
| 2f2bac9d3a | |||
| cef3e29ddc | |||
| 6b757c37be | |||
| 68560655f3 | |||
| eac86afa53 | |||
| 909e32523d | |||
| 792a6c1b05 | |||
| 52818e50fb | |||
| 2ece4b6e3e | |||
| 99cb0e63e6 | |||
| 573718b38f | |||
| 3ed951b506 | |||
| edb7f6d2a1 |
@@ -7,3 +7,4 @@ storage/framework/cache/data
|
||||
.env
|
||||
.env.*
|
||||
public/storage
|
||||
public/build
|
||||
|
||||
+44
-16
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -23,3 +23,7 @@ Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
.env.prod
|
||||
|
||||
**/caddy
|
||||
frankenphp
|
||||
frankenphp-worker.php
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum NutritionPlanSource: string
|
||||
{
|
||||
case ONBOARDING = 'onboarding';
|
||||
case PROFILE_RECALCULATION = 'profile_recalculation';
|
||||
case WEEKLY_ADJUSTMENT = 'weekly_adjustment';
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum SocialProvider: string
|
||||
{
|
||||
case Google = 'google';
|
||||
case Apple = 'apple';
|
||||
}
|
||||
@@ -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(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+61
@@ -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}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
+16
@@ -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 [];
|
||||
}
|
||||
}
|
||||
+20
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+55
@@ -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(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+98
@@ -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'))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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)],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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')]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+7
-7
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user