Compare commits
46 Commits
6a24d07cec
...
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 | |||
| 5cf0510aa5 | |||
| ea0d625bf9 | |||
| c9dafc0f05 | |||
| 1db50bc69a | |||
| 548100af74 | |||
| 1fdb5f4cda | |||
| 5d2f4d0ee0 | |||
| 1216c8a073 | |||
| a01387bb09 | |||
| 51b50aa1d0 |
+1
-1
@@ -1,6 +1,5 @@
|
||||
.git
|
||||
.ssh
|
||||
.kamal/secrets*
|
||||
vendor
|
||||
node_modules
|
||||
storage/logs
|
||||
@@ -8,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
|
||||
@@ -0,0 +1,67 @@
|
||||
name: CD
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- CI
|
||||
branches:
|
||||
- main
|
||||
types:
|
||||
- completed
|
||||
jobs:
|
||||
deploy:
|
||||
name: Déploiement production
|
||||
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
|
||||
echo "${{ secrets.DEPLOY_SSH_KEY }}" \
|
||||
> ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan \
|
||||
-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: |
|
||||
ssh \
|
||||
-i ~/.ssh/id_ed25519 \
|
||||
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << 'EOF'
|
||||
set -e
|
||||
|
||||
echo "Projet"
|
||||
cd /data/stacks/bowli-api
|
||||
|
||||
echo "Pull nouvelle image"
|
||||
docker compose pull
|
||||
|
||||
echo "Redémarrage"
|
||||
docker compose up -d --wait --wait-timeout 180
|
||||
|
||||
EOF
|
||||
@@ -0,0 +1,124 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
jobs:
|
||||
test:
|
||||
name: 🧪 Tests Laravel
|
||||
runs-on: ubuntu-latest
|
||||
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:
|
||||
php-version: '8.4'
|
||||
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: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: |
|
||||
attempt=1
|
||||
|
||||
until composer install \
|
||||
--no-interaction \
|
||||
--prefer-dist \
|
||||
--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: |
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
touch database/database.sqlite
|
||||
php artisan migrate --force
|
||||
|
||||
|
||||
- name: 🧪 Run PHPUnit
|
||||
run: |
|
||||
php artisan test
|
||||
|
||||
|
||||
|
||||
docker:
|
||||
name: 🐳 Build & Push Images
|
||||
needs: test
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 📥 Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🔐 Login Registry Gitea
|
||||
run: |
|
||||
docker login git.leonmorival.xyz \
|
||||
-u "${{ secrets.REGISTRY_USER }}" \
|
||||
-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: 🏗️ 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 }}
|
||||
@@ -1,165 +0,0 @@
|
||||
name: Laravel CI-CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
env:
|
||||
KAMAL_IMAGE: ghcr.io/basecamp/kamal:v2.11.0
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Tests Unitaires
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run Tests
|
||||
uses: docker://laravelsail/php84-composer:latest
|
||||
env:
|
||||
APP_ENV: testing
|
||||
APP_KEY: base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
DB_CONNECTION: sqlite
|
||||
DB_DATABASE: ":memory:"
|
||||
with:
|
||||
args: >
|
||||
bash -lc "apt-get update
|
||||
&& apt-get install -y --no-install-recommends libicu-dev
|
||||
&& docker-php-ext-install intl
|
||||
&& php -m | grep -qi '^intl$'
|
||||
&& composer install --no-interaction --prefer-dist
|
||||
&& php -d memory_limit=512M artisan test --compact"
|
||||
|
||||
deploy:
|
||||
name: Deploy with Kamal
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.TOKEN_GITEA }}
|
||||
APP_KEY: ${{ secrets.APP_KEY }}
|
||||
DB_DATABASE: ${{ secrets.DB_DATABASE }}
|
||||
DB_USERNAME: ${{ secrets.DB_USERNAME }}
|
||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
||||
MEILISEARCH_KEY: ${{ secrets.MEILISEARCH_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
STRIPE_KEY: ${{ secrets.STRIPE_KEY }}
|
||||
STRIPE_SECRET: ${{ secrets.STRIPE_SECRET }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
STRAVA_CLIENT_ID: ${{ secrets.STRAVA_CLIENT_ID }}
|
||||
STRAVA_CLIENT_SECRET: ${{ secrets.STRAVA_CLIENT_SECRET }}
|
||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
|
||||
run: |
|
||||
set -eu
|
||||
: "${GEMINI_API_KEY:?GEMINI_API_KEY secret is required}"
|
||||
: "${STRIPE_KEY:?STRIPE_KEY secret is required}"
|
||||
: "${STRIPE_SECRET:?STRIPE_SECRET secret is required}"
|
||||
: "${STRIPE_WEBHOOK_SECRET:?STRIPE_WEBHOOK_SECRET secret is required}"
|
||||
: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID secret is required}"
|
||||
: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY secret is required}"
|
||||
|
||||
WORKSPACE="${GITHUB_WORKSPACE:-$PWD}"
|
||||
test -f "$WORKSPACE/config/deploy.yml"
|
||||
|
||||
tar \
|
||||
--exclude=.kamal/secrets \
|
||||
--exclude=.kamal/secrets.* \
|
||||
--exclude=.env \
|
||||
--exclude=.env.* \
|
||||
-C "$WORKSPACE" -cf - . | docker run --rm -i \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e GITEA_TOKEN \
|
||||
-e APP_KEY \
|
||||
-e DB_DATABASE \
|
||||
-e DB_USERNAME \
|
||||
-e DB_PASSWORD \
|
||||
-e MEILISEARCH_KEY \
|
||||
-e GEMINI_API_KEY \
|
||||
-e STRIPE_KEY \
|
||||
-e STRIPE_SECRET \
|
||||
-e STRIPE_WEBHOOK_SECRET \
|
||||
-e SSH_HOST \
|
||||
-e SSH_USER \
|
||||
-e SSH_PRIVATE_KEY \
|
||||
-e STRAVA_CLIENT_ID \
|
||||
-e STRAVA_CLIENT_SECRET \
|
||||
-e RESEND_API_KEY \
|
||||
-e AWS_ACCESS_KEY_ID \
|
||||
-e AWS_SECRET_ACCESS_KEY \
|
||||
--entrypoint /bin/sh \
|
||||
"$KAMAL_IMAGE" -lc '
|
||||
set -eu
|
||||
|
||||
mkdir -p /workdir /root/.ssh
|
||||
tar -xf - -C /workdir
|
||||
cd /workdir
|
||||
|
||||
chmod 700 /root/.ssh
|
||||
printf "%s\n" "$SSH_PRIVATE_KEY" | tr -d "\r" > /root/.ssh/id_ed25519
|
||||
chmod 600 /root/.ssh/id_ed25519
|
||||
|
||||
REMOTE_HOST="${SSH_HOST:-89.167.35.217}"
|
||||
REMOTE_USER="${SSH_USER:-root}"
|
||||
|
||||
mkdir -p /workdir/.ssh
|
||||
cat > /workdir/.ssh/config <<'"'"'SSH_CONFIG'"'"'
|
||||
Host *
|
||||
IdentityFile /root/.ssh/id_ed25519
|
||||
IdentitiesOnly yes
|
||||
StrictHostKeyChecking yes
|
||||
UserKnownHostsFile /workdir/.ssh/known_hosts
|
||||
GlobalKnownHostsFile /dev/null
|
||||
CheckHostIP no
|
||||
SSH_CONFIG
|
||||
ssh-keyscan -H "$REMOTE_HOST" > /workdir/.ssh/known_hosts
|
||||
chmod 600 /workdir/.ssh/config /workdir/.ssh/known_hosts
|
||||
|
||||
mkdir -p .kamal
|
||||
cat > .kamal/secrets <<'"'"'SECRETS'"'"'
|
||||
GITEA_TOKEN=$GITEA_TOKEN
|
||||
APP_KEY=$APP_KEY
|
||||
DB_DATABASE=$DB_DATABASE
|
||||
DB_USERNAME=$DB_USERNAME
|
||||
DB_PASSWORD=$DB_PASSWORD
|
||||
MEILISEARCH_KEY=$MEILISEARCH_KEY
|
||||
GEMINI_API_KEY=$GEMINI_API_KEY
|
||||
STRIPE_KEY=$STRIPE_KEY
|
||||
STRIPE_SECRET=$STRIPE_SECRET
|
||||
STRIPE_WEBHOOK_SECRET=$STRIPE_WEBHOOK_SECRET
|
||||
SSH_PRIVATE_KEY=$SSH_PRIVATE_KEY
|
||||
STRAVA_CLIENT_ID=$STRAVA_CLIENT_ID
|
||||
STRAVA_CLIENT_SECRET=$STRAVA_CLIENT_SECRET
|
||||
RESEND_API_KEY=$RESEND_API_KEY
|
||||
AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
|
||||
AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
|
||||
SECRETS
|
||||
chmod 600 .kamal/secrets
|
||||
|
||||
ssh -F /workdir/.ssh/config "$REMOTE_USER@$REMOTE_HOST" <<'"'"'REMOTE'"'"'
|
||||
set -eu
|
||||
|
||||
docker network inspect bemeal_public >/dev/null 2>&1 || docker network create bemeal_public
|
||||
docker network inspect bemeal_internal >/dev/null 2>&1 || docker network create bemeal_internal
|
||||
|
||||
for container in bemeal-pgsql bemeal-redis bemeal-meilisearch bemeal-adminer; do
|
||||
if docker inspect "$container" >/dev/null 2>&1; then
|
||||
docker network connect bemeal_internal "$container" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
REMOTE
|
||||
|
||||
/kamal/bin/kamal deploy
|
||||
'
|
||||
+4
-1
@@ -23,4 +23,7 @@ Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
.env.prod
|
||||
.kamal/secrets
|
||||
|
||||
**/caddy
|
||||
frankenphp
|
||||
frankenphp-worker.php
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "Docker set up on $KAMAL_HOSTS..."
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..."
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# A sample post-deploy hook
|
||||
#
|
||||
# These environment variables are available:
|
||||
# KAMAL_RECORDED_AT
|
||||
# KAMAL_PERFORMER
|
||||
# KAMAL_VERSION
|
||||
# KAMAL_HOSTS
|
||||
# KAMAL_ROLES (if set)
|
||||
# KAMAL_DESTINATION (if set)
|
||||
# KAMAL_RUNTIME
|
||||
|
||||
echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds"
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "Rebooted kamal-proxy on $KAMAL_HOSTS"
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..."
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# A sample pre-build hook
|
||||
#
|
||||
# Checks:
|
||||
# 1. We have a clean checkout
|
||||
# 2. A remote is configured
|
||||
# 3. The branch has been pushed to the remote
|
||||
# 4. The version we are deploying matches the remote
|
||||
#
|
||||
# These environment variables are available:
|
||||
# KAMAL_RECORDED_AT
|
||||
# KAMAL_PERFORMER
|
||||
# KAMAL_VERSION
|
||||
# KAMAL_HOSTS
|
||||
# KAMAL_ROLES (if set)
|
||||
# KAMAL_DESTINATION (if set)
|
||||
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "Git checkout is not clean, aborting..." >&2
|
||||
git status --porcelain >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
first_remote=$(git remote)
|
||||
|
||||
if [ -z "$first_remote" ]; then
|
||||
echo "No git remote set, aborting..." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
current_branch=$(git branch --show-current)
|
||||
|
||||
if [ -z "$current_branch" ]; then
|
||||
echo "Not on a git branch, aborting..." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1)
|
||||
|
||||
if [ -z "$remote_head" ]; then
|
||||
echo "Branch not pushed to remote, aborting..." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$KAMAL_VERSION" != "$remote_head" ]; then
|
||||
echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,47 +0,0 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
# A sample pre-connect check
|
||||
#
|
||||
# Warms DNS before connecting to hosts in parallel
|
||||
#
|
||||
# These environment variables are available:
|
||||
# KAMAL_RECORDED_AT
|
||||
# KAMAL_PERFORMER
|
||||
# KAMAL_VERSION
|
||||
# KAMAL_HOSTS
|
||||
# KAMAL_ROLES (if set)
|
||||
# KAMAL_DESTINATION (if set)
|
||||
# KAMAL_RUNTIME
|
||||
|
||||
hosts = ENV["KAMAL_HOSTS"].split(",")
|
||||
results = nil
|
||||
max = 3
|
||||
|
||||
elapsed = Benchmark.realtime do
|
||||
results = hosts.map do |host|
|
||||
Thread.new do
|
||||
tries = 1
|
||||
|
||||
begin
|
||||
Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME)
|
||||
rescue SocketError
|
||||
if tries < max
|
||||
puts "Retrying DNS warmup: #{host}"
|
||||
tries += 1
|
||||
sleep rand
|
||||
retry
|
||||
else
|
||||
puts "DNS warmup failed: #{host}"
|
||||
host
|
||||
end
|
||||
end
|
||||
|
||||
tries
|
||||
end
|
||||
end.map(&:value)
|
||||
end
|
||||
|
||||
retries = results.sum - hosts.size
|
||||
nopes = results.count { |r| r == max }
|
||||
|
||||
puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ]
|
||||
@@ -1,122 +0,0 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
# A sample pre-deploy hook
|
||||
#
|
||||
# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds.
|
||||
#
|
||||
# Fails unless the combined status is "success"
|
||||
#
|
||||
# These environment variables are available:
|
||||
# KAMAL_RECORDED_AT
|
||||
# KAMAL_PERFORMER
|
||||
# KAMAL_VERSION
|
||||
# KAMAL_HOSTS
|
||||
# KAMAL_COMMAND
|
||||
# KAMAL_SUBCOMMAND
|
||||
# KAMAL_ROLES (if set)
|
||||
# KAMAL_DESTINATION (if set)
|
||||
|
||||
# Only check the build status for production deployments
|
||||
if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production"
|
||||
exit 0
|
||||
end
|
||||
|
||||
require "bundler/inline"
|
||||
|
||||
# true = install gems so this is fast on repeat invocations
|
||||
gemfile(true, quiet: true) do
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "octokit"
|
||||
gem "faraday-retry"
|
||||
end
|
||||
|
||||
MAX_ATTEMPTS = 72
|
||||
ATTEMPTS_GAP = 10
|
||||
|
||||
def exit_with_error(message)
|
||||
$stderr.puts message
|
||||
exit 1
|
||||
end
|
||||
|
||||
class GithubStatusChecks
|
||||
attr_reader :remote_url, :git_sha, :github_client, :combined_status
|
||||
|
||||
def initialize
|
||||
@remote_url = github_repo_from_remote_url
|
||||
@git_sha = `git rev-parse HEAD`.strip
|
||||
@github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"])
|
||||
refresh!
|
||||
end
|
||||
|
||||
def refresh!
|
||||
@combined_status = github_client.combined_status(remote_url, git_sha)
|
||||
end
|
||||
|
||||
def state
|
||||
combined_status[:state]
|
||||
end
|
||||
|
||||
def first_status_url
|
||||
first_status = combined_status[:statuses].find { |status| status[:state] == state }
|
||||
first_status && first_status[:target_url]
|
||||
end
|
||||
|
||||
def complete_count
|
||||
combined_status[:statuses].count { |status| status[:state] != "pending"}
|
||||
end
|
||||
|
||||
def total_count
|
||||
combined_status[:statuses].count
|
||||
end
|
||||
|
||||
def current_status
|
||||
if total_count > 0
|
||||
"Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..."
|
||||
else
|
||||
"Build not started..."
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def github_repo_from_remote_url
|
||||
url = `git config --get remote.origin.url`.strip.delete_suffix(".git")
|
||||
if url.start_with?("https://github.com/")
|
||||
url.delete_prefix("https://github.com/")
|
||||
elsif url.start_with?("git@github.com:")
|
||||
url.delete_prefix("git@github.com:")
|
||||
else
|
||||
url
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
$stdout.sync = true
|
||||
|
||||
begin
|
||||
puts "Checking build status..."
|
||||
|
||||
attempts = 0
|
||||
checks = GithubStatusChecks.new
|
||||
|
||||
loop do
|
||||
case checks.state
|
||||
when "success"
|
||||
puts "Checks passed, see #{checks.first_status_url}"
|
||||
exit 0
|
||||
when "failure"
|
||||
exit_with_error "Checks failed, see #{checks.first_status_url}"
|
||||
when "pending"
|
||||
attempts += 1
|
||||
end
|
||||
|
||||
exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS
|
||||
|
||||
puts checks.current_status
|
||||
sleep(ATTEMPTS_GAP)
|
||||
checks.refresh!
|
||||
end
|
||||
rescue Octokit::NotFound
|
||||
exit_with_error "Build status could not be found"
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "Rebooting kamal-proxy on $KAMAL_HOSTS..."
|
||||
@@ -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.'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,6 @@ class ExpoPushChannel
|
||||
'tokens_count' => $tokens->count(),
|
||||
]);
|
||||
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum FollowStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Accepted = 'accepted';
|
||||
case Rejected = 'rejected';
|
||||
case Blocked = 'blocked';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,11 +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;
|
||||
}
|
||||
@@ -1,20 +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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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\TextInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,92 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\CreditProductType;
|
||||
use App\Http\Resources\CreditProductResource;
|
||||
use App\Models\CreditProduct;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
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): 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) {
|
||||
$checkout = $user
|
||||
->newSubscription('default', $creditProduct->stripe_price_id)
|
||||
->withMetadata($metadata)
|
||||
->checkout($sessionOptions);
|
||||
} else {
|
||||
$checkout = $user->checkout([
|
||||
$creditProduct->stripe_price_id => 1,
|
||||
], $sessionOptions);
|
||||
}
|
||||
|
||||
$session = $checkout->asStripeCheckoutSession();
|
||||
|
||||
return response()->json([
|
||||
'id' => $session->id,
|
||||
'url' => $session->url,
|
||||
]);
|
||||
}
|
||||
|
||||
public function portal(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$this->ensureStripeCustomer($user);
|
||||
|
||||
$portalUrl = $user->billingPortalUrl($this->redirectUrl('portal'));
|
||||
|
||||
return response()->json([
|
||||
'url' => $portalUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
private function ensureStripeCustomer(mixed $user): void
|
||||
{
|
||||
if (! is_object($user) || ! method_exists($user, 'createOrGetStripeCustomer')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user->createOrGetStripeCustomer();
|
||||
}
|
||||
|
||||
private function redirectUrl(string $status): string
|
||||
{
|
||||
$scheme = trim((string) config('app.mobile_scheme', env('APP_MOBILE_SCHEME', 'bowly')), ':/');
|
||||
|
||||
return "{$scheme}:///billing/{$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',
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
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;
|
||||
|
||||
class FollowController extends Controller
|
||||
{
|
||||
public function follow(FollowRequest $request, User $user): JsonResponse
|
||||
{
|
||||
$authUser = $request->user();
|
||||
|
||||
if ($authUser->id === $user->id) {
|
||||
return response()->json([
|
||||
'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;
|
||||
|
||||
$changes = $authUser->following()->syncWithPivotValues([$user->id], ['status' => $status], false);
|
||||
|
||||
if ($changes['attached'] !== []) {
|
||||
$user->notify(
|
||||
(new NewFollowerNotification($authUser))
|
||||
->locale($user->preferredLocale()),
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'USER_FOLLOWED',
|
||||
'status' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
public function unfollow(FollowRequest $request, User $user): JsonResponse
|
||||
{
|
||||
$request->user()->following()->detach($user->id);
|
||||
|
||||
return response()->json([
|
||||
'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([
|
||||
'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([
|
||||
'code' => $detached ? 'FOLLOW_REQUEST_REJECTED' : 'FOLLOW_REQUEST_NOT_FOUND',
|
||||
]);
|
||||
}
|
||||
|
||||
public function followers(Request $request, User $user): AnonymousResourceCollection
|
||||
{
|
||||
abort_unless($request->user()->id === $user->id, 403);
|
||||
|
||||
$perPage = max(1, min($request->integer('per_page', 15), 100));
|
||||
|
||||
$followers = $user->followers()
|
||||
->wherePivot('status', FollowStatus::Accepted)
|
||||
->withoutBlockingRelationshipWith($request->user())
|
||||
->paginate($perPage);
|
||||
|
||||
return FollowResource::collection($followers);
|
||||
}
|
||||
|
||||
public function following(Request $request, User $user): AnonymousResourceCollection
|
||||
{
|
||||
abort_unless($request->user()->id === $user->id, 403);
|
||||
|
||||
$perPage = max(1, min($request->integer('per_page', 15), 100));
|
||||
|
||||
$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,20 +19,15 @@ class MealImageAnalysisController extends Controller
|
||||
{
|
||||
public function store(
|
||||
MealImageAnalysisRequest $request,
|
||||
AiCreditService $aiCredits,
|
||||
AiUsageRecorder $aiUsageRecorder,
|
||||
): JsonResponse
|
||||
{
|
||||
): 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);
|
||||
}
|
||||
|
||||
@@ -46,8 +40,6 @@ class MealImageAnalysisController extends Controller
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
$aiCredits->refund($user);
|
||||
|
||||
$aiUsageRecorder->failed(
|
||||
user: $user,
|
||||
type: AiUsageType::MEAL_IMAGE_ANALYSIS,
|
||||
@@ -56,7 +48,7 @@ class MealImageAnalysisController extends Controller
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => __('api.meal_image_analysis.failed'),
|
||||
'code' => 'MEAL_ANALYSIS_FAILED',
|
||||
], 502);
|
||||
}
|
||||
|
||||
@@ -80,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');
|
||||
}
|
||||
|
||||
@@ -231,7 +242,7 @@ class MealPostController extends Controller
|
||||
$data = $request->validated();
|
||||
|
||||
if ($request->hasFile('image')) {
|
||||
$path = $request->file('image')->store('meal-posts', );
|
||||
$path = $request->file('image')->store('meal-posts');
|
||||
$data['image_url'] = $path;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\FollowStatus;
|
||||
use App\Enums\MealPostVisibility;
|
||||
use App\Http\Resources\MealPostsResource;
|
||||
use App\Models\User;
|
||||
@@ -14,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' => [
|
||||
@@ -39,8 +43,26 @@ class PublicUserProfileController extends Controller
|
||||
->where('visibility', MealPostVisibility::Public->value)
|
||||
->whereNull('hidden_at')
|
||||
->count(),
|
||||
'followersCount' => $user->followers()
|
||||
->wherePivot('status', FollowStatus::Accepted)
|
||||
->count(),
|
||||
'followingCount' => $user->following()
|
||||
->wherePivot('status', FollowStatus::Accepted)
|
||||
->count(),
|
||||
],
|
||||
'isFollowing' => $request->user()
|
||||
? $request->user()->following()
|
||||
->where('following_id', $user->id)
|
||||
->whereIn('status', [FollowStatus::Accepted, FollowStatus::Pending])
|
||||
->exists()
|
||||
: false,
|
||||
'meals' => MealPostsResource::collection($meals->getCollection())->resolve($request),
|
||||
'mealsPagination' => [
|
||||
'currentPage' => $meals->currentPage(),
|
||||
'lastPage' => $meals->lastPage(),
|
||||
'perPage' => $meals->perPage(),
|
||||
'total' => $meals->total(),
|
||||
],
|
||||
'meals' => MealPostsResource::collection($meals)->resolve($request),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\WorkoutSessionsRequest;
|
||||
use App\Http\Requests\WorkoutSessionsCalendarRequest;
|
||||
use App\Http\Requests\WorkoutSessionsRequest;
|
||||
use App\Http\Resources\WorkoutSessionsResource;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
@@ -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 FollowRequest 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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// No specific body rules for now as we use route model binding
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?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 FollowResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user