Compare commits

...

46 Commits

Author SHA1 Message Date
leonm e16e3f17ec feat: try fix
CI / 🧪 Tests Laravel (push) Successful in 9m13s
CI / 🐳 Build & Push Images (push) Successful in 1m55s
2026-08-17 20:50:30 +02:00
leonm 411bdce002 feat: fix seeder
CI / 🧪 Tests Laravel (push) Failing after 8m41s
CI / 🐳 Build & Push Images (push) Has been skipped
2026-08-17 16:48:27 +02:00
leonm 2d794ff0c9 feat: auth with google
CI / 🧪 Tests Laravel (push) Successful in 3m9s
CI / 🐳 Build & Push Images (push) Successful in 47s
2026-08-17 15:13:21 +02:00
leonm a037e99cff ci: harden Composer downloads against rate limits
CI / 🧪 Tests Laravel (push) Successful in 2m41s
CI / 🐳 Build & Push Images (push) Successful in 2m32s
2026-08-17 10:35:49 +02:00
leonm 3ee3024de9 feat: try fix ci/cd
CI / 🧪 Tests Laravel (push) Failing after 1m28s
CI / 🐳 Build & Push Images (push) Has been skipped
2026-08-17 10:25:15 +02:00
leonm 5b07d9db4e feat: remove immutabilité from ci/cd
CI / 🧪 Tests Laravel (push) Failing after 1m6s
CI / 🐳 Build & Push Images (push) Has been skipped
2026-08-17 10:11:09 +02:00
leonm ddc80956a0 feat: google login
CI / 🧪 Tests Laravel (push) Failing after 9s
CI / 🐳 Build & Push Images (push) Has been skipped
2026-08-17 10:06:35 +02:00
leonm c0688044e8 feat: upgrade laravel from 12 to 13
CI / 🧪 Tests Laravel (push) Failing after 1m3s
CI / 🐳 Build & Push Images (push) Has been skipped
2026-08-16 17:36:34 +02:00
leonm 7aa5c4e659 ci: prevent edits to existing migrations
CI / 🧪 Tests Laravel (push) Successful in 2m24s
CI / 🐳 Build & Push Images (push) Successful in 1m5s
2026-08-16 13:16:15 +02:00
leonm f752c28d99 feat: followers and following
CI / 🧪 Tests Laravel (push) Successful in 2m27s
CI / 🐳 Build & Push Images (push) Successful in 43s
2026-08-14 14:17:29 +02:00
leonm 238a044d98 feat: seeder
CI / 🧪 Tests Laravel (push) Successful in 2m33s
CI / 🐳 Build & Push Images (push) Successful in 49s
2026-08-14 13:43:35 +02:00
leonm 8b60283eeb feat: translation codes
CI / 🧪 Tests Laravel (push) Successful in 2m34s
CI / 🐳 Build & Push Images (push) Successful in 2m26s
2026-08-14 12:46:29 +02:00
leonm 69372a49ed feat: history and calculate needs for user
CI / 🧪 Tests Laravel (push) Failing after 2m18s
CI / 🐳 Build & Push Images (push) Has been skipped
2026-08-14 12:45:37 +02:00
leonm ea5a5737ec feat: remove horizon
CI / 🧪 Tests Laravel (push) Successful in 2m15s
CI / 🐳 Build & Push Images (push) Successful in 1m9s
2026-08-13 16:58:13 +02:00
leonm 8ef1a3cff8 feat: try fix ci cd
CI / 🧪 Tests Laravel (push) Successful in 2m13s
CI / 🐳 Build & Push Images (push) Successful in 1m5s
2026-08-13 14:30:46 +02:00
leonm 334d94e712 feat: backup cron
CI / 🧪 Tests Laravel (push) Successful in 2m8s
CI / 🐳 Build & Push Image (push) Successful in 35s
2026-08-13 14:20:28 +02:00
leonm fd59e627cc feat: change server
CI / 🧪 Tests Laravel (push) Successful in 2m19s
CI / 🐳 Build & Push Image (push) Successful in 35s
2026-08-13 10:37:02 +02:00
leonm 5702ad88e4 feat: laravel prompts
CI / 🧪 Tests Laravel (push) Successful in 2m31s
CI / 🐳 Build & Push Image (push) Successful in 39s
2026-08-13 09:45:00 +02:00
leonm 29e85589b0 feat: revenue cat
CI / 🧪 Tests Laravel (push) Successful in 2m24s
CI / 🐳 Build & Push Image (push) Successful in 1m13s
2026-08-12 16:00:11 +02:00
leonm 31633cfd2c feat: name
CI / 🧪 Tests Laravel (push) Successful in 2m16s
CI / 🐳 Build & Push Image (push) Successful in 38s
2026-08-12 10:08:59 +02:00
leonm eef021fc1f feat: moderation
CI / 🧪 Tests Laravel (push) Successful in 2m11s
CI / 🐳 Build & Push Image (push) Successful in 40s
2026-08-12 09:46:28 +02:00
leonm df4f523089 feat: add persistent mobile notifications
CI / 🧪 Tests Laravel (push) Successful in 2m7s
CI / 🐳 Build & Push Image (push) Successful in 42s
2026-08-11 16:46:05 +02:00
leonm bd1b4bba20 refactor: hardcode Bowli mobile scheme
CI / 🧪 Tests Laravel (push) Successful in 2m8s
CI / 🐳 Build & Push Image (push) Successful in 31s
2026-08-11 16:20:27 +02:00
leonm 2f2bac9d3a feat: paginate public profile meals
CI / 🧪 Tests Laravel (push) Successful in 2m12s
CI / 🐳 Build & Push Image (push) Successful in 38s
2026-08-11 16:06:58 +02:00
leonm cef3e29ddc feat: add reciprocal user blocking
CI / 🧪 Tests Laravel (push) Successful in 2m6s
CI / 🐳 Build & Push Image (push) Successful in 39s
2026-08-11 15:58:31 +02:00
leonm 6b757c37be feat: accept terms
CI / 🧪 Tests Laravel (push) Successful in 2m7s
CI / 🐳 Build & Push Image (push) Successful in 38s
2026-08-07 16:10:25 +02:00
leonm 68560655f3 feat: clean credits users
CI / 🧪 Tests Laravel (push) Successful in 2m0s
CI / 🐳 Build & Push Image (push) Successful in 35s
2026-08-07 15:32:40 +02:00
leonm eac86afa53 feat: create user command
CI / 🧪 Tests Laravel (push) Successful in 2m7s
CI / 🐳 Build & Push Image (push) Successful in 37s
2026-08-07 15:01:50 +02:00
leonm 909e32523d feat: mails
CI / 🧪 Tests Laravel (push) Successful in 2m6s
CI / 🐳 Build & Push Image (push) Successful in 38s
2026-08-07 14:54:27 +02:00
leonm 792a6c1b05 feat: fix test proxy
CI / 🧪 Tests Laravel (push) Successful in 2m2s
CI / 🐳 Build & Push Image (push) Successful in 37s
2026-08-07 13:35:47 +02:00
leonm 52818e50fb feat: trusproxies
CI / 🧪 Tests Laravel (push) Failing after 2m9s
CI / 🐳 Build & Push Image (push) Has been skipped
2026-08-07 13:28:25 +02:00
leonm 2ece4b6e3e feat: subscriptions
CI / 🧪 Tests Laravel (push) Successful in 2m48s
CI / 🐳 Build & Push Image (push) Successful in 3m0s
2026-08-07 11:53:46 +02:00
leonm 99cb0e63e6 feat: build assets
CI / 🧪 Tests Laravel (push) Successful in 2m2s
CI / 🐳 Build & Push Image (push) Successful in 21s
2026-07-16 17:05:00 +02:00
leonm 573718b38f feat: cd
CI / 🧪 Tests Laravel (push) Successful in 1m57s
CI / 🐳 Build & Push Image (push) Successful in 1m34s
2026-07-16 16:08:18 +02:00
leonm 3ed951b506 feat: php version and franken php
CI / 🧪 Tests Laravel (push) Successful in 2m0s
CI / 🐳 Build & Push Image (push) Failing after 6s
2026-07-16 15:47:15 +02:00
leonm edb7f6d2a1 feat: fix dockerfile
CI / 🧪 Tests Laravel (push) Successful in 1m55s
CI / 🐳 Build & Push Image (push) Failing after 6s
2026-07-16 15:44:03 +02:00
leonm 5cf0510aa5 feat: ci/cd
CI / 🧪 Tests Laravel (push) Successful in 2m49s
CI / 🐳 Build & Push Image (push) Successful in 2m41s
2026-07-16 15:19:50 +02:00
leonm ea0d625bf9 feat: add isFollowing 2026-05-28 21:03:18 +02:00
leonm c9dafc0f05 feat: get number of follows on public profile 2026-05-28 20:52:48 +02:00
leonm 1db50bc69a follow 2026-05-28 20:37:53 +02:00
leonm 548100af74 feat: update products on stripe 2026-05-27 17:22:19 +02:00
leonm 1fdb5f4cda feat: fix 2026-05-27 13:30:07 +02:00
leonm 5d2f4d0ee0 feat: bigint to ulid 2026-05-27 13:24:53 +02:00
leonm 1216c8a073 feat: add invoice 2026-05-27 13:14:09 +02:00
leonm a01387bb09 feat: historique paiement 2026-05-27 11:37:29 +02:00
leonm 51b50aa1d0 feat: deeplink 2026-05-27 10:56:56 +02:00
273 changed files with 12664 additions and 5964 deletions
+1 -1
View File
@@ -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
View File
@@ -1,10 +1,14 @@
APP_NAME=Starter
APP_NAME=Bowli
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_MOBILE_SCHEME=bowly
APP_PORT=8003
IMAGE_TAG=
POSTGRES_VERSION=15-alpine
REDIS_VERSION=7.4-alpine
MEILISEARCH_VERSION=1.49.0
ADMINER_VERSION=5.5.0-standalone
APP_LOCALE=fr
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
@@ -49,30 +53,54 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
MAIL_MAILER=resend
MAIL_FROM_ADDRESS=noreply@leonmorival.xyz
MAIL_FROM_NAME="Bowli"
RESEND_API_KEY=
EXPO_ACCESS_TOKEN=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=
APPLE_CLIENT_ID=com.meal.daily
# Comma-separated when several Apple bundle or service IDs are accepted.
APPLE_CLIENT_IDS=
APPLE_TEAM_ID=
APPLE_KEY_ID=
# Base64-encoded contents of the Sign in with Apple .p8 private key.
APPLE_PRIVATE_KEY_BASE64=
# Alternative to APPLE_PRIVATE_KEY_BASE64: raw PEM with escaped \n characters.
APPLE_PRIVATE_KEY=
STRAVA_CLIENT_ID=
STRAVA_CLIENT_SECRET=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_SESSION_TOKEN=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
# Production database backups (use a private bucket or a private prefix)
BACKUP_CRON="0 3 * * *"
BACKUP_LOCAL_RETENTION_DAYS=7
BACKUP_RUN_ON_START=true
BACKUP_S3_BUCKET=bowli-backup
BACKUP_S3_PREFIX=database-backups/postgres
BACKUP_S3_ENDPOINT=
TZ=Europe/Paris
OLLAMA_API_KEY=
GEMINI_API_KEY=
OPENAI_API_KEY=
CONTENT_MODERATION_ENABLED=false
CONTENT_MODERATION_MODEL=omni-moderation-latest
CONTENT_MODERATION_TIMEOUT=12
AI_MEAL_USER_NAME="Daily Meal AI"
AI_MEAL_USER_NAME="Bowli AI"
AI_MEAL_USER_EMAIL=ai@daily-meal.local
AI_MEAL_IMAGE_PATH=meal-posts/ai-generated
AI_MEAL_IMAGE_QUALITY=medium
@@ -85,8 +113,8 @@ MEILISEARCH_KEY=masterKey
VITE_APP_NAME="${APP_NAME}"
#Stripe
STRIPE_KEY=your-stripe-key
STRIPE_SECRET=your-stripe-secret
STRIPE_WEBHOOK_SECRET=your-stripe-webhook-secret
CASHIER_CURRENCY=eur
# RevenueCat (secret server key and exact webhook Authorization header)
REVENUECAT_SECRET_KEY=
REVENUECAT_WEBHOOK_AUTHORIZATION=
REVENUECAT_SUBSCRIPTION_ENTITLEMENT="bowli Pro"
REVENUECAT_CERTIFICATION_ENTITLEMENT=certification
+82
View File
@@ -0,0 +1,82 @@
APP_NAME=Bowli
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=https://dailyapi.leonmorival.xyz
APP_LOCALE=fr
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=fr_FR
LOG_CHANNEL=stderr
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=warning
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=
DB_USERNAME=
DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=true
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax
CACHE_STORE=redis
QUEUE_CONNECTION=redis
REDIS_CLIENT=predis
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=eu-north-1
AWS_BUCKET=bowli
AWS_URL=
AWS_ENDPOINT=
AWS_USE_PATH_STYLE_ENDPOINT=false
MAIL_MAILER=resend
MAIL_FROM_ADDRESS=noreply@leonmorival.xyz
MAIL_FROM_NAME="Bowli"
RESEND_API_KEY=
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://meilisearch:7700
MEILISEARCH_KEY=
MEILISEARCH_VERSION=1.49.0
GEMINI_API_KEY=
OPENAI_API_KEY=
CONTENT_MODERATION_ENABLED=true
CONTENT_MODERATION_MODEL=omni-moderation-latest
CONTENT_MODERATION_TIMEOUT=12
AI_MEAL_USER_NAME="Bowli AI"
AI_MEAL_USER_EMAIL=ai@daily-meal.local
AI_MEAL_IMAGE_PATH=meal-posts/ai-generated
AI_MEAL_IMAGE_QUALITY=medium
AI_MEAL_TEXT_TIMEOUT=90
AI_MEAL_IMAGE_TIMEOUT=120
EXPO_ACCESS_TOKEN=
STRAVA_CLIENT_ID=
STRAVA_CLIENT_SECRET=
STRAVA_SCOPE=read,activity:read
REVENUECAT_SECRET_KEY=
REVENUECAT_WEBHOOK_AUTHORIZATION=
REVENUECAT_SUBSCRIPTION_ENTITLEMENT="bowli Pro"
REVENUECAT_CERTIFICATION_ENTITLEMENT=certification
HEALTH_SECRET_TOKEN=
SCHEDULE_HEARTBEAT_URL=
IMAGE_TAG=latest
ADMINER_PORT=8080
+67
View File
@@ -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
+124
View File
@@ -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 }}
-165
View File
@@ -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
View File
@@ -23,4 +23,7 @@ Homestead.json
Homestead.yaml
Thumbs.db
.env.prod
.kamal/secrets
**/caddy
frankenphp
frankenphp-worker.php
-3
View File
@@ -1,3 +0,0 @@
#!/bin/sh
echo "Docker set up on $KAMAL_HOSTS..."
-3
View File
@@ -1,3 +0,0 @@
#!/bin/sh
echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..."
-14
View File
@@ -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"
-3
View File
@@ -1,3 +0,0 @@
#!/bin/sh
echo "Rebooted kamal-proxy on $KAMAL_HOSTS"
-3
View File
@@ -1,3 +0,0 @@
#!/bin/sh
echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..."
-51
View File
@@ -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
-47
View File
@@ -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 ]
-122
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
#!/bin/sh
echo "Rebooting kamal-proxy on $KAMAL_HOSTS..."
-1
View File
@@ -13,7 +13,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
- filament/filament (FILAMENT) - v5
- laravel/ai (AI) - v0
- laravel/framework (LARAVEL) - v12
- laravel/horizon (HORIZON) - v5
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- laravel/scout (SCOUT) - v11
+73 -48
View File
@@ -1,61 +1,86 @@
# --- Étape de Build (Composer) ---
FROM composer:2 AS composer-bin
FROM dunglas/frankenphp:php8.4-alpine AS php
FROM dunglas/frankenphp:1.3-php8.4-alpine AS composer-build
WORKDIR /app
WORKDIR /var/www/html
COPY --from=composer-bin /usr/bin/composer /usr/bin/composer
RUN install-php-extensions intl zip bcmath pdo_pgsql pcntl redis
COPY . .
RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
# --- Étape de Build (Node) ---
FROM node:20-alpine AS node-build
WORKDIR /var/www/html
COPY package*.json ./
RUN npm ci
COPY --from=composer-build /var/www/html/vendor ./vendor
COPY . .
RUN npm run build
# --- Étape Finale (Production) ---
FROM dunglas/frankenphp:1.3-php8.4-alpine
# On installe les extensions pour le fonctionnement de l'app en prod
RUN apk add --no-cache \
libpq-dev \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
icu-dev
RUN install-php-extensions \
intl \
bcmath \
pdo_pgsql \
gd \
zip \
opcache \
pcntl \
redis
zip \
intl
WORKDIR /var/www/html
COPY --from=composer-build /var/www/html /var/www/html
COPY --from=node-build /var/www/html/public/build /var/www/html/public/build
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Permissions (crucial pour Laravel)
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
ENV APP_ENV=production
ENV APP_RUNTIME=Laravel\FrankenPHP\Runtime
COPY composer.json composer.lock ./
HEALTHCHECK NONE
RUN --mount=type=cache,target=/tmp/composer-cache \
--mount=type=secret,id=composer_github_token,required=false \
set -eu; \
if [ -s /run/secrets/composer_github_token ]; then \
export COMPOSER_AUTH="{\"github-oauth\":{\"github.com\":\"$(cat /run/secrets/composer_github_token)\"}}"; \
fi; \
export COMPOSER_CACHE_DIR=/tmp/composer-cache; \
export COMPOSER_MAX_PARALLEL_HTTP=4; \
attempt=1; \
until composer install \
--no-dev \
--no-interaction \
--prefer-dist \
--optimize-autoloader \
--no-scripts; do \
if [ "$attempt" -ge 4 ]; then \
exit 1; \
fi; \
delay=$((attempt * 15)); \
echo "Composer install failed (attempt $attempt/4), retrying in ${delay}s..."; \
sleep "$delay"; \
attempt=$((attempt + 1)); \
done
CMD ["sh", "-c", "php artisan migrate --force && php artisan storage:link && php artisan optimize && php artisan view:cache && php artisan event:cache && frankenphp run --config /etc/caddy/Caddyfile"]
COPY . .
RUN composer dump-autoload --optimize
FROM node:22-alpine AS frontend
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
COPY --from=php /app/vendor ./vendor
RUN npm run build
FROM php AS production
COPY --from=frontend /app/public/build ./public/build
RUN mkdir -p \
storage/app/public \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
bootstrap/cache \
&& chown -R www-data:www-data storage bootstrap/cache
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/entrypoint.sh"]
+195
View File
@@ -0,0 +1,195 @@
# Daily Meal API
API Laravel de Daily Meal.
## Docker en production
Le fichier source du dépôt est `docker-compose.prod.yml`. Sur le serveur, il est
déployé sous le nom `docker-compose.yml`. Les commandes de ce guide sont donc
exécutées depuis le dossier de l'application sans option `-f`.
### Configuration
Le Compose charge la configuration applicative depuis le `.env` de production.
Les variables Laravel ne sont pas répétées dans le fichier Compose afin de
conserver une seule source de vérité.
Exemple des variables propres à l'infrastructure :
```dotenv
IMAGE_TAG=2026-08-13.1
APP_ENV=production
APP_DEBUG=false
LOG_CHANNEL=stderr
LOG_LEVEL=warning
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=bowli
DB_USERNAME=bowli
DB_PASSWORD=change-me
REDIS_HOST=redis
QUEUE_CONNECTION=redis
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://meilisearch:7700
MEILISEARCH_KEY=change-me
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=change-me
AWS_SECRET_ACCESS_KEY=change-me
AWS_DEFAULT_REGION=eu-west-3
```
`IMAGE_TAG` est obligatoire. Utiliser un tag de release immuable permet de savoir
exactement quelle version est déployée et évite l'utilisation accidentelle de
`latest`.
### Volumes et réseau externes
PostgreSQL, Redis, Meilisearch et les sauvegardes locales utilisent des volumes
externes. Ils restent présents quand le projet Compose est recréé.
À effectuer une seule fois sur un nouveau serveur :
```bash
docker volume create bemeal_pgsql_data
docker volume create bemeal_redis_data
docker volume create bemeal_meilisearch_data
docker volume create bemeal_database_backups
docker network create proxy
```
Ces commandes sont idempotentes. Le réseau `proxy` peut déjà avoir été créé par
le reverse proxy. Ne jamais supprimer `bemeal_pgsql_data` sans avoir validé une
sauvegarde.
### Premier démarrage
```bash
docker compose config -q
docker compose pull app db-backup postgres redis meilisearch
docker compose up -d
docker compose ps
```
L'image `db-backup` contient `pg_dump`, `pg_restore`, AWS CLI, le cron et les
scripts de `docker/database-backup`. Elle est construite et publiée dans le
registre par la CI. Le serveur la télécharge avec `docker compose pull` et n'a
donc pas besoin de posséder le code source ni de construire une image localement.
### Mettre à jour l'application
Après avoir changé `IMAGE_TAG` dans `.env` :
```bash
docker compose config -q
docker compose pull app db-backup
docker compose up -d --remove-orphans
docker compose ps
```
Si les migrations ne sont pas déjà gérées par le pipeline ou l'image :
```bash
docker compose exec app php artisan migrate --force
```
## Sauvegardes PostgreSQL vers S3
Le service `db-backup` lance une sauvegarde au démarrage, puis chaque jour à
03:00. Chaque archive est compressée, contrôlée avec `pg_restore`, accompagnée
d'un checksum SHA-256 puis envoyée vers S3 avec le chiffrement AES-256. Les
copies locales sont conservées 7 jours par défaut.
```dotenv
BACKUP_CRON="0 3 * * *"
BACKUP_RUN_ON_START=true
BACKUP_LOCAL_RETENTION_DAYS=7
BACKUP_S3_BUCKET=bowli-backup
BACKUP_S3_PREFIX=database-backups/postgres
BACKUP_S3_ENDPOINT=
TZ=Europe/Paris
```
Ces valeurs sont les valeurs par défaut et peuvent être omises du `.env`. Le
bucket `bowli-backup` doit rester entièrement privé. `BACKUP_S3_ENDPOINT` reste
vide avec AWS S3.
### Autorisations IAM minimales
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListDatabaseBackups",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::bowli-backup",
"Condition": {
"StringLike": {
"s3:prefix": [
"database-backups/postgres",
"database-backups/postgres/*"
]
}
}
},
{
"Sid": "ManageDatabaseBackupObjects",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject"],
"Resource": "arn:aws:s3:::bowli-backup/database-backups/postgres/*"
}
]
}
```
Le conteneur ne supprime pas les sauvegardes distantes. Configurer une règle de
cycle de vie S3 sur ce préfixe, par exemple après 30 ou 90 jours.
### Sauvegarde manuelle et contrôle
```bash
docker compose exec db-backup /usr/local/bin/backup-database manual
docker compose ps db-backup
docker compose logs --tail=100 db-backup
```
## Restaurer une sauvegarde
Pour restaurer la sauvegarde S3 la plus récente :
```bash
./docker/database-backup/restore-production latest-s3
```
Autres sources acceptées :
```bash
# Dernière sauvegarde du volume local
./docker/database-backup/restore-production latest-local
# Sauvegarde S3 précise
./docker/database-backup/restore-production \
s3://bowli-backup/database-backups/postgres/20260813T030000Z-bowli-scheduled.dump
```
La restauration demande une confirmation explicite. Elle arrête l'application
et les workers, crée une sauvegarde de sécurité, vérifie l'archive, restaure dans
une base temporaire puis échange les bases. L'ancienne base est conservée sous
un nom daté pour permettre un rollback.
Les services redémarrent uniquement si toute l'opération réussit. En cas
d'échec, ils restent arrêtés afin d'éviter d'écrire dans une base potentiellement
incomplète.
Pour utiliser exceptionnellement un autre nom de fichier Compose :
```bash
COMPOSE_FILE=docker-compose.prod.yml \
./docker/database-backup/restore-production latest-s3
```
@@ -0,0 +1,79 @@
<?php
namespace App\Actions;
use App\Enums\NutritionPlanSource;
use App\Enums\WeeklyReviewStatus;
use App\Models\NutritionPlan;
use App\Models\WeeklyReview;
use Illuminate\Support\Facades\DB;
class AcceptWeeklyReviewAdjustment
{
public function execute(WeeklyReview $weeklyReview): NutritionPlan
{
return DB::transaction(function () use ($weeklyReview): NutritionPlan {
$lockedReview = WeeklyReview::query()->lockForUpdate()->findOrFail($weeklyReview->getKey());
abort_if($lockedReview->accepted_at !== null, 409, 'WEEKLY_ADJUSTMENT_ALREADY_ACCEPTED');
abort_unless(
$lockedReview->status === WeeklyReviewStatus::ADJUSTMENT_RECOMMENDED
&& $lockedReview->recommended_calorie_adjustment !== 0,
409,
'WEEKLY_ADJUSTMENT_UNAVAILABLE',
);
$user = $lockedReview->user()->lockForUpdate()->firstOrFail();
$currentPlan = $user->nutritionPlans()->where('is_active', true)->latest('effective_from')->firstOrFail();
abort_unless(
$lockedReview->nutrition_plan_id === $currentPlan->getKey(),
409,
'WEEKLY_ADJUSTMENT_STALE',
);
$now = now();
$targetCalories = max(1200, $currentPlan->calories + $lockedReview->recommended_calorie_adjustment);
$proteins = (int) round($currentPlan->proteins);
$fats = (int) round($currentPlan->fats);
$carbs = max(0, (int) round(($targetCalories - ($proteins * 4) - ($fats * 9)) / 4));
$calories = ($proteins * 4) + ($carbs * 4) + ($fats * 9);
abort_if(
$calories === $currentPlan->calories,
409,
'WEEKLY_ADJUSTMENT_UNAVAILABLE',
);
$currentPlan->update([
'is_active' => false,
'effective_until' => $now,
]);
$nextPlan = $user->nutritionPlans()->create([
'source' => NutritionPlanSource::WEEKLY_ADJUSTMENT,
'formula_version' => $currentPlan->formula_version,
'calories' => $calories,
'proteins' => $proteins,
'carbs' => $carbs,
'fats' => $fats,
'resting_metabolism' => $currentPlan->resting_metabolism,
'maintenance_calories' => $currentPlan->maintenance_calories,
'calorie_adjustment_percent' => ($calories - $currentPlan->maintenance_calories) / max(1, $currentPlan->maintenance_calories),
'calculation_details' => [
...$currentPlan->calculation_details,
'weeklyReviewId' => $lockedReview->getKey(),
'weeklyAdjustmentCalories' => $lockedReview->recommended_calorie_adjustment,
],
'effective_from' => $now,
]);
$lockedReview->update([
'accepted_at' => $now,
'accepted_nutrition_plan_id' => $nextPlan->getKey(),
]);
return $nextPlan;
}, attempts: 3);
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace App\Actions;
use App\Enums\SocialProvider;
use App\Models\SocialAccount;
use App\Models\User;
use App\Services\AppleOAuthTokenClient;
use App\Services\SocialIdentityResolver;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\User as SocialiteUser;
class AuthenticateSocialUser
{
public function __construct(
private SocialIdentityResolver $identityResolver,
private AppleOAuthTokenClient $appleOAuthTokenClient,
) {}
/**
* @param array<string, mixed> $data
*/
public function execute(array $data): User
{
$provider = SocialProvider::from((string) $data['provider']);
$identity = $this->identityResolver->resolve(
$provider,
(string) $data['idToken'],
isset($data['nonce']) ? (string) $data['nonce'] : null,
);
$this->ensureUsableIdentity($identity);
$email = strtolower(trim((string) $identity->getEmail()));
$providerRefreshToken = $provider === SocialProvider::Apple
? $this->appleOAuthTokenClient->exchangeAuthorizationCode(
(string) ($data['authorizationCode'] ?? ''),
(string) $identity->getId(),
)
: null;
try {
return Cache::lock('social-auth:user:'.hash('sha256', $email), 10)
->block(5, fn (): User => DB::transaction(function () use ($data, $email, $provider, $providerRefreshToken, $identity): User {
$providerUserId = (string) $identity->getId();
$socialAccount = SocialAccount::query()
->where('provider', $provider)
->where('provider_user_id', $providerUserId)
->lockForUpdate()
->first();
if ($socialAccount) {
$user = $socialAccount->user;
abort_if(! $user, 409, 'SOCIAL_ACCOUNT_CONFLICT');
if ($providerRefreshToken !== null) {
$socialAccount->update([
'provider_refresh_token' => $providerRefreshToken,
]);
}
return $this->prepareUserForLogin($user, $data);
}
$user = User::query()
->where('email', $email)
->lockForUpdate()
->first();
if (! $user) {
abort_if(($data['termsAccepted'] ?? false) !== true, 422, 'TERMS_ACCEPTANCE_REQUIRED');
$user = User::create([
'email' => $email,
'email_verified_at' => now(),
'locale' => $data['locale'] ?? 'en',
'name' => $this->uniqueUsername($this->preferredName($provider, $identity, $data), $email),
'password' => null,
'terms_accepted_at' => now(),
]);
} else {
abort_if($user->isSuspended(), 403, 'ACCOUNT_SUSPENDED');
if (! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
}
}
$user->socialAccounts()->create([
'provider' => $provider,
'provider_refresh_token' => $providerRefreshToken,
'provider_user_id' => $providerUserId,
]);
return $this->prepareUserForLogin($user, $data);
}, 3));
} catch (LockTimeoutException) {
abort(409, 'SOCIAL_AUTH_IN_PROGRESS');
}
}
private function ensureUsableIdentity(SocialiteUser $identity): void
{
$providerUserId = $identity->getId();
$email = $identity->getEmail();
$rawIdentity = $identity->getRaw();
$emailVerified = filter_var(
$rawIdentity['email_verified'] ?? $rawIdentity['verified_email'] ?? false,
FILTER_VALIDATE_BOOL,
);
abort_if(! is_string($providerUserId) || trim($providerUserId) === '', 401, 'SOCIAL_TOKEN_INVALID');
abort_if(! is_string($email) || filter_var($email, FILTER_VALIDATE_EMAIL) === false, 401, 'SOCIAL_TOKEN_INVALID');
abort_if($emailVerified !== true, 401, 'SOCIAL_EMAIL_NOT_VERIFIED');
}
/**
* @param array<string, mixed> $data
*/
private function prepareUserForLogin(User $user, array $data): User
{
abort_if($user->isSuspended(), 403, 'ACCOUNT_SUSPENDED');
if (isset($data['locale']) && $user->locale !== $data['locale']) {
$user->forceFill(['locale' => $data['locale']])->save();
}
return $user->fresh();
}
/**
* @param array<string, mixed> $data
*/
private function preferredName(
SocialProvider $provider,
SocialiteUser $identity,
array $data,
): ?string {
if ($provider === SocialProvider::Apple && isset($data['name']) && is_string($data['name'])) {
return $data['name'];
}
return $identity->getName();
}
private function uniqueUsername(?string $preferredName, string $email): string
{
$source = filled($preferredName) ? $preferredName : Str::before($email, '@');
$base = Str::of((string) $source)
->ascii()
->lower()
->replaceMatches('/[^a-z0-9_.-]+/', '.')
->trim('.-_')
->limit(24, '')
->toString();
if (mb_strlen($base) < 3) {
$base = 'user';
}
if (! User::withTrashed()->where('name', $base)->exists()) {
return $base;
}
do {
$candidate = $base.'-'.Str::lower(Str::random(6));
} while (User::withTrashed()->where('name', $candidate)->exists());
return $candidate;
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Actions;
use App\Models\User;
use App\Models\UserBlock;
use Illuminate\Support\Facades\DB;
class BlockUser
{
public function execute(User $blocker, User $blocked): UserBlock
{
return DB::transaction(function () use ($blocker, $blocked): UserBlock {
$userBlock = UserBlock::query()->firstOrCreate([
'blocker_id' => $blocker->getKey(),
'blocked_id' => $blocked->getKey(),
]);
$blocker->following()->detach($blocked->getKey());
$blocker->followers()->detach($blocked->getKey());
return $userBlock;
});
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Actions;
use App\Enums\NutritionPlanSource;
use App\Models\User;
class CompleteNutritionOnboarding
{
public function __construct(private RecalculateNutritionPlan $recalculateNutritionPlan) {}
/**
* @param array<string, mixed> $data
*/
public function execute(User $user, array $data): User
{
if ($user->hasCompletedNutritionOnboarding()) {
return $user->loadMissing(['activeNutritionPlan', 'latestWeightEntry']);
}
return $this->recalculateNutritionPlan->execute(
user: $user,
data: $data,
source: NutritionPlanSource::ONBOARDING,
completeOnboarding: true,
);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Actions;
use App\Enums\NutritionPlanSource;
use App\Enums\PacePreference;
use App\Enums\PhysicalActivityLevel;
use App\Enums\UserSex;
use App\Enums\WeightGoal;
use App\Models\User;
use App\Services\NutritionPlanCalculator;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
class RecalculateNutritionPlan
{
public function __construct(private NutritionPlanCalculator $calculator) {}
/**
* @param array<string, mixed> $data
*/
public function execute(
User $user,
array $data,
NutritionPlanSource $source,
bool $completeOnboarding = false,
): User {
return DB::transaction(function () use ($user, $data, $source, $completeOnboarding): User {
$lockedUser = User::query()->lockForUpdate()->findOrFail($user->getKey());
$now = now();
if ($completeOnboarding && $lockedUser->hasCompletedNutritionOnboarding()) {
return $lockedUser->load(['activeNutritionPlan', 'latestWeightEntry']);
}
$lockedUser->update([
'height' => $data['heightCm'],
'target_weight' => $data['targetWeightKg'] ?? null,
'date_of_birth' => $data['dateOfBirth'],
'sex' => $data['sex'],
'physical_activity_level' => $data['physicalActivityLevel'],
'weight_goal' => $data['weightGoal'],
'pace_preference' => $data['pacePreference'],
...($completeOnboarding ? [
'onboarding_completed_at' => $now,
'nutrition_estimate_accepted_at' => $now,
] : []),
]);
$weightKg = (float) $data['weightKg'];
$latestWeight = $lockedUser->weightEntries()->latest('measured_at')->first();
if ($latestWeight === null || abs($latestWeight->weight_kg - $weightKg) >= 0.01) {
$lockedUser->weightEntries()->create([
'weight_kg' => $weightKg,
'measured_at' => $now,
]);
}
$lockedUser->nutritionPlans()
->where('is_active', true)
->update([
'is_active' => false,
'effective_until' => $now,
]);
$calculation = $this->calculator->calculate(
weightKg: $weightKg,
heightCm: (int) $data['heightCm'],
dateOfBirth: CarbonImmutable::parse($data['dateOfBirth']),
sex: UserSex::from($data['sex']),
activityLevel: PhysicalActivityLevel::from($data['physicalActivityLevel']),
weightGoal: WeightGoal::from($data['weightGoal']),
pacePreference: PacePreference::from($data['pacePreference']),
);
$lockedUser->nutritionPlans()->create([
...$calculation,
'source' => $source,
'is_active' => true,
'effective_from' => $now,
]);
return $lockedUser->fresh()->load(['activeNutritionPlan', 'latestWeightEntry']);
}, attempts: 3);
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Actions;
use App\Enums\IdentityVerificationStatus;
use App\Models\IdentityVerificationRequest;
use App\Models\User;
use App\Notifications\CertificationReviewedNotification;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class ReviewIdentityVerification
{
public function execute(
IdentityVerificationRequest $verificationRequest,
IdentityVerificationStatus $status,
User $reviewer,
?string $rejectionReason = null,
): IdentityVerificationRequest {
abort_unless($reviewer->isAdmin(), 403);
abort_unless(
in_array($status, [IdentityVerificationStatus::APPROVED, IdentityVerificationStatus::REJECTED], true),
422,
);
abort_if(
$status === IdentityVerificationStatus::REJECTED && blank($rejectionReason),
422,
'CERTIFICATION_REJECTION_REASON_REQUIRED',
);
$reviewedRequest = DB::transaction(function () use (
$rejectionReason,
$reviewer,
$status,
$verificationRequest,
): IdentityVerificationRequest {
$lockedRequest = IdentityVerificationRequest::query()
->whereKey($verificationRequest->getKey())
->lockForUpdate()
->firstOrFail();
abort_unless($lockedRequest->status === IdentityVerificationStatus::PENDING, 409);
$lockedRequest->update([
'status' => $status,
'reviewed_at' => now(),
'reviewed_by' => $reviewer->getKey(),
'rejection_reason' => $status === IdentityVerificationStatus::REJECTED
? trim((string) $rejectionReason)
: null,
]);
if ($status === IdentityVerificationStatus::APPROVED) {
$lockedRequest->user()->update([
'account_verified_at' => now(),
]);
}
return $lockedRequest->fresh(['user', 'reviewer']);
});
$this->deleteReviewedDocument($reviewedRequest);
$reviewedRequest->user->notify(
(new CertificationReviewedNotification($reviewedRequest))
->locale($reviewedRequest->user->preferredLocale()),
);
return $reviewedRequest->fresh(['user', 'reviewer']);
}
private function deleteReviewedDocument(IdentityVerificationRequest $verificationRequest): void
{
if (! $verificationRequest->hasDocument()) {
return;
}
try {
Storage::disk($verificationRequest->document_disk)
->delete($verificationRequest->document_path);
$verificationRequest->update([
'document_path' => null,
'document_deleted_at' => now(),
]);
} catch (\Throwable $exception) {
report($exception);
}
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Actions;
use App\Enums\SocialProvider;
use App\Models\User;
use App\Services\AppleOAuthTokenClient;
use Illuminate\Contracts\Encryption\DecryptException;
class RevokeSocialAuthorizations
{
public function __construct(private AppleOAuthTokenClient $appleOAuthTokenClient) {}
public function execute(User $user): void
{
$appleAccount = $user->socialAccounts()
->where('provider', SocialProvider::Apple)
->first();
if (! $appleAccount) {
return;
}
try {
$refreshToken = $appleAccount->provider_refresh_token;
} catch (DecryptException) {
abort(503, 'SOCIAL_REVOCATION_FAILED');
}
if (! is_string($refreshToken) || $refreshToken === '') {
abort(409, 'SOCIAL_REVOCATION_TOKEN_MISSING');
}
$this->appleOAuthTokenClient->revoke($refreshToken);
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Actions;
use App\Enums\IdentityVerificationStatus;
use App\Models\IdentityVerificationRequest;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Throwable;
class SubmitIdentityVerification
{
public function execute(User $user, UploadedFile $document): IdentityVerificationRequest
{
return Cache::lock('identity-verification-submit:'.$user->getKey(), 30)
->block(5, fn (): IdentityVerificationRequest => $this->store($user, $document));
}
private function store(User $user, UploadedFile $document): IdentityVerificationRequest
{
$existingRequest = $user->identityVerificationRequest()->first();
if ($existingRequest?->status === IdentityVerificationStatus::PENDING) {
throw new ConflictHttpException('CERTIFICATION_ALREADY_PENDING');
}
$disk = (string) config('billing.identity_verification.disk', 'identity_documents');
$path = null;
try {
$path = Storage::disk($disk)->putFileAs(
'submissions',
$document,
Str::ulid().'.'.$this->extensionFor($document),
['visibility' => 'private'],
);
if (! is_string($path) || $path === '') {
throw new \RuntimeException('The identity document could not be stored.');
}
$verificationRequest = IdentityVerificationRequest::query()->updateOrCreate(
['user_id' => $user->getKey()],
[
'status' => IdentityVerificationStatus::PENDING,
'document_disk' => $disk,
'document_path' => $path,
'document_mime_type' => $document->getMimeType(),
'document_size' => $document->getSize(),
'submitted_at' => now(),
'reviewed_at' => null,
'reviewed_by' => null,
'rejection_reason' => null,
'document_deleted_at' => null,
],
);
} catch (Throwable $exception) {
if ($path) {
Storage::disk($disk)->delete($path);
}
throw $exception;
}
if ($existingRequest?->hasDocument() && $existingRequest->document_path !== $path) {
Storage::disk($existingRequest->document_disk)->delete($existingRequest->document_path);
}
return $verificationRequest;
}
private function extensionFor(UploadedFile $document): string
{
return match ($document->getMimeType()) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'application/pdf' => 'pdf',
default => throw new \InvalidArgumentException('Unsupported identity document type.'),
};
}
}
-1
View File
@@ -110,7 +110,6 @@ class ExpoPushChannel
'tokens_count' => $tokens->count(),
]);
return;
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Console\Commands;
use App\Enums\UserRole;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use function Laravel\Prompts\error;
use function Laravel\Prompts\intro;
use function Laravel\Prompts\outro;
use function Laravel\Prompts\password;
use function Laravel\Prompts\select;
use function Laravel\Prompts\text;
class CreateUser extends Command
{
protected $signature = 'user:create
{--name= : The unique user name}
{--email= : The unique email address}
{--role= : The user role: user, admin, or moderator}
{--locale=fr : The user locale}
{--verified : Mark the email address as verified}';
protected $description = 'Create an application user.';
public function handle(): int
{
intro('Create a Bowli user');
$name = trim((string) ($this->option('name') ?: text(
label: 'User name',
placeholder: 'e.g. jane.doe',
required: true,
hint: '3 to 32 characters: letters, numbers, dots, hyphens, or underscores.',
)));
$email = strtolower(trim((string) ($this->option('email') ?: text(
label: 'Email address',
placeholder: 'e.g. jane@example.com',
required: true,
))));
$role = (string) ($this->option('role') ?: select(
label: 'Role',
options: collect(UserRole::cases())
->mapWithKeys(fn (UserRole $role): array => [$role->value => ucfirst($role->value)])
->all(),
default: UserRole::USER->value,
hint: 'Choose the permissions granted to this user.',
));
$locale = strtolower(trim((string) $this->option('locale')));
$attributesValidator = Validator::make([
'name' => $name,
'email' => $email,
'role' => $role,
'locale' => $locale,
], [
'name' => ['required', 'string', 'min:3', 'max:32', 'regex:/^[a-zA-Z0-9_.-]+$/', 'unique:users,name'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'role' => ['required', Rule::enum(UserRole::class)],
'locale' => ['required', Rule::in(config('app.supported_locales', ['fr', 'en']))],
]);
if ($attributesValidator->fails()) {
foreach ($attributesValidator->errors()->all() as $error) {
error($error);
}
return self::FAILURE;
}
$password = password(
label: 'Password',
required: true,
hint: 'At least 8 characters with uppercase, lowercase, number, and symbol.',
);
$passwordConfirmation = password(
label: 'Confirm password',
required: true,
);
$passwordValidator = Validator::make([
'password' => $password,
'password_confirmation' => $passwordConfirmation,
], [
'password' => [
'required',
'confirmed',
Password::min(8)
->mixedCase()
->letters()
->numbers()
->symbols(),
],
]);
if ($passwordValidator->fails()) {
foreach ($passwordValidator->errors()->all() as $error) {
error($error);
}
return self::FAILURE;
}
$user = User::query()->create([
'name' => $name,
'email' => $email,
'email_verified_at' => $this->option('verified') ? now() : null,
'locale' => $locale,
'password' => Hash::make($password),
'role' => UserRole::from($role),
]);
outro("User {$user->email} created with ID {$user->getKey()} and role {$user->role->value}.");
return self::SUCCESS;
}
}
@@ -55,8 +55,9 @@ class SendEngagementReminderNotifications extends Command
private function eligibleUsersQuery(): Builder
{
return User::query()
->select(['id', 'locale', 'suspended_at'])
->select(['id', 'locale', 'suspended_at', 'engagement_reminders_enabled'])
->whereNull('suspended_at')
->where('engagement_reminders_enabled', true)
->whereHas('deviceTokens')
->whereDoesntHave('mealPosts', fn (Builder $query): Builder => $query
->where('created_at', '>=', now()->subDay()));
-38
View File
@@ -1,38 +0,0 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum CreditLedgerEntryType: string implements HasColor, HasLabel
{
case FREE_GRANT = 'free_grant';
case PURCHASE = 'purchase';
case SUBSCRIPTION_RENEWAL = 'subscription_renewal';
case USAGE = 'usage';
case REFUND = 'refund';
case ADJUSTMENT = 'adjustment';
public function getLabel(): string
{
return match ($this) {
self::FREE_GRANT => __('enums.credit_ledger_entry_type.free_grant'),
self::PURCHASE => __('enums.credit_ledger_entry_type.purchase'),
self::SUBSCRIPTION_RENEWAL => __('enums.credit_ledger_entry_type.subscription_renewal'),
self::USAGE => __('enums.credit_ledger_entry_type.usage'),
self::REFUND => __('enums.credit_ledger_entry_type.refund'),
self::ADJUSTMENT => __('enums.credit_ledger_entry_type.adjustment'),
};
}
public function getColor(): string
{
return match ($this) {
self::FREE_GRANT, self::PURCHASE, self::SUBSCRIPTION_RENEWAL => 'success',
self::USAGE => 'warning',
self::REFUND => 'info',
self::ADJUSTMENT => 'gray',
};
}
}
-28
View File
@@ -1,28 +0,0 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum CreditProductType: string implements HasColor, HasLabel
{
case MONTHLY = 'monthly';
case ONE_TIME = 'one_time';
public function getLabel(): string
{
return match ($this) {
self::MONTHLY => __('enums.credit_product_type.monthly'),
self::ONE_TIME => __('enums.credit_product_type.one_time'),
};
}
public function getColor(): string
{
return match ($this) {
self::MONTHLY => 'info',
self::ONE_TIME => 'success',
};
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum FollowStatus: string
{
case Pending = 'pending';
case Accepted = 'accepted';
case Rejected = 'rejected';
case Blocked = 'blocked';
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum IdentityVerificationStatus: string implements HasColor, HasLabel
{
case PENDING = 'pending';
case APPROVED = 'approved';
case REJECTED = 'rejected';
public function getLabel(): string
{
return match ($this) {
self::PENDING => __('enums.identity_verification_status.pending'),
self::APPROVED => __('enums.identity_verification_status.approved'),
self::REJECTED => __('enums.identity_verification_status.rejected'),
};
}
public function getColor(): string
{
return match ($this) {
self::PENDING => 'warning',
self::APPROVED => 'success',
self::REJECTED => 'danger',
};
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum NutritionPlanSource: string
{
case ONBOARDING = 'onboarding';
case PROFILE_RECALCULATION = 'profile_recalculation';
case WEEKLY_ADJUSTMENT = 'weekly_adjustment';
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum SocialProvider: string
{
case Google = 'google';
case Apple = 'apple';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum WeeklyReviewStatus: string
{
case INSUFFICIENT_DATA = 'insufficient_data';
case ON_TRACK = 'on_track';
case ADJUSTMENT_RECOMMENDED = 'adjustment_recommended';
}
@@ -1,78 +0,0 @@
<?php
namespace App\Filament\Resources\CreditProducts;
use App\Filament\Resources\CreditProducts\Pages\CreateCreditProduct;
use App\Filament\Resources\CreditProducts\Pages\EditCreditProduct;
use App\Filament\Resources\CreditProducts\Pages\ListCreditProducts;
use App\Filament\Resources\CreditProducts\Schemas\CreditProductForm;
use App\Filament\Resources\CreditProducts\Tables\CreditProductsTable;
use App\Models\CreditProduct;
use App\Services\StripeCreditProductSyncer;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class CreditProductResource extends Resource
{
protected static ?string $model = CreditProduct::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCreditCard;
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationLabel(): string
{
return __('admin.credit_products.navigation.label');
}
public static function getModelLabel(): string
{
return __('admin.credit_products.navigation.singular');
}
public static function getPluralModelLabel(): string
{
return __('admin.credit_products.navigation.plural');
}
public static function form(Schema $schema): Schema
{
return CreditProductForm::configure($schema);
}
public static function table(Table $table): Table
{
return CreditProductsTable::configure($table);
}
public static function syncStripeAction(): Action
{
return Action::make('syncStripe')
->label(__('admin.credit_products.actions.sync_stripe'))
->icon(Heroicon::OutlinedArrowPath)
->visible(fn (CreditProduct $record): bool => blank($record->stripe_price_id))
->requiresConfirmation()
->action(function (CreditProduct $record): void {
app(StripeCreditProductSyncer::class)->sync($record);
Notification::make()
->title(__('admin.credit_products.actions.sync_stripe_success'))
->success()
->send();
});
}
public static function getPages(): array
{
return [
'index' => ListCreditProducts::route('/'),
'create' => CreateCreditProduct::route('/create'),
'edit' => EditCreditProduct::route('/{record}/edit'),
];
}
}
@@ -1,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(),
]),
]);
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Filament\Resources\IdentityVerificationRequests;
use App\Filament\Resources\IdentityVerificationRequests\Pages\ListIdentityVerificationRequests;
use App\Filament\Resources\IdentityVerificationRequests\Pages\ViewIdentityVerificationRequest;
use App\Filament\Resources\IdentityVerificationRequests\Schemas\IdentityVerificationRequestInfolist;
use App\Filament\Resources\IdentityVerificationRequests\Tables\IdentityVerificationRequestsTable;
use App\Models\IdentityVerificationRequest;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class IdentityVerificationRequestResource extends Resource
{
protected static ?string $model = IdentityVerificationRequest::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedIdentification;
protected static ?string $recordTitleAttribute = 'id';
public static function getNavigationLabel(): string
{
return __('admin.identity_verification_requests.navigation.label');
}
public static function getModelLabel(): string
{
return __('admin.identity_verification_requests.navigation.singular');
}
public static function getPluralModelLabel(): string
{
return __('admin.identity_verification_requests.navigation.plural');
}
public static function canCreate(): bool
{
return false;
}
public static function infolist(Schema $schema): Schema
{
return IdentityVerificationRequestInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return IdentityVerificationRequestsTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListIdentityVerificationRequests::route('/'),
'view' => ViewIdentityVerificationRequest::route('/{record}'),
];
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\IdentityVerificationRequests\Pages;
use App\Filament\Resources\IdentityVerificationRequests\IdentityVerificationRequestResource;
use Filament\Resources\Pages\ListRecords;
class ListIdentityVerificationRequests extends ListRecords
{
protected static string $resource = IdentityVerificationRequestResource::class;
protected function getHeaderActions(): array
{
return [];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\IdentityVerificationRequests\Pages;
use App\Filament\Resources\IdentityVerificationRequests\IdentityVerificationRequestResource;
use App\Filament\Resources\IdentityVerificationRequests\Tables\IdentityVerificationRequestsTable;
use Filament\Resources\Pages\ViewRecord;
class ViewIdentityVerificationRequest extends ViewRecord
{
protected static string $resource = IdentityVerificationRequestResource::class;
protected function getHeaderActions(): array
{
return [
IdentityVerificationRequestsTable::approveAction(),
IdentityVerificationRequestsTable::rejectAction(),
];
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Filament\Resources\IdentityVerificationRequests\Schemas;
use App\Models\IdentityVerificationRequest;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class IdentityVerificationRequestInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make(__('admin.identity_verification_requests.sections.request'))
->columns(3)
->schema([
TextEntry::make('id')
->label(__('admin.identity_verification_requests.fields.id'))
->copyable(),
TextEntry::make('status')
->label(__('admin.identity_verification_requests.fields.status'))
->badge(),
TextEntry::make('user.email')
->label(__('admin.identity_verification_requests.fields.user'))
->copyable(),
TextEntry::make('submitted_at')
->label(__('admin.identity_verification_requests.fields.submitted_at'))
->dateTime(),
TextEntry::make('reviewed_at')
->label(__('admin.identity_verification_requests.fields.reviewed_at'))
->dateTime()
->placeholder(__('admin.identity_verification_requests.placeholders.empty')),
TextEntry::make('reviewer.email')
->label(__('admin.identity_verification_requests.fields.reviewed_by'))
->placeholder(__('admin.identity_verification_requests.placeholders.empty')),
TextEntry::make('document')
->label(__('admin.identity_verification_requests.fields.document'))
->state(__('admin.identity_verification_requests.actions.open_document'))
->url(fn (IdentityVerificationRequest $record): ?string => $record->temporaryDocumentUrl())
->openUrlInNewTab()
->visible(fn (IdentityVerificationRequest $record): bool => $record->hasDocument()),
TextEntry::make('document_deleted_at')
->label(__('admin.identity_verification_requests.fields.document_deleted_at'))
->dateTime()
->placeholder(__('admin.identity_verification_requests.placeholders.empty')),
TextEntry::make('rejection_reason')
->label(__('admin.identity_verification_requests.fields.rejection_reason'))
->placeholder(__('admin.identity_verification_requests.placeholders.empty'))
->columnSpanFull(),
]),
]);
}
}
@@ -0,0 +1,98 @@
<?php
namespace App\Filament\Resources\IdentityVerificationRequests\Tables;
use App\Actions\ReviewIdentityVerification;
use App\Enums\IdentityVerificationStatus;
use App\Models\IdentityVerificationRequest;
use App\Models\User;
use Filament\Actions\Action;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Textarea;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class IdentityVerificationRequestsTable
{
public static function configure(Table $table): Table
{
return $table
->defaultSort('submitted_at', 'desc')
->columns([
TextColumn::make('submitted_at')
->label(__('admin.identity_verification_requests.fields.submitted_at'))
->dateTime()
->sortable(),
TextColumn::make('status')
->label(__('admin.identity_verification_requests.fields.status'))
->badge()
->sortable(),
TextColumn::make('user.email')
->label(__('admin.identity_verification_requests.fields.user'))
->searchable(),
TextColumn::make('reviewed_at')
->label(__('admin.identity_verification_requests.fields.reviewed_at'))
->dateTime()
->sortable()
->placeholder(__('admin.identity_verification_requests.placeholders.empty')),
])
->filters([
SelectFilter::make('status')
->label(__('admin.identity_verification_requests.fields.status'))
->options(IdentityVerificationStatus::class),
])
->recordActions([
ViewAction::make(),
self::approveAction(),
self::rejectAction(),
]);
}
public static function approveAction(): Action
{
return Action::make('approve')
->label(__('admin.identity_verification_requests.actions.approve'))
->color('success')
->requiresConfirmation()
->visible(fn (IdentityVerificationRequest $record): bool => $record->status === IdentityVerificationStatus::PENDING)
->action(function (IdentityVerificationRequest $record): void {
app(ReviewIdentityVerification::class)->execute(
$record,
IdentityVerificationStatus::APPROVED,
self::reviewer(),
);
});
}
public static function rejectAction(): Action
{
return Action::make('reject')
->label(__('admin.identity_verification_requests.actions.reject'))
->color('danger')
->schema([
Textarea::make('reason')
->label(__('admin.identity_verification_requests.fields.rejection_reason'))
->required()
->maxLength(2000),
])
->visible(fn (IdentityVerificationRequest $record): bool => $record->status === IdentityVerificationStatus::PENDING)
->action(function (array $data, IdentityVerificationRequest $record): void {
app(ReviewIdentityVerification::class)->execute(
$record,
IdentityVerificationStatus::REJECTED,
self::reviewer(),
$data['reason'],
);
});
}
private static function reviewer(): User
{
$reviewer = auth()->user();
abort_unless($reviewer instanceof User, 403);
return $reviewer;
}
}
@@ -2,12 +2,7 @@
namespace App\Filament\Resources\Users\Schemas;
use App\Enums\PacePreference;
use App\Enums\PhysicalActivityLevel;
use App\Enums\UserRole;
use App\Enums\UserSex;
use App\Enums\WeightGoal;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Select;
@@ -63,86 +58,13 @@ class UserForm
->autocomplete('new-password'),
DateTimePicker::make('email_verified_at')
->label(__('admin.users.fields.email_verified_at')),
DateTimePicker::make('account_verified_at')
->label(__('admin.users.fields.account_verified_at')),
]),
Section::make(__('admin.users.sections.profile'))
->columns(2)
->schema([
Textarea::make('bio')
->label(__('admin.users.fields.bio'))
->rows(4)
->columnSpanFull(),
TextInput::make('height')
->label(__('admin.users.fields.height'))
->integer()
->minValue(0)
->maxValue(300)
->suffix('cm'),
DatePicker::make('date_of_birth')
->label(__('admin.users.fields.date_of_birth'))
->minDate('1900-01-01')
->maxDate(now()->subDay()),
Select::make('sex')
->label(__('admin.users.fields.sex'))
->options(UserSex::class)
->default(UserSex::UNKNOWN->value)
->required(),
]),
Section::make(__('admin.users.sections.nutrition_goals'))
->columns(4)
->schema([
TextInput::make('daily_calorie_goal')
->label(__('admin.users.fields.daily_calorie_goal'))
->integer()
->minValue(0)
->maxValue(100000)
->suffix('kcal')
->default(2000)
->required(),
TextInput::make('daily_protein_goal')
->label(__('admin.users.fields.daily_protein_goal'))
->numeric()
->minValue(0)
->maxValue(10000)
->suffix('g')
->default(120)
->required(),
TextInput::make('daily_carbs_goal')
->label(__('admin.users.fields.daily_carbs_goal'))
->numeric()
->minValue(0)
->maxValue(10000)
->suffix('g')
->default(250)
->required(),
TextInput::make('daily_fats_goal')
->label(__('admin.users.fields.daily_fats_goal'))
->numeric()
->minValue(0)
->maxValue(10000)
->suffix('g')
->default(70)
->required(),
]),
Section::make(__('admin.users.sections.activity'))
->columns(3)
->schema([
Select::make('physical_activity_level')
->label(__('admin.users.fields.physical_activity_level'))
->options(PhysicalActivityLevel::class)
->default(PhysicalActivityLevel::SEDENTARY->value)
->required(),
Select::make('weight_goal')
->label(__('admin.users.fields.weight_goal'))
->options(WeightGoal::class)
->default(WeightGoal::LOSE_WEIGHT->value)
->required(),
Select::make('pace_preference')
->label(__('admin.users.fields.pace_preference'))
->options(PacePreference::class)
->default(PacePreference::SLOW->value)
->required(),
]),
]);
}
@@ -35,6 +35,14 @@ class UserInfolist
->label(__('admin.users.fields.account_verified_at'))
->dateTime()
->placeholder(__('admin.users.placeholders.not_verified')),
TextEntry::make('certification_purchased_at')
->label(__('admin.users.fields.certification_purchased_at'))
->dateTime()
->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('subscription_expires_at')
->label(__('admin.users.fields.subscription_expires_at'))
->dateTime()
->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('suspended_at')
->label(__('admin.users.fields.suspended_at'))
->dateTime()
@@ -59,6 +67,16 @@ class UserInfolist
->numeric(decimalPlaces: 0)
->suffix(' cm')
->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('latestWeightEntry.weight_kg')
->label(__('admin.users.fields.current_weight'))
->numeric(maxDecimalPlaces: 2)
->suffix(' kg')
->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('target_weight')
->label(__('admin.users.fields.target_weight'))
->numeric(maxDecimalPlaces: 2)
->suffix(' kg')
->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('date_of_birth')
->label(__('admin.users.fields.date_of_birth'))
->date()
@@ -67,25 +85,29 @@ class UserInfolist
->label(__('admin.users.fields.sex'))
->badge(),
]),
Section::make(__('admin.users.sections.nutrition_goals'))
Section::make(__('admin.users.sections.nutrition_plan'))
->columns(4)
->schema([
TextEntry::make('daily_calorie_goal')
->label(__('admin.users.fields.daily_calorie_goal'))
TextEntry::make('activeNutritionPlan.calories')
->label(__('admin.users.fields.calorie_goal'))
->numeric(decimalPlaces: 0)
->suffix(' kcal'),
TextEntry::make('daily_protein_goal')
->label(__('admin.users.fields.daily_protein_goal'))
->suffix(' kcal')
->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('activeNutritionPlan.proteins')
->label(__('admin.users.fields.protein_goal'))
->numeric(maxDecimalPlaces: 2)
->suffix(' g'),
TextEntry::make('daily_carbs_goal')
->label(__('admin.users.fields.daily_carbs_goal'))
->suffix(' g')
->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('activeNutritionPlan.carbs')
->label(__('admin.users.fields.carbs_goal'))
->numeric(maxDecimalPlaces: 2)
->suffix(' g'),
TextEntry::make('daily_fats_goal')
->label(__('admin.users.fields.daily_fats_goal'))
->suffix(' g')
->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('activeNutritionPlan.fats')
->label(__('admin.users.fields.fats_goal'))
->numeric(maxDecimalPlaces: 2)
->suffix(' g'),
->suffix(' g')
->placeholder(__('admin.users.placeholders.empty')),
]),
Section::make(__('admin.users.sections.activity'))
->columns(3)
@@ -98,7 +120,12 @@ class UserInfolist
->badge(),
TextEntry::make('pace_preference')
->label(__('admin.users.fields.pace_preference'))
->badge(),
->badge()
->placeholder(__('admin.users.placeholders.empty')),
TextEntry::make('onboarding_completed_at')
->label(__('admin.users.fields.onboarding_completed_at'))
->dateTime()
->placeholder(__('admin.users.placeholders.empty')),
]),
Section::make(__('admin.users.sections.metadata'))
->columns(2)
@@ -51,6 +51,12 @@ class UsersTable
->sortable()
->placeholder(__('admin.users.placeholders.not_verified'))
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('subscription_expires_at')
->label(__('admin.users.fields.subscription_expires_at'))
->dateTime()
->sortable()
->placeholder(__('admin.users.placeholders.empty'))
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('suspended_at')
->label(__('admin.users.fields.suspended_at'))
->dateTime()
@@ -89,29 +95,25 @@ class UsersTable
->badge()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('daily_calorie_goal')
->label(__('admin.users.fields.daily_calorie_goal'))
TextColumn::make('activeNutritionPlan.calories')
->label(__('admin.users.fields.calorie_goal'))
->numeric(decimalPlaces: 0)
->suffix(' kcal')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('daily_protein_goal')
->label(__('admin.users.fields.daily_protein_goal'))
TextColumn::make('activeNutritionPlan.proteins')
->label(__('admin.users.fields.protein_goal'))
->numeric(maxDecimalPlaces: 2)
->suffix(' g')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('daily_carbs_goal')
->label(__('admin.users.fields.daily_carbs_goal'))
TextColumn::make('activeNutritionPlan.carbs')
->label(__('admin.users.fields.carbs_goal'))
->numeric(maxDecimalPlaces: 2)
->suffix(' g')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('daily_fats_goal')
->label(__('admin.users.fields.daily_fats_goal'))
TextColumn::make('activeNutritionPlan.fats')
->label(__('admin.users.fields.fats_goal'))
->numeric(maxDecimalPlaces: 2)
->suffix(' g')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('bio')
->label(__('admin.users.fields.bio'))
+44 -49
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Actions\RevokeSocialAuthorizations;
use App\Http\Requests\EmailVerificationNotificationRequest;
use App\Http\Requests\ForgotPasswordRequest;
use App\Http\Requests\LoginRequest;
@@ -11,6 +12,7 @@ use App\Http\Requests\UpdatePasswordRequest;
use App\Http\Requests\UpdateUserRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Services\RevenueCatService;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Auth\Events\Verified;
use Illuminate\Http\JsonResponse;
@@ -42,7 +44,7 @@ class AuthController extends Controller
$this->startWebSession($request, $user);
return response()->json([
'message' => __('api.auth.registered_unverified'),
'code' => 'REGISTERED_UNVERIFIED',
'user' => new UserResource($user),
], 201);
}
@@ -72,7 +74,7 @@ class AuthController extends Controller
}
return response()->json([
'message' => __('api.auth.registered_unverified'),
'code' => 'REGISTERED_UNVERIFIED',
'token' => $this->createMobileToken($user, $request->validated('device_name')),
'user' => new UserResource($user),
], 201);
@@ -100,7 +102,7 @@ class AuthController extends Controller
if (! hash_equals($hash, sha1($user->getEmailForVerification()))) {
if ($request->expectsJson()) {
return response()->json([
'message' => __('api.auth.invalid_verification_link'),
'code' => 'INVALID_VERIFICATION_LINK',
], 403);
}
@@ -117,9 +119,9 @@ class AuthController extends Controller
if ($request->expectsJson()) {
return response()->json([
'message' => $alreadyVerified
? __('api.auth.already_verified')
: __('api.auth.verified'),
'code' => $alreadyVerified
? 'EMAIL_ALREADY_VERIFIED'
: 'EMAIL_VERIFIED',
'user' => new UserResource($user->fresh()),
]);
}
@@ -140,7 +142,7 @@ class AuthController extends Controller
}
return response()->json([
'message' => __('api.auth.verification_sent_if_unverified'),
'code' => 'VERIFICATION_EMAIL_SENT_IF_UNVERIFIED',
]);
}
@@ -151,12 +153,12 @@ class AuthController extends Controller
if ($status === Password::RESET_THROTTLED) {
return response()->json([
'message' => __($status),
'code' => 'PASSWORD_RESET_THROTTLED',
], 429);
}
return response()->json([
'message' => __('api.auth.password_reset_link_sent'),
'code' => 'PASSWORD_RESET_LINK_SENT',
]);
}
@@ -166,12 +168,12 @@ class AuthController extends Controller
if ($status !== Password::PASSWORD_RESET) {
return response()->json([
'message' => __($status),
'code' => 'PASSWORD_RESET_FAILED',
], 422);
}
return response()->json([
'message' => __('api.auth.password_reset'),
'code' => 'PASSWORD_RESET',
]);
}
@@ -227,7 +229,7 @@ class AuthController extends Controller
}
return response()->json([
'message' => __('api.auth.logout'),
'code' => 'LOGGED_OUT',
]);
}
@@ -236,7 +238,7 @@ class AuthController extends Controller
$user = auth()->user();
if (! $user) {
return response()->json(['message' => __('api.auth.unauthenticated')], 401);
return response()->json(['code' => 'UNAUTHENTICATED'], 401);
}
return new UserResource($user);
@@ -246,10 +248,9 @@ class AuthController extends Controller
{
$user = $request->user();
abort_if($user->isSuspended(), 403, __('api.auth.suspended'));
abort_if($user->isSuspended(), 403, 'ACCOUNT_SUSPENDED');
$data = $request->validated();
$nutritionGoals = $data['nutritionGoals'] ?? null;
if ($request->hasFile('avatar')) {
if ($user->avatar_url) {
@@ -262,13 +263,6 @@ class AuthController extends Controller
$userAttributes = $this->userAttributesFromRequestData($data);
if (is_array($nutritionGoals)) {
$userAttributes['daily_calorie_goal'] = $nutritionGoals['calories'];
$userAttributes['daily_protein_goal'] = $nutritionGoals['proteins'];
$userAttributes['daily_carbs_goal'] = $nutritionGoals['carbs'];
$userAttributes['daily_fats_goal'] = $nutritionGoals['fats'];
}
if (! empty($userAttributes)) {
$user->update($userAttributes);
}
@@ -280,7 +274,7 @@ class AuthController extends Controller
{
$user = $request->user();
abort_if($user->isSuspended(), 403, __('api.auth.suspended'));
abort_if($user->isSuspended(), 403, 'ACCOUNT_SUSPENDED');
$currentAccessToken = $user->currentAccessToken();
@@ -298,19 +292,31 @@ class AuthController extends Controller
}
return response()->json([
'message' => __('api.auth.password_updated'),
'code' => 'PASSWORD_UPDATED',
]);
}
public function destroy(Request $request): JsonResponse
{
public function destroy(
Request $request,
RevenueCatService $revenueCat,
RevokeSocialAuthorizations $revokeSocialAuthorizations,
): JsonResponse {
$user = $request->user();
$identityVerificationRequest = $user->identityVerificationRequest()->first();
$storagePaths = collect([$user->avatar_url])
->merge($user->mealPosts()->withTrashed()->pluck('image_url'))
->filter(fn (?string $path): bool => $this->isPublicStoragePath($path))
->values()
->all();
$revokeSocialAuthorizations->execute($user);
try {
$revenueCat->deleteCustomer($user);
} catch (Throwable $exception) {
report($exception);
}
DB::transaction(function () use ($user): void {
$user->tokens()->delete();
$user->notifications()->delete();
@@ -330,6 +336,11 @@ class AuthController extends Controller
Storage::disk()->delete($storagePaths);
}
if ($identityVerificationRequest?->hasDocument()) {
Storage::disk($identityVerificationRequest->document_disk)
->delete($identityVerificationRequest->document_path);
}
if ($request->hasSession()) {
Auth::guard('web')->logout();
$request->session()->invalidate();
@@ -337,7 +348,7 @@ class AuthController extends Controller
}
return response()->json([
'message' => __('api.auth.deleted'),
'code' => 'ACCOUNT_DELETED',
]);
}
@@ -354,7 +365,7 @@ class AuthController extends Controller
$userAttributes['email'] = strtolower($data['email']);
$userAttributes['password'] = Hash::make($data['password']);
$userAttributes['avatar_url'] = $avatarPath;
$userAttributes['terms_accepted_at'] = now();
$user = User::create($userAttributes);
try {
@@ -369,7 +380,7 @@ class AuthController extends Controller
}
return response()->json([
'message' => __('api.auth.verification_email_failed'),
'code' => 'VERIFICATION_EMAIL_FAILED',
], 503);
}
@@ -383,15 +394,15 @@ class AuthController extends Controller
{
$user = User::where('email', strtolower((string) $data['email']))->first();
if (! $user || ! Hash::check((string) $data['password'], $user->password)) {
if (! $user || ! is_string($user->password) || ! Hash::check((string) $data['password'], $user->password)) {
return response()->json([
'message' => __('api.auth.invalid_credentials'),
'code' => 'INVALID_CREDENTIALS',
], 401);
}
if ($user->isSuspended()) {
return response()->json([
'message' => __('api.auth.suspended'),
'code' => 'ACCOUNT_SUSPENDED',
], 403);
}
@@ -401,7 +412,7 @@ class AuthController extends Controller
if (! $user->hasVerifiedEmail()) {
return response()->json([
'message' => __('api.auth.email_not_verified'),
'code' => 'EMAIL_NOT_VERIFIED',
], 403);
}
@@ -467,25 +478,9 @@ class AuthController extends Controller
'name',
'avatar_url',
'locale',
'height',
'bio',
'sex',
'date_of_birth',
]);
$attributeMap = [
'physicalActivityLevel' => 'physical_activity_level',
'weightGoal' => 'weight_goal',
'pacePreference' => 'pace_preference',
'dateOfBirth' => 'date_of_birth',
];
foreach ($attributeMap as $requestKey => $attribute) {
if (array_key_exists($requestKey, $data)) {
$attributes[$attribute] = $data[$requestKey];
}
}
return $attributes;
}
}
@@ -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',
]);
}
+106
View File
@@ -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,
],
]);
}
+18 -7
View File
@@ -137,7 +137,7 @@ class MealPostController extends Controller
public function show(Request $request, MealPosts $mealPost): MealPostsResource
{
$this->abortIfUnavailable($mealPost);
$this->abortIfUnavailable($request, $mealPost);
return new MealPostsResource($this->loadMealPostForResponse($mealPost, $request));
}
@@ -173,7 +173,7 @@ class MealPostController extends Controller
public function like(Request $request, MealPosts $mealPost): MealPostsResource
{
$this->abortIfUnavailable($mealPost);
$this->abortIfUnavailable($request, $mealPost);
$mealPost->likedByUsers()->syncWithoutDetaching([$request->user()->getKey()]);
@@ -182,7 +182,7 @@ class MealPostController extends Controller
public function unlike(Request $request, MealPosts $mealPost): MealPostsResource
{
$this->abortIfUnavailable($mealPost);
$this->abortIfUnavailable($request, $mealPost);
$mealPost->likedByUsers()->detach($request->user()->getKey());
@@ -204,10 +204,16 @@ class MealPostController extends Controller
abort_unless($mealPost->user_id === $request->user()->getKey(), 404);
}
private function abortIfUnavailable(MealPosts $mealPost): void
private function abortIfUnavailable(Request $request, MealPosts $mealPost): void
{
$mealPost->loadMissing('user');
$owner = $mealPost->user;
abort_if(
$mealPost->isHidden() || $mealPost->user()->whereNotNull('suspended_at')->exists(),
$mealPost->isHidden()
|| $owner === null
|| $owner->isSuspended()
|| $request->user()?->hasBlockingRelationshipWith($owner),
404,
);
}
@@ -222,7 +228,12 @@ class MealPostController extends Controller
{
return MealPosts::query()
->whereNull('hidden_at')
->whereHas('user', fn (Builder $query): Builder => $query->whereNull('suspended_at'))
->whereHas(
'user',
fn (Builder $query): Builder => $query
->whereNull('suspended_at')
->withoutBlockingRelationshipWith($request->user())
)
->with('user:id,name,avatar_url,account_verified_at');
}
@@ -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,
));
}
}
+25 -9
View File
@@ -7,6 +7,7 @@ use App\Http\Resources\PostReviewsResource;
use App\Models\MealPosts;
use App\Models\PostReviews;
use App\Notifications\MealPostCommentedNotification;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -16,11 +17,15 @@ class PostReviewsController extends Controller
{
public function index(Request $request, MealPosts $mealPost): AnonymousResourceCollection
{
$this->abortIfUnavailable($mealPost);
$this->abortIfUnavailable($request, $mealPost);
$perPage = max(1, min($request->integer('per_page', 15), 100));
$reviews = $mealPost->reviews()
->whereHas(
'user',
fn (Builder $query): Builder => $query->withoutBlockingRelationshipWith($request->user())
)
->with('user:id,name,avatar_url')
->latest()
->paginate($perPage)
@@ -31,7 +36,7 @@ class PostReviewsController extends Controller
public function store(PostReviewsRequest $request, MealPosts $mealPost): JsonResponse
{
$this->abortIfUnavailable($mealPost);
$this->abortIfUnavailable($request, $mealPost);
$userId = $request->user()->getKey();
@@ -41,7 +46,7 @@ class PostReviewsController extends Controller
->where('user_id', $userId)
->exists(),
409,
__('api.post_reviews.already_exists')
'MEAL_REVIEW_ALREADY_EXISTS'
);
$postReview = PostReviews::create([
@@ -60,7 +65,7 @@ class PostReviewsController extends Controller
public function mine(Request $request, MealPosts $mealPost): JsonResponse|PostReviewsResource
{
$this->abortIfUnavailable($mealPost);
$this->abortIfUnavailable($request, $mealPost);
$postReview = $mealPost->reviews()
->where('user_id', $request->user()->getKey())
@@ -74,10 +79,15 @@ class PostReviewsController extends Controller
return new PostReviewsResource($postReview);
}
public function show(PostReviews $postReview): PostReviewsResource
public function show(Request $request, PostReviews $postReview): PostReviewsResource
{
$postReview->loadMissing('mealPost.user');
$this->abortIfUnavailable($postReview->mealPost);
$postReview->loadMissing('mealPost.user', 'user');
$this->abortIfUnavailable($request, $postReview->mealPost);
abort_if(
$postReview->user === null
|| $request->user()->hasBlockingRelationshipWith($postReview->user),
404,
);
return new PostReviewsResource($postReview->loadMissing(
'mealPost.ingredients',
@@ -110,10 +120,16 @@ class PostReviewsController extends Controller
abort_unless($postReview->user_id === $request->user()->getKey(), 404);
}
private function abortIfUnavailable(MealPosts $mealPost): void
private function abortIfUnavailable(Request $request, MealPosts $mealPost): void
{
$mealPost->loadMissing('user');
$owner = $mealPost->user;
abort_if(
$mealPost->isHidden() || $mealPost->user()->whereNotNull('suspended_at')->exists(),
$mealPost->isHidden()
|| $owner === null
|| $owner->isSuspended()
|| $request->user()?->hasBlockingRelationshipWith($owner),
404,
);
}
@@ -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),
],
]);
}
+3 -3
View File
@@ -26,7 +26,7 @@ class ReportController extends Controller
);
return (new ReportResource($report))
->additional(['message' => __('api.reports.created')])
->additional(['code' => 'REPORT_CREATED'])
->response()
->setStatusCode(201);
}
@@ -44,7 +44,7 @@ class ReportController extends Controller
);
return (new ReportResource($report))
->additional(['message' => __('api.reports.created')])
->additional(['code' => 'REPORT_CREATED'])
->response()
->setStatusCode(201);
}
@@ -62,7 +62,7 @@ class ReportController extends Controller
);
return (new ReportResource($report))
->additional(['message' => __('api.reports.created')])
->additional(['code' => 'REPORT_CREATED'])
->response()
->setStatusCode(201);
}
@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\UserResource;
use App\Models\RevenueCatWebhookEvent;
use App\Models\User;
use App\Notifications\PaymentFailedNotification;
use App\Services\RevenueCatService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
class RevenueCatController extends Controller
{
public function sync(Request $request, RevenueCatService $revenueCat): UserResource
{
return new UserResource($revenueCat->sync($request->user()));
}
public function webhook(Request $request, RevenueCatService $revenueCat): JsonResponse
{
$expectedAuthorization = trim((string) config('billing.revenuecat.webhook_authorization'));
$providedAuthorization = (string) $request->header('Authorization');
abort_if(
$expectedAuthorization === '' || ! hash_equals($expectedAuthorization, $providedAuthorization),
401,
);
$event = $request->input('event');
abort_unless(is_array($event), 422);
$eventId = $this->stringValue($event['id'] ?? null);
$eventType = $this->stringValue($event['type'] ?? null);
abort_unless($eventId && $eventType, 422);
$webhookEvent = RevenueCatWebhookEvent::query()->firstOrCreate([
'event_id' => $eventId,
], [
'type' => $eventType,
'app_user_id' => $this->stringValue($event['app_user_id'] ?? null),
'product_id' => $this->stringValue($event['product_id'] ?? null),
'entitlement_ids' => $this->stringList($event['entitlement_ids'] ?? []),
'environment' => $this->stringValue($event['environment'] ?? null),
'payload' => $event,
]);
if ($webhookEvent->processed_at !== null) {
return response()->json(['received' => true]);
}
$user = $this->userFromEvent($event);
if ($user) {
$revenueCat->sync($user);
if ($eventType === 'BILLING_ISSUE') {
$user->notify(
(new PaymentFailedNotification)->locale($user->preferredLocale()),
);
}
}
$webhookEvent->forceFill(['processed_at' => now()])->save();
return response()->json(['received' => true]);
}
/**
* @param array<string, mixed> $event
*/
private function userFromEvent(array $event): ?User
{
$identifiers = collect([
$event['app_user_id'] ?? null,
$event['original_app_user_id'] ?? null,
])
->merge(Arr::wrap($event['aliases'] ?? []))
->merge(Arr::wrap($event['transferred_to'] ?? []))
->filter(fn (mixed $identifier): bool => is_string($identifier) && $identifier !== '')
->unique()
->values();
return User::query()->whereIn('id', $identifiers)->first();
}
/**
* @return list<string>
*/
private function stringList(mixed $value): array
{
return collect(Arr::wrap($value))
->filter(fn (mixed $item): bool => is_string($item) && $item !== '')
->values()
->all();
}
private function stringValue(mixed $value): ?string
{
return is_string($value) && $value !== '' ? $value : null;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers;
use App\Enums\MealPostVisibility;
use App\Http\Requests\SearchRequest;
use App\Http\Resources\MealPostsResource;
use App\Http\Resources\SearchUserResource;
use App\Models\MealPosts;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class SearchController extends Controller
{
public function __invoke(SearchRequest $request): AnonymousResourceCollection
{
$query = (string) $request->validated('q');
$perPage = (int) ($request->validated('per_page') ?? 20);
if ($request->validated('type') === 'users') {
$users = User::query()
->whereKeyNot($request->user()->getKey())
->whereNull('suspended_at')
->withoutBlockingRelationshipWith($request->user())
->where(function (Builder $builder) use ($query): void {
$builder
->whereLike('name', "%{$query}%", caseSensitive: false)
->orWhereLike('bio', "%{$query}%", caseSensitive: false);
})
->orderByRaw('CASE WHEN LOWER(name) = LOWER(?) THEN 0 WHEN LOWER(name) LIKE LOWER(?) THEN 1 ELSE 2 END', [
$query,
"{$query}%",
])
->orderBy('name')
->paginate($perPage)
->withQueryString();
return SearchUserResource::collection($users);
}
$meals = MealPosts::query()
->where('visibility', MealPostVisibility::Public)
->whereNull('hidden_at')
->whereHas(
'user',
fn (Builder $builder): Builder => $builder
->whereNull('suspended_at')
->withoutBlockingRelationshipWith($request->user())
)
->where(function (Builder $builder) use ($query): void {
$builder
->whereLike('title', "%{$query}%", caseSensitive: false)
->orWhereLike('caption', "%{$query}%", caseSensitive: false)
->orWhereHas(
'ingredients',
fn (Builder $ingredientQuery): Builder => $ingredientQuery
->whereLike('ingredient', "%{$query}%", caseSensitive: false)
);
})
->with('user:id,name,avatar_url,account_verified_at')
->latest('eaten_at')
->paginate($perPage)
->withQueryString();
return MealPostsResource::collection($meals);
}
}
+6 -6
View File
@@ -51,13 +51,13 @@ class StravaController extends Controller
if (! is_array($state) || $state['user_id'] !== $request->user()->getKey()) {
return response()->json([
'message' => __('api.strava.state_expired'),
'code' => 'STRAVA_STATE_EXPIRED',
], 422);
}
if (isset($data['error'])) {
return response()->json([
'message' => __('api.strava.refused'),
'code' => 'STRAVA_AUTHORIZATION_REFUSED',
], 422);
}
@@ -115,14 +115,14 @@ class StravaController extends Controller
report($exception);
return response()->json([
'message' => __('api.strava.disconnect_failed'),
'code' => 'STRAVA_DISCONNECT_FAILED',
], 502);
}
} catch (ConnectionException $exception) {
report($exception);
return response()->json([
'message' => __('api.strava.disconnect_failed'),
'code' => 'STRAVA_DISCONNECT_FAILED',
], 502);
}
@@ -137,13 +137,13 @@ class StravaController extends Controller
if (! $connection) {
return response()->json([
'message' => __('api.strava.missing_connection'),
'code' => 'STRAVA_CONNECTION_MISSING',
], 422);
}
if (! $this->canReadActivities($connection->scopes ?? [])) {
return response()->json([
'message' => __('api.strava.missing_activity_scope'),
'code' => 'STRAVA_ACTIVITY_SCOPE_MISSING',
], 422);
}
@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers;
use App\Actions\BlockUser;
use App\Http\Resources\BlockedUserResource;
use App\Models\User;
use App\Models\UserBlock;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
class UserBlockController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$perPage = max(1, min($request->integer('per_page', 15), 100));
$blockedUsers = $request->user()
->blockedUsers()
->orderByPivotDesc('created_at')
->paginate($perPage)
->withQueryString();
return BlockedUserResource::collection($blockedUsers);
}
public function store(Request $request, User $user, BlockUser $blockUser): JsonResponse
{
abort_if($request->user()->is($user), 422, 'CANNOT_BLOCK_SELF');
$blockUser->execute($request->user(), $user);
return response()->json([
'code' => 'USER_BLOCKED',
]);
}
public function destroy(Request $request, User $user): Response
{
UserBlock::query()
->where('blocker_id', $request->user()->getKey())
->where('blocked_id', $user->getKey())
->delete();
return response()->noContent();
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers;
use App\Actions\AcceptWeeklyReviewAdjustment;
use App\Http\Resources\NutritionPlanResource;
use App\Http\Resources\WeeklyReviewResource;
use App\Models\WeeklyReview;
use App\Services\WeeklyReviewService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class WeeklyReviewController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
return WeeklyReviewResource::collection(
$request->user()->weeklyReviews()->latest('period_start')->limit(52)->get()
);
}
public function current(Request $request, WeeklyReviewService $weeklyReviewService): JsonResponse
{
return (new WeeklyReviewResource($weeklyReviewService->generate($request->user())))
->response()
->setStatusCode(200);
}
public function accept(
Request $request,
WeeklyReview $weeklyReview,
AcceptWeeklyReviewAdjustment $acceptWeeklyReviewAdjustment,
): NutritionPlanResource {
abort_unless($weeklyReview->user_id === $request->user()->getKey(), 404);
return new NutritionPlanResource($acceptWeeklyReviewAdjustment->execute($weeklyReview));
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ListWeightEntriesRequest;
use App\Http\Requests\StoreWeightEntryRequest;
use App\Http\Resources\WeightEntryResource;
use App\Models\WeightEntry;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
class WeightEntryController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(ListWeightEntriesRequest $request): AnonymousResourceCollection
{
$from = now()->subDays($request->integer('days', 365))->startOfDay();
$entries = $request->user()
->weightEntries()
->where('measured_at', '>=', $from)
->latest('measured_at')
->get();
return WeightEntryResource::collection($entries);
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreWeightEntryRequest $request): WeightEntryResource
{
$entry = $request->user()->weightEntries()->create([
'weight_kg' => $request->validated('weightKg'),
'measured_at' => CarbonImmutable::parse($request->validated('measuredAt') ?? now()),
]);
return new WeightEntryResource($entry);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Request $request, WeightEntry $weightEntry): Response
{
abort_unless($weightEntry->user_id === $request->user()->getKey(), 404);
$weightEntry->delete();
return response()->noContent();
}
}
@@ -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);
}];
}
}
+28
View File
@@ -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'],
];
}
}
+40 -1
View File
@@ -6,18 +6,24 @@ use App\Enums\DietType;
use App\Enums\IngredientUnit;
use App\Enums\MealPostType;
use App\Enums\MealPostVisibility;
use App\Http\Requests\Concerns\ModeratesUserContent;
use App\Models\MealPosts;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class MealPostsRequest extends FormRequest
{
use ModeratesUserContent;
public function rules(): array
{
$isCreating = $this->isMethod('post');
return [
'image' => [$isCreating ? 'required_without:image_url' : 'sometimes', 'nullable', 'image', 'max:4096'],
'image_url' => [$isCreating ? 'required_without:image' : 'sometimes', 'nullable', 'string', 'max:2048'],
'image_url' => [$isCreating ? 'required_without:image' : 'sometimes', 'nullable', 'url:http,https', 'max:2048'],
'caption' => ['nullable', 'string', 'max:1000'],
'calories' => ['nullable', 'integer', 'min:0'],
'proteins' => ['nullable', 'numeric', 'min:0'],
@@ -42,6 +48,39 @@ class MealPostsRequest extends FormRequest
return true;
}
public function after(): array
{
$mealPost = $this->route('mealPost');
$currentVisibility = $mealPost instanceof MealPosts
? $mealPost->visibility
: MealPostVisibility::Private;
$visibility = MealPostVisibility::tryFrom((string) $this->input('visibility')) ?? $currentVisibility;
if ($visibility !== MealPostVisibility::Public) {
return [];
}
$isBecomingPublic = $mealPost instanceof MealPosts
&& $mealPost->visibility !== MealPostVisibility::Public;
$ingredients = $this->has('ingredients')
? collect($this->input('ingredients', []))->pluck('ingredient')->all()
: ($isBecomingPublic ? $mealPost->ingredients()->pluck('ingredient')->all() : []);
$existingImageUrl = $isBecomingPublic && filled($mealPost->image_url)
? (Str::startsWith($mealPost->image_url, ['http://', 'https://'])
? $mealPost->image_url
: asset(Storage::url($mealPost->image_url)))
: null;
return $this->moderationChecks(
[
$this->has('title') ? $this->input('title') : ($isBecomingPublic ? $mealPost->title : null),
$this->has('caption') ? $this->input('caption') : ($isBecomingPublic ? $mealPost->caption : null),
$ingredients,
],
[$this->file('image'), $this->input('image_url'), $existingImageUrl],
);
}
public function messages(): array
{
return [
@@ -0,0 +1,62 @@
<?php
namespace App\Http\Requests;
use App\Enums\SocialProvider;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class MobileSocialLoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'authorizationCode' => [
'nullable',
Rule::requiredIf($this->input('provider') === SocialProvider::Apple->value),
'string',
'max:4096',
],
'deviceName' => ['sometimes', 'string', 'max:255'],
'device_name' => ['sometimes', 'string', 'max:255'],
'idToken' => ['required', 'string', 'max:10000'],
'intent' => ['required', Rule::in(['login', 'register'])],
'locale' => ['sometimes', 'string', Rule::in(config('app.supported_locales', ['fr', 'en']))],
'name' => ['sometimes', 'nullable', 'string', 'max:255'],
'nonce' => ['required', 'string', 'size:64', 'regex:/\A[a-f0-9]{64}\z/'],
'provider' => ['required', Rule::enum(SocialProvider::class)],
'termsAccepted' => ['required', 'boolean'],
];
}
protected function prepareForValidation(): void
{
if ($this->has('locale')) {
$locale = strtolower(str_replace('_', '-', (string) $this->input('locale')));
$this->merge([
'locale' => explode('-', $locale)[0],
]);
}
if ($this->has('deviceName') && ! $this->has('device_name')) {
$this->merge([
'device_name' => $this->input('deviceName'),
]);
}
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use App\Enums\SocialProvider;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class MobileSocialNonceRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'provider' => ['required', Rule::enum(SocialProvider::class)],
];
}
}
+8
View File
@@ -2,10 +2,13 @@
namespace App\Http\Requests;
use App\Http\Requests\Concerns\ModeratesUserContent;
use Illuminate\Foundation\Http\FormRequest;
class PostReviewsRequest extends FormRequest
{
use ModeratesUserContent;
public function rules(): array
{
$isCreating = $this->isMethod('post');
@@ -20,4 +23,9 @@ class PostReviewsRequest extends FormRequest
{
return true;
}
public function after(): array
{
return $this->moderationChecks([$this->input('comment')]);
}
}
+12 -9
View File
@@ -2,16 +2,15 @@
namespace App\Http\Requests;
use App\Enums\PacePreference;
use App\Enums\PhysicalActivityLevel;
use App\Enums\UserSex;
use App\Enums\WeightGoal;
use App\Http\Requests\Concerns\ModeratesUserContent;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
class RegisterRequest extends FormRequest
{
use ModeratesUserContent;
/**
* Determine if the user is authorized to make this request.
*/
@@ -50,13 +49,9 @@ class RegisterRequest extends FormRequest
->symbols()
->uncompromised(),
],
'termsAccepted' => ['required', 'accepted'],
'avatar' => ['sometimes', 'nullable', 'image', 'max:2048'],
'bio' => ['nullable', 'string'],
'physicalActivityLevel' => ['sometimes', Rule::enum(PhysicalActivityLevel::class)],
'weightGoal' => ['sometimes', Rule::enum(WeightGoal::class)],
'pacePreference' => ['sometimes', Rule::enum(PacePreference::class)],
'sex' => ['sometimes', Rule::enum(UserSex::class)],
'dateOfBirth' => ['sometimes', 'nullable', 'date_format:Y-m-d', 'before:today', 'after:1900-01-01'],
];
}
@@ -67,6 +62,14 @@ class RegisterRequest extends FormRequest
];
}
public function after(): array
{
return $this->moderationChecks(
[$this->input('name'), $this->input('bio')],
[$this->file('avatar')],
);
}
protected function prepareForValidation(): void
{
if ($this->has('locale')) {
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SearchRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'q' => ['required', 'string', 'min:2', 'max:100'],
'type' => ['required', Rule::in(['meals', 'users'])],
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:50'],
];
}
protected function prepareForValidation(): void
{
if ($this->has('q')) {
$this->merge([
'q' => trim((string) $this->input('q')),
]);
}
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\File;
class StoreIdentityVerificationRequest extends FormRequest
{
public function authorize(): bool
{
$user = $this->user();
return $user?->hasPurchasedCertification() === true
&& $user->account_verified_at === null;
}
public function rules(): array
{
return [
'document' => [
'required',
File::types(['jpg', 'jpeg', 'png', 'pdf'])->max('10mb'),
'mimetypes:image/jpeg,image/png,application/pdf',
],
];
}
public function messages(): array
{
return [
'document.required' => __('api.certification.document_required'),
'document.mimetypes' => __('api.certification.document_type'),
'document.max' => __('api.certification.document_max'),
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreWeightEntryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()?->hasCompletedNutritionOnboarding() === true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'weightKg' => ['required', 'numeric', 'min:35', 'max:300'],
'measuredAt' => ['sometimes', 'nullable', 'date', 'before_or_equal:now'],
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateNotificationPreferencesRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'socialNotificationsEnabled' => ['sometimes', 'required', 'boolean'],
'engagementRemindersEnabled' => ['sometimes', 'required', 'boolean'],
];
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Http\Requests;
class UpdateNutritionProfileRequest extends CompleteNutritionOnboardingRequest
{
public function rules(): array
{
$rules = parent::rules();
unset($rules['nutritionEstimateAccepted']);
return $rules;
}
}
+19 -19
View File
@@ -2,15 +2,14 @@
namespace App\Http\Requests;
use App\Enums\PacePreference;
use App\Enums\PhysicalActivityLevel;
use App\Enums\UserSex;
use App\Enums\WeightGoal;
use App\Http\Requests\Concerns\ModeratesUserContent;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateUserRequest extends FormRequest
{
use ModeratesUserContent;
public function authorize(): bool
{
return true;
@@ -19,31 +18,32 @@ class UpdateUserRequest extends FormRequest
public function rules(): array
{
return [
'name' => [
'sometimes',
'string',
'min:3',
'max:32',
'regex:/^[a-zA-Z0-9_.-]+$/',
Rule::unique('users', 'name')->ignore($this->user()),
],
'bio' => ['sometimes', 'nullable', 'string'],
'height' => ['sometimes', 'nullable', 'integer'],
'locale' => ['sometimes', 'string', Rule::in(config('app.supported_locales', ['fr', 'en']))],
'avatar' => ['sometimes', 'nullable', 'image', 'max:2048'],
'nutritionGoals' => ['sometimes', 'array'],
'nutritionGoals.calories' => ['required_with:nutritionGoals', 'integer', 'min:0', 'max:100000'],
'nutritionGoals.proteins' => ['required_with:nutritionGoals', 'numeric', 'min:0', 'max:10000'],
'nutritionGoals.carbs' => ['required_with:nutritionGoals', 'numeric', 'min:0', 'max:10000'],
'nutritionGoals.fats' => ['required_with:nutritionGoals', 'numeric', 'min:0', 'max:10000'],
'physicalActivityLevel' => ['sometimes', Rule::enum(PhysicalActivityLevel::class)],
'weightGoal' => ['sometimes', Rule::enum(WeightGoal::class)],
'pacePreference' => ['sometimes', Rule::enum(PacePreference::class)],
'sex' => ['sometimes', Rule::enum(UserSex::class)],
'dateOfBirth' => ['sometimes', 'nullable', 'date_format:Y-m-d', 'before:today', 'after:1900-01-01'],
];
}
public function after(): array
{
return $this->moderationChecks(
[$this->input('name'), $this->input('bio')],
[$this->file('avatar')],
);
}
public function messages(): array
{
return [
'avatar.max' => __('api.validation.image_max_2mb'),
'nutritionGoals.calories.integer' => __('api.validation.calories_integer'),
'nutritionGoals.*.required_with' => __('api.validation.nutrition_goals_required'),
'nutritionGoals.*.min' => __('api.validation.nutrition_goals_positive'),
'nutritionGoals.*.max' => __('api.validation.nutrition_goals_max'),
];
}
@@ -2,12 +2,17 @@
namespace App\Http\Resources;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
class CreditProductResource extends JsonResource
/** @mixin User */
class BlockedUserResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
@@ -15,12 +20,7 @@ class CreditProductResource extends JsonResource
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'type' => $this->type,
'credits' => $this->credits,
'amount' => $this->amount,
'currency' => $this->currency,
'formattedAmount' => $this->formattedAmount(),
'avatarUrl' => $this->avatar_url ? asset(Storage::url($this->avatar_url)) : null,
];
}
}
+28
View File
@@ -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,
];
}
}
-2
View File
@@ -17,10 +17,8 @@ class ReportResource extends JsonResource
return [
'id' => $this->id,
'reason' => $this->reason,
'reasonLabel' => $this->reason?->getLabel(),
'details' => $this->details,
'status' => $this->status,
'statusLabel' => $this->status?->getLabel(),
'reportableType' => $this->reportableType(),
'reportableId' => $this->reportable_id,
'moderationCaseId' => $this->moderation_case_id,

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