StreakFit — API Laravel 13 OSDD + Octane/FrankenPHP
Build & Deploy / build (push) Successful in 18s

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 03:00:17 +02:00
co-authored by Claude Opus 4.8
commit af32669931
300 changed files with 21235 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
.git
.gitignore
.env
.env.*
vendor
node_modules
storage/app/dataset
storage/app/private/*
storage/logs/*
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/views/*
tests
*.md
.phpunit.result.cache
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4
+85
View File
@@ -0,0 +1,85 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=.streakfit.nfteam.ovh
SESSION_SAME_SITE=lax
# Sanctum SPA (auth par cookie de session) — voir API_CONTRACT.md.
SANCTUM_STATEFUL_DOMAINS=streakfit.nfteam.ovh
# En dev local avec un front sur localhost, ajouter aussi :
# SANCTUM_STATEFUL_DOMAINS=streakfit.nfteam.ovh,localhost:3000
FRONTEND_URL=https://streakfit.nfteam.ovh
APP_FRONT_URL=https://streakfit.nfteam.ovh
# En dev local :
# FRONTEND_URL=http://localhost:3000
# APP_FRONT_URL=http://localhost:3000
# Login OIDC pocket-id (Authorization Code + PKCE) — client OIDC + secret dans Vault (secret/oidc-clients).
OIDC_ISSUER=https://pocket.nfteam.ovh
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=https://api.streakfit.nfteam.ovh/auth/oidc/callback
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
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}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Base publique du stockage objet (RustFS/S3) servant les médias (avatar_url, ...).
MEDIA_BASE_URL=https://s3.nfteam.ovh/streakfit
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+31
View File
@@ -0,0 +1,31 @@
name: Build & Deploy
on:
push:
branches: [main]
workflow_dispatch: {}
env:
IMAGE: git.nfteam.ovh/neckfire/streakfit-api
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build & push image
run: |
set -e
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.nfteam.ovh -u "${{ secrets.REGISTRY_USER }}" --password-stdin
SHA="${GITHUB_SHA::12}"
docker build -t "${IMAGE}:latest" -t "${IMAGE}:${SHA}" -f Dockerfile .
docker push --all-tags "${IMAGE}"
- name: Notify ntfy
if: always()
run: |
if [ "${{ job.status }}" = "success" ]; then EMOJI="white_check_mark"; PRIO="default"; else EMOJI="rotating_light"; PRIO="high"; fi
curl -s -H "Authorization: Bearer ${{ secrets.NTFY_TOKEN }}" -H "Title: ${GITHUB_REPOSITORY} — ${{ job.status }}" \
-H "Priority: ${PRIO}" -H "Tags: ${EMOJI}" -H "Click: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions" \
-d "${GITHUB_WORKFLOW} (${GITHUB_REF_NAME} #${GITHUB_RUN_NUMBER}) : ${{ job.status }}" \
"${{ secrets.NTFY_URL }}/${{ secrets.NTFY_TOPIC }}" || true
+32
View File
@@ -0,0 +1,32 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.codex
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/storage/app/dataset
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
**/caddy
frankenphp
frankenphp-worker.php
+2
View File
@@ -0,0 +1,2 @@
ignore-scripts=true
audit=true
+58
View File
@@ -0,0 +1,58 @@
# StreakFit — Contrat d'API (v1)
Base API : `https://api.streakfit.nfteam.ovh`. Front : `https://streakfit.nfteam.ovh`.
**Auth = Sanctum SPA par COOKIE de session** (pas de token exposé au front). Cookie de session sur `.streakfit.nfteam.ovh`.
`SANCTUM_STATEFUL_DOMAINS=streakfit.nfteam.ovh`, `SESSION_DOMAIN=.streakfit.nfteam.ovh`, CORS `supports_credentials=true`, origine autorisée = le front.
**Toute la logique est back.** Le front ne fait que : GET `/sanctum/csrf-cookie`, envoyer le cookie XSRF, afficher.
## Auth (couche functional/users)
- `GET /sanctum/csrf-cookie` — pose le cookie XSRF (Sanctum).
- `POST /register` `{name,email,password}` → 201, ouvre la session. Crée User (locale/units par défaut) + Streak vide.
- `POST /login` `{email,password}` → 200 `{user}`, ouvre la session.
- `POST /logout` → 204.
- `GET /auth/oidc/redirect` → 302 vers pocket-id (authorization code + PKCE). État en session.
- `GET /auth/oidc/callback?code&state` → échange le code (OIDC_ISSUER=https://pocket.nfteam.ovh, client via env OIDC_CLIENT_ID/SECRET,
redirect OIDC_REDIRECT_URI), lie/crée le user par `oidc_sub` puis `email`, ouvre la session, **302 vers APP_FRONT_URL**.
- `GET /api/me` (auth) → `{ user:{id,name,email,locale,units,timezone,avatar_url}, streak:{current,longest,freezes} }`.
## Exercices (public, lecture seule — couche exercises)
- `GET /api/exercises?search=&body_part=&equipment=&target=&category=&per_page=` → pagination
`{ data:[{id,name,slug,body_part,equipment,target,image_url,gif_url}], meta:{current_page,last_page,total} }`.
- `GET /api/exercises/{id}?locale=fr|pt-BR|en` → `{id,name,slug,body_part,category,equipment,target,muscle_group,
secondary_muscles[], instructions:[...étapes dans la locale (pt-BR→fallback en/fr)], image_url, gif_url, attribution}`.
- `GET /api/exercises/facets` → valeurs distinctes pour filtres `{body_parts[],equipments[],targets[],categories[]}`.
## Routines (auth, scopées user — couche routines)
CRUD REST : `GET/POST /api/routines`, `GET/PUT/DELETE /api/routines/{id}`.
Routine = `{id,name,description,is_public,estimated_minutes, exercises:[{exercise_id,position,sets,target_reps,rest_seconds,tempo,target_weight,notes, exercise:{id,name,image_url,target}}]}`.
POST/PUT accepte `exercises[]` (sync du pivot routine_exercises).
## Programmes (auth — couche programs)
CRUD REST : `/api/programs`. Program = `{id,name,description,duration_weeks,days_per_week, slots:[{week,weekday,routine_id,label}]}`.
## Séances / tracking (auth — couche tracking)
- `POST /api/sessions` `{routine_id?,title?}` → démarre une séance (status=active) → `{session}`.
- `GET /api/sessions/{id}` → séance + setLogs.
- `POST /api/sessions/{id}/sets` `{exercise_id,set_number,reps?,weight?,rpe?,duration_seconds?,distance_meters?,is_warmup?}` → log un set → `{set_log}`.
- `POST /api/sessions/{id}/complete` `{ended_at?}` → passe status=completed (déclenche WorkoutCompleted → streak). → `{session, streak, unlocked_achievements[]}`.
- `GET /api/history?per_page=` → séances completed paginées.
- `GET /api/metrics` / `POST /api/metrics` `{measured_on,weight?,body_fat?,notes?}` — mensurations.
## Dashboard (LE cœur — couche streaks, controller view-model)
- `GET /api/dashboard` (auth) → objet prêt-à-afficher :
```
{
streak: { current, longest, freezes_available, last_active_date, flame_level (0..5 selon current), active_today (bool) },
week: { goal_sessions, done_sessions, days:[{date,done,source}] }, // 7 derniers jours
recent_sessions: [ {id,title,started_at,total_volume,set_count} ],
achievements: [ {key,earned_at,label} ],
suggestion: { type:'routine'|'rest', routine_id?, label } // proposition prochaine séance
}
```
- `GET /api/achievements` → liste complète des badges de l'user.
## Conventions
- Tout scopé par `auth()->id()` (access-control lomkit ou policies) sauf exercices (public).
- Réponses JSON camelCase OU snake_case — rester COHÉRENT (snake_case, comme ci-dessus).
- Erreurs : 401 non authentifié, 403 non autorisé, 422 validation (format Laravel `{message,errors}`).
- Pagination Laravel standard (`meta`, `links`).
+81
View File
@@ -0,0 +1,81 @@
# StreakFit — Modèle de domaine (spec OSDD)
App de cross-training / suivi muscu. **Concept central = le _streak_** (régularité : jours d'entraînement
consécutifs, gamification à la Duolingo). Stack : Laravel 13 + OSDD (`xefi/laravel-osdd`) + Octane/FrankenPHP,
Filament 5 (admin), Sanctum (auth cookie SPA), lomkit REST API + access-control, MySQL (mysql-shared), RustFS/S3 (médias).
## Règles d'architecture (NON négociables)
- **Le front ne contient AUCUNE logique métier, même l'auth.** Tout est calculé/validé côté back ; l'API renvoie
du prêt-à-afficher (streak courant, PR, progression, prochaine séance, instructions dans la locale). Auth = cookie
de session Sanctum, flow OIDC piloté 100% par le back.
- **OSDD** : une couche = un package `type: layer` sous `functional/` (métier) ou `technical/` (infra), autonome
(`src/`, `database/migrations|factories|seeders`, `routes/`, provider `extends LayerServiceProvider`). Placer chaque
fichier via `php artisan osdd:<gen> --layer=<functional|technical>/<nom>`.
- **i18n** : fr, pt-BR, en. Instructions d'exos = en+fr (dataset), pt-BR retombe sur en/fr.
- FK toujours vers `users(id)` (table possédée par `functional/users`). Ne pas recréer la table users.
- Pattern de référence = `functional/exercises` (déjà fait : model + migration + config + import command).
## Couches & schéma
### functional/users (existe — auth)
- `users` : + `locale` (string, def 'fr'), `units` enum('metric','imperial') def 'metric', `timezone` (string, nullable),
`avatar_path` (nullable), `oidc_sub` (string nullable, unique). Profil léger sur le user.
### functional/exercises (FAIT — lecture seule, catalogue)
- `exercises` : external_id(unique), name, slug, category, body_part, equipment, target, muscle_group,
secondary_muscles(json), instructions(json {en:[],fr:[]}), image_path, gif_path, attribution.
### functional/routines
Une routine = liste ordonnée d'exercices avec séries/reps/repos.
- `routines` : user_id(fk), name, slug, description(text null), is_public(bool def false), estimated_minutes(int null).
- `routine_exercises` (pivot enrichi) : routine_id(fk), exercise_id(fk), position(int), sets(int), target_reps(string, ex "8-12"),
rest_seconds(int null), tempo(string null), target_weight(decimal 8,2 null), notes(text null).
- Relations : Routine belongsTo User ; Routine hasMany RoutineExercise (ordered by position) ; RoutineExercise belongsTo Exercise.
### functional/programs
Un programme = planning de routines sur plusieurs semaines.
- `programs` : user_id(fk), name, slug, description(text null), duration_weeks(int), days_per_week(int null), is_public(bool def false).
- `program_slots` : program_id(fk), week(int, 1..duration_weeks), weekday(tinyint 1..7), routine_id(fk null), label(string null).
- Relations : Program belongsTo User ; Program hasMany ProgramSlot ; ProgramSlot belongsTo Routine.
### functional/tracking
Séances réalisées + sets loggés + records + mensurations.
- `workout_sessions` : user_id(fk), routine_id(fk null), program_id(fk null), title(string null), started_at(datetime),
ended_at(datetime null), status enum('active','completed','abandoned') def 'active', notes(text null), total_volume(decimal 10,2 null).
- `set_logs` : workout_session_id(fk), exercise_id(fk), set_number(int), reps(int null), weight(decimal 8,2 null),
rpe(decimal 3,1 null), duration_seconds(int null), distance_meters(decimal 8,2 null), is_warmup(bool def false),
is_completed(bool def true), logged_at(datetime).
- `personal_records` : user_id(fk), exercise_id(fk), type enum('1rm','max_weight','max_reps','max_volume','best_time'),
value(decimal 10,2), unit(string), achieved_at(datetime), workout_session_id(fk null). unique(user_id,exercise_id,type).
- `body_metrics` : user_id(fk), measured_on(date), weight(decimal 6,2 null), body_fat(decimal 5,2 null), notes(text null). unique(user_id,measured_on).
- **Événement** : à la complétion d'une WorkoutSession, dispatch `WorkoutCompleted(session)` (listener côté streaks).
### functional/streaks (LE CONCEPT)
- `streaks` (1 par user) : user_id(fk, unique), current_count(int def 0), longest_count(int def 0),
last_active_date(date null), freezes_available(int def 0), freeze_last_granted_on(date null).
- `streak_days` : user_id(fk), date(date), source enum('workout','manual','freeze','rest'),
workout_session_id(fk null). unique(user_id,date).
- `goals` : user_id(fk), type enum('weekly_sessions','weekly_minutes','weekly_volume'), target(int),
period enum('week') def 'week', is_active(bool def true).
- `achievements` (badges) : user_id(fk), key(string, ex 'streak_7','streak_30','first_workout'), earned_at(datetime),
meta(json null). unique(user_id,key).
- **Logique (service `StreakService`, côté back)** : listener sur `WorkoutCompleted` → enregistre un `streak_day`
(source=workout) pour la date → recalcule : si last_active_date == veille → current++ ; == aujourd'hui → no-op ;
sinon si un freeze couvre le trou → consomme ; sinon reset à 1. Met à jour longest. Débloque les achievements aux paliers.
### technical/integrations (EXTENSIBILITÉ Garmin/Strava)
Générique : aucune référence en dur à un provider. Garmin/Strava = futures couches `functional/strava`, `functional/garmin`
qui implémentent le contrat et s'enregistrent dans le registre.
- `integrations` (comptes connectés) : user_id(fk), provider(string), external_user_id(string null),
access_token(text null, cast encrypted), refresh_token(text null, cast encrypted), token_expires_at(datetime null),
scopes(json null), status enum('connected','revoked','error') def 'connected', connected_at(datetime), last_synced_at(datetime null), meta(json null). unique(user_id,provider).
- `external_activities` : integration_id(fk), user_id(fk), provider(string), external_id(string), type(string),
started_at(datetime), duration_seconds(int null), distance_meters(decimal 10,2 null), calories(int null), raw(json null). unique(provider,external_id).
- **Contrats** (`src/Contracts/`) : interface `ActivityProvider` { key():string; label():string; connectUrl(User):string;
handleCallback(User,array):Integration; fetchActivities(Integration, ?Carbon $since):iterable; } — et un singleton
`ProviderRegistry` { register(ActivityProvider), get(string):?ActivityProvider, all():array }. Enregistré dans le provider de la couche.
## API (lomkit REST) — fait plus tard, couche par couche
Resources lomkit sous `src/Rest/Resources`, contrôlées par access-control (chaque user ne voit que ses données ;
exercises = public en lecture). Endpoints REST search/mutate. Vues "prêtes à afficher" (dashboard streak, séance courante)
exposées via contrôleurs dédiés quand un simple CRUD ne suffit pas.
+32
View File
@@ -0,0 +1,32 @@
# syntax=docker/dockerfile:1
# StreakFit API — Laravel 13 OSDD, runtime Laravel Octane + FrankenPHP.
# ── Stage 1 : dépendances PHP (les couches OSDD sont des packages "path") ─────
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
COPY functional ./functional
COPY technical ./technical
RUN composer install --no-dev --optimize-autoloader --no-interaction --no-scripts --ignore-platform-reqs
# ── Stage 2 : assets Filament (thème/admin) ──────────────────────────────────
# Filament v5 publie ses assets via artisan au runtime ; pas de build vite nécessaire ici.
# ── Stage 3 : runtime (FrankenPHP + Octane) ──────────────────────────────────
FROM dunglas/frankenphp:1-php8.4 AS runtime
RUN install-php-extensions intl gd zip bcmath pdo_mysql exif pcntl opcache
ENV OCTANE_SERVER=frankenphp \
SERVER_NAME=:80 \
APP_ENV=production
WORKDIR /app
COPY . .
COPY --from=vendor /app/vendor ./vendor
RUN cp docker/entrypoint.sh /usr/local/bin/entrypoint && chmod +x /usr/local/bin/entrypoint \
&& chown -R www-data:www-data storage bootstrap/cache
EXPOSE 80
ENTRYPOINT ["entrypoint"]
CMD ["php", "artisan", "octane:frankenphp", "--host=0.0.0.0", "--port=80", "--workers=auto", "--max-requests=512"]
+58
View File
@@ -0,0 +1,58 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
## Agentic Development
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
```bash
composer require laravel/boost --dev
php artisan boost:install
```
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
+46
View File
@@ -0,0 +1,46 @@
# StreakFit — Thèse produit : rétention & différenciation
## Le problème
Les apps fitness meurent de la **spirale de culpabilité** : rater un jour casse le streak → sentiment d'échec → abandon.
La majorité des apps à streak *amplifient* le churn. StreakFit inverse ça : **le streak est indulgent et social.**
## Les 5 différenciateurs (ordre d'impact rétention)
### 1. 🔥 Moteur de streak indulgent — LE cœur (couche `functional/streaks`)
- **Freezes** : jetons gagnés par régularité (ex : +1 tous les 7 jours), consommés automatiquement pour couvrir un trou d'1 jour.
- **Jours de repos qui comptent** : un `streak_day` source='rest' entretient la flamme (objectif = régularité, pas surentraînement).
- **Objectif hebdo** : atteindre X séances/semaine garde le streak "vivant" même sans séance quotidienne (`goals`).
- **Streak repair** : logger rétroactivement la veille (fenêtre 24-48h) pour réparer une série.
- Anti-churn *by design* : la flamme ne meurt pas à cause de la vraie vie.
### 2. 👥 Streaks de crew — redevabilité sociale (couche `functional/crews` à venir)
- Rejoindre un **crew/box** ; flamme **collective** (le crew maintient une série commune).
- Voir les flammes des membres, un mini-classement bienveillant, nudge quand un membre va casser sa série.
- Le cross-training est communautaire → la pression sociale positive = rétention.
### 3. ⚡ Log sans friction (couche `tracking` + front PWA)
- Sets en un tap, timer de repos auto, "répéter la dernière séance", pré-remplissage du dernier poids/reps.
- **Offline-first** (PWA + background sync) : logger au gym sans réseau, sync au retour.
### 4. 📈 Célébrer le progrès (couche `tracking` : `personal_records`, volume)
- Confettis/anim sur nouveau PR, courbes force/volume, milestones fun ("12 t déplacées cette semaine"), mensurations.
- Progrès visible = motivation. Tout est calculé côté back, renvoyé prêt-à-afficher.
### 5. 🔗 Tout compte — intégrations (couche `technical/integrations` + futures `strava`/`garmin`)
- Une activité Strava/Garmin entretient le streak (un run compte). Mobilité d'un jour off compte.
- Le contrat `ActivityProvider` rend l'ajout d'une source trivial (nouvelle couche, zéro modif du noyau).
## Boucle d'engagement (rappels ntfy — couche `technical/notifications` à venir)
- Cron quotidien : détecte les streaks "à risque" (pas d'activité aujourd'hui, freeze insuffisant) → push ntfy bienveillant.
- "🔥 ta série de N jours a besoin d'une séance aujourd'hui" / "😌 tu as gagné un jour de repos" / "🏆 nouveau PR débloqué".
- Jamais spam : 1 rappel/jour max, désactivable, respect du fuseau (`users.timezone`).
## Identité visuelle
Thème **moderne, accent flamme** : base claire (off-white) + dark mode, dégradé flamme orange→rouge (#F97316#EF4444),
ember/amber (#FBBF24) en highlight. La **flamme du streak est le héros visuel** (taille/intensité ∝ au streak).
Typo sans moderne, chiffres en display bold (le compteur de streak). Cartes arrondies, chaleur subtile.
## Priorisation build
V1 (MVP sticky) : streaks indulgents + tracking sans friction + exercices + progrès/PR + PWA installable.
V1.1 : rappels ntfy + intégrations (1 provider, ex Strava).
V1.2 : crews / social.
@@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\Achievements;
use App\Filament\Resources\Achievements\Pages\CreateAchievement;
use App\Filament\Resources\Achievements\Pages\EditAchievement;
use App\Filament\Resources\Achievements\Pages\ListAchievements;
use App\Filament\Resources\Achievements\Schemas\AchievementForm;
use App\Filament\Resources\Achievements\Tables\AchievementsTable;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Functional\Streaks\Models\Achievement;
class AchievementResource extends Resource
{
protected static ?string $model = Achievement::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return AchievementForm::configure($schema);
}
public static function table(Table $table): Table
{
return AchievementsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListAchievements::route('/'),
'create' => CreateAchievement::route('/create'),
'edit' => EditAchievement::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Achievements\Pages;
use App\Filament\Resources\Achievements\AchievementResource;
use Filament\Resources\Pages\CreateRecord;
class CreateAchievement extends CreateRecord
{
protected static string $resource = AchievementResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Achievements\Pages;
use App\Filament\Resources\Achievements\AchievementResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditAchievement extends EditRecord
{
protected static string $resource = AchievementResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Achievements\Pages;
use App\Filament\Resources\Achievements\AchievementResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListAchievements extends ListRecords
{
protected static string $resource = AchievementResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Filament\Resources\Achievements\Schemas;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class AchievementForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Select::make('user_id')
->relationship('user', 'name')
->required(),
TextInput::make('key')
->required(),
DateTimePicker::make('earned_at')
->required(),
TextInput::make('meta'),
]);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Filament\Resources\Achievements\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
/**
* Table des badges débloqués : utilisateur, clé du badge, date d'obtention.
*/
class AchievementsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('user.name')
->label('Utilisateur')
->searchable()
->sortable(),
TextColumn::make('key')
->label('Badge')
->badge()
->searchable(),
TextColumn::make('earned_at')
->label('Obtenu le')
->dateTime()
->sortable(),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->defaultSort('earned_at', 'desc');
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Filament\Resources\Exercises;
use App\Filament\Resources\Exercises\Pages\ListExercises;
use App\Filament\Resources\Exercises\Schemas\ExerciseInfolist;
use App\Filament\Resources\Exercises\Tables\ExercisesTable;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Functional\Exercises\Models\Exercise;
use UnitEnum;
/**
* Catalogue d'exercices consultation en lecture seule (1324 lignes).
* Filtrable par body_part / equipment / target, recherche sur le nom.
*/
class ExerciseResource extends Resource
{
protected static ?string $model = Exercise::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
protected static string|UnitEnum|null $navigationGroup = 'Catalogue';
protected static ?string $modelLabel = 'exercice';
protected static ?string $pluralModelLabel = 'exercices';
protected static ?string $recordTitleAttribute = 'name';
public static function infolist(Schema $schema): Schema
{
return ExerciseInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return ExercisesTable::configure($table);
}
// Catalogue en lecture seule : pas de création / édition / suppression.
public static function canCreate(): bool
{
return false;
}
public static function getPages(): array
{
return [
'index' => ListExercises::route('/'),
];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Resources\Exercises\Pages;
use App\Filament\Resources\Exercises\ExerciseResource;
use Filament\Resources\Pages\ListRecords;
class ListExercises extends ListRecords
{
protected static string $resource = ExerciseResource::class;
// Catalogue en lecture seule : aucune action de création.
protected function getHeaderActions(): array
{
return [];
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Filament\Resources\Exercises\Schemas;
use Filament\Infolists\Components\ImageEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema;
/**
* Fiche de consultation d'un exercice (modal "Voir").
* Affiche les métadonnées et les instructions du dataset (lecture seule).
*/
class ExerciseInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
ImageEntry::make('image_url')
->label('Aperçu')
->height(160)
->square(),
TextEntry::make('name')
->label('Nom'),
TextEntry::make('category')
->label('Catégorie')
->badge(),
TextEntry::make('body_part')
->label('Partie du corps')
->badge(),
TextEntry::make('equipment')
->label('Équipement')
->badge(),
TextEntry::make('target')
->label('Muscle cible')
->badge(),
TextEntry::make('muscle_group')
->label('Groupe musculaire'),
TextEntry::make('external_id')
->label('Identifiant externe'),
TextEntry::make('attribution')
->label('Attribution')
->placeholder('—'),
]);
}
}
@@ -0,0 +1,70 @@
<?php
namespace App\Filament\Resources\Exercises\Tables;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Functional\Exercises\Models\Exercise;
/**
* Table du catalogue d'exercices : thumbnail + métadonnées, filtrable et consultable.
*/
class ExercisesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
ImageColumn::make('image_url')
->label('Aperçu')
->square(),
TextColumn::make('name')
->label('Nom')
->searchable()
->sortable(),
TextColumn::make('body_part')
->label('Partie du corps')
->badge()
->sortable(),
TextColumn::make('equipment')
->label('Équipement')
->badge(),
TextColumn::make('target')
->label('Muscle cible')
->badge(),
])
->filters([
SelectFilter::make('body_part')
->label('Partie du corps')
->options(fn (): array => self::distinctOptions('body_part')),
SelectFilter::make('equipment')
->label('Équipement')
->options(fn (): array => self::distinctOptions('equipment')),
SelectFilter::make('target')
->label('Muscle cible')
->options(fn (): array => self::distinctOptions('target')),
])
->recordActions([
ViewAction::make(),
])
->defaultSort('name');
}
/**
* Valeurs distinctes d'une colonne pour alimenter un filtre (clé = valeur = libellé brut).
*
* @return array<string, string>
*/
private static function distinctOptions(string $column): array
{
return Exercise::query()
->whereNotNull($column)
->distinct()
->orderBy($column)
->pluck($column, $column)
->all();
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\Integrations;
use App\Filament\Resources\Integrations\Pages\CreateIntegration;
use App\Filament\Resources\Integrations\Pages\EditIntegration;
use App\Filament\Resources\Integrations\Pages\ListIntegrations;
use App\Filament\Resources\Integrations\Schemas\IntegrationForm;
use App\Filament\Resources\Integrations\Tables\IntegrationsTable;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Technical\Integrations\Models\Integration;
class IntegrationResource extends Resource
{
protected static ?string $model = Integration::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return IntegrationForm::configure($schema);
}
public static function table(Table $table): Table
{
return IntegrationsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListIntegrations::route('/'),
'create' => CreateIntegration::route('/create'),
'edit' => EditIntegration::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Integrations\Pages;
use App\Filament\Resources\Integrations\IntegrationResource;
use Filament\Resources\Pages\CreateRecord;
class CreateIntegration extends CreateRecord
{
protected static string $resource = IntegrationResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Integrations\Pages;
use App\Filament\Resources\Integrations\IntegrationResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditIntegration extends EditRecord
{
protected static string $resource = IntegrationResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Integrations\Pages;
use App\Filament\Resources\Integrations\IntegrationResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListIntegrations extends ListRecords
{
protected static string $resource = IntegrationResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Filament\Resources\Integrations\Schemas;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
use Technical\Integrations\Enums\IntegrationStatus;
class IntegrationForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Select::make('user_id')
->relationship('user', 'name')
->required(),
TextInput::make('provider')
->required(),
TextInput::make('external_user_id'),
DateTimePicker::make('token_expires_at'),
TextInput::make('scopes'),
Select::make('status')
->options(IntegrationStatus::class)
->default('connected')
->required(),
DateTimePicker::make('connected_at')
->required(),
DateTimePicker::make('last_synced_at'),
TextInput::make('meta'),
]);
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Filament\Resources\Integrations\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Technical\Integrations\Enums\IntegrationStatus;
/**
* Table des intégrations externes : utilisateur, fournisseur, état, dernière synchro.
* Les tokens (access/refresh) ne sont JAMAIS affichés.
*/
class IntegrationsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('user.name')
->label('Utilisateur')
->searchable()
->sortable(),
TextColumn::make('provider')
->label('Fournisseur')
->badge()
->searchable(),
TextColumn::make('status')
->label('État')
->badge()
->color(fn (IntegrationStatus $state): string => match ($state) {
IntegrationStatus::Connected => 'success',
IntegrationStatus::Revoked => 'gray',
IntegrationStatus::Error => 'danger',
}),
TextColumn::make('last_synced_at')
->label('Dernière synchro')
->dateTime()
->sortable()
->placeholder('Jamais'),
])
->filters([
SelectFilter::make('status')
->label('État')
->options(IntegrationStatus::class),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->defaultSort('last_synced_at', 'desc');
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Programs\Pages;
use App\Filament\Resources\Programs\ProgramResource;
use Filament\Resources\Pages\CreateRecord;
class CreateProgram extends CreateRecord
{
protected static string $resource = ProgramResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Programs\Pages;
use App\Filament\Resources\Programs\ProgramResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditProgram extends EditRecord
{
protected static string $resource = ProgramResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Programs\Pages;
use App\Filament\Resources\Programs\ProgramResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListPrograms extends ListRecords
{
protected static string $resource = ProgramResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\Programs;
use App\Filament\Resources\Programs\Pages\CreateProgram;
use App\Filament\Resources\Programs\Pages\EditProgram;
use App\Filament\Resources\Programs\Pages\ListPrograms;
use App\Filament\Resources\Programs\Schemas\ProgramForm;
use App\Filament\Resources\Programs\Tables\ProgramsTable;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Functional\Programs\Models\Program;
class ProgramResource extends Resource
{
protected static ?string $model = Program::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return ProgramForm::configure($schema);
}
public static function table(Table $table): Table
{
return ProgramsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListPrograms::route('/'),
'create' => CreateProgram::route('/create'),
'edit' => EditProgram::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Filament\Resources\Programs\Schemas;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
class ProgramForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Select::make('user_id')
->relationship('user', 'name')
->required(),
TextInput::make('name')
->required(),
TextInput::make('slug')
->required(),
Textarea::make('description')
->columnSpanFull(),
TextInput::make('duration_weeks')
->required()
->numeric(),
TextInput::make('days_per_week')
->numeric(),
Toggle::make('is_public')
->required(),
]);
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Filament\Resources\Programs\Tables;
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\Table;
/**
* Table des programmes : nom, propriétaire, durée, visibilité, date de création.
*/
class ProgramsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Nom')
->searchable()
->sortable(),
TextColumn::make('user.name')
->label('Utilisateur')
->searchable()
->sortable(),
TextColumn::make('duration_weeks')
->label('Semaines')
->numeric()
->sortable(),
IconColumn::make('is_public')
->label('Public')
->boolean(),
TextColumn::make('created_at')
->label('Créé le')
->dateTime()
->sortable(),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->defaultSort('created_at', 'desc');
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Routines\Pages;
use App\Filament\Resources\Routines\RoutineResource;
use Filament\Resources\Pages\CreateRecord;
class CreateRoutine extends CreateRecord
{
protected static string $resource = RoutineResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Routines\Pages;
use App\Filament\Resources\Routines\RoutineResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditRoutine extends EditRecord
{
protected static string $resource = RoutineResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Routines\Pages;
use App\Filament\Resources\Routines\RoutineResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListRoutines extends ListRecords
{
protected static string $resource = RoutineResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\Routines;
use App\Filament\Resources\Routines\Pages\CreateRoutine;
use App\Filament\Resources\Routines\Pages\EditRoutine;
use App\Filament\Resources\Routines\Pages\ListRoutines;
use App\Filament\Resources\Routines\Schemas\RoutineForm;
use App\Filament\Resources\Routines\Tables\RoutinesTable;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Functional\Routines\Models\Routine;
class RoutineResource extends Resource
{
protected static ?string $model = Routine::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return RoutineForm::configure($schema);
}
public static function table(Table $table): Table
{
return RoutinesTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListRoutines::route('/'),
'create' => CreateRoutine::route('/create'),
'edit' => EditRoutine::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Filament\Resources\Routines\Schemas;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
class RoutineForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Select::make('user_id')
->relationship('user', 'name')
->required(),
TextInput::make('name')
->required(),
TextInput::make('slug')
->required(),
Textarea::make('description')
->columnSpanFull(),
Toggle::make('is_public')
->required(),
TextInput::make('estimated_minutes')
->numeric(),
]);
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Filament\Resources\Routines\Tables;
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\Table;
/**
* Table des routines : nom, propriétaire, visibilité, date de création.
*/
class RoutinesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Nom')
->searchable()
->sortable(),
TextColumn::make('user.name')
->label('Utilisateur')
->searchable()
->sortable(),
IconColumn::make('is_public')
->label('Publique')
->boolean(),
TextColumn::make('created_at')
->label('Créée le')
->dateTime()
->sortable(),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->defaultSort('created_at', 'desc');
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Streaks\Pages;
use App\Filament\Resources\Streaks\StreakResource;
use Filament\Resources\Pages\CreateRecord;
class CreateStreak extends CreateRecord
{
protected static string $resource = StreakResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Streaks\Pages;
use App\Filament\Resources\Streaks\StreakResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditStreak extends EditRecord
{
protected static string $resource = StreakResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Streaks\Pages;
use App\Filament\Resources\Streaks\StreakResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListStreaks extends ListRecords
{
protected static string $resource = StreakResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Filament\Resources\Streaks\Schemas;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class StreakForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Select::make('user_id')
->relationship('user', 'name')
->required(),
TextInput::make('current_count')
->required()
->numeric()
->default(0),
TextInput::make('longest_count')
->required()
->numeric()
->default(0),
DatePicker::make('last_active_date'),
TextInput::make('freezes_available')
->required()
->numeric()
->default(0),
DatePicker::make('freeze_last_granted_on'),
]);
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\Streaks;
use App\Filament\Resources\Streaks\Pages\CreateStreak;
use App\Filament\Resources\Streaks\Pages\EditStreak;
use App\Filament\Resources\Streaks\Pages\ListStreaks;
use App\Filament\Resources\Streaks\Schemas\StreakForm;
use App\Filament\Resources\Streaks\Tables\StreaksTable;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Functional\Streaks\Models\Streak;
class StreakResource extends Resource
{
protected static ?string $model = Streak::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return StreakForm::configure($schema);
}
public static function table(Table $table): Table
{
return StreaksTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListStreaks::route('/'),
'create' => CreateStreak::route('/create'),
'edit' => EditStreak::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Filament\Resources\Streaks\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
/**
* Table des streaks : régularité par utilisateur (courant, record, freezes, dernière activité).
*/
class StreaksTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('user.name')
->label('Utilisateur')
->searchable()
->sortable(),
TextColumn::make('current_count')
->label('Streak courant')
->badge()
->color('warning')
->sortable(),
TextColumn::make('longest_count')
->label('Record')
->numeric()
->sortable(),
TextColumn::make('freezes_available')
->label('Freezes dispo')
->numeric()
->sortable(),
TextColumn::make('last_active_date')
->label('Dernière activité')
->date()
->sortable(),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->defaultSort('current_count', 'desc');
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource;
use Filament\Resources\Pages\CreateRecord;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditUser extends EditRecord
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Filament\Resources\Users\Schemas;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
use Functional\Users\Enums\Units;
class UserForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->required(),
TextInput::make('email')
->label('Email address')
->email()
->required(),
DateTimePicker::make('email_verified_at'),
TextInput::make('password')
->password()
->required(),
TextInput::make('locale')
->required()
->default('fr'),
Select::make('units')
->options(Units::class)
->default('metric')
->required(),
TextInput::make('timezone'),
TextInput::make('avatar_path'),
TextInput::make('oidc_sub'),
]);
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Filament\Resources\Users\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
/**
* Table des utilisateurs : identité, préférences d'affichage, date d'inscription.
*/
class UsersTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Nom')
->searchable()
->sortable(),
TextColumn::make('email')
->label('Email')
->searchable()
->sortable(),
TextColumn::make('locale')
->label('Langue')
->badge(),
TextColumn::make('units')
->label('Unités')
->badge(),
TextColumn::make('created_at')
->label('Inscrit le')
->dateTime()
->sortable(),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->defaultSort('created_at', 'desc');
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\Users;
use App\Filament\Resources\Users\Pages\CreateUser;
use App\Filament\Resources\Users\Pages\EditUser;
use App\Filament\Resources\Users\Pages\ListUsers;
use App\Filament\Resources\Users\Schemas\UserForm;
use App\Filament\Resources\Users\Tables\UsersTable;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Functional\Users\Models\User;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return UserForm::configure($schema);
}
public static function table(Table $table): Table
{
return UsersTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListUsers::route('/'),
'create' => CreateUser::route('/create'),
'edit' => EditUser::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\WorkoutSessions\Pages;
use App\Filament\Resources\WorkoutSessions\WorkoutSessionResource;
use Filament\Resources\Pages\CreateRecord;
class CreateWorkoutSession extends CreateRecord
{
protected static string $resource = WorkoutSessionResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\WorkoutSessions\Pages;
use App\Filament\Resources\WorkoutSessions\WorkoutSessionResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditWorkoutSession extends EditRecord
{
protected static string $resource = WorkoutSessionResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\WorkoutSessions\Pages;
use App\Filament\Resources\WorkoutSessions\WorkoutSessionResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListWorkoutSessions extends ListRecords
{
protected static string $resource = WorkoutSessionResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Filament\Resources\WorkoutSessions\Schemas;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Schemas\Schema;
use Functional\Tracking\Enums\WorkoutSessionStatus;
class WorkoutSessionForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Select::make('user_id')
->relationship('user', 'name')
->required(),
Select::make('routine_id')
->relationship('routine', 'name'),
Select::make('program_id')
->relationship('program', 'name'),
TextInput::make('title'),
DateTimePicker::make('started_at')
->required(),
DateTimePicker::make('ended_at'),
Select::make('status')
->options(WorkoutSessionStatus::class)
->default('active')
->required(),
Textarea::make('notes')
->columnSpanFull(),
TextInput::make('total_volume')
->numeric(),
]);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Filament\Resources\WorkoutSessions\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Functional\Tracking\Enums\WorkoutSessionStatus;
/**
* Table des séances : utilisateur, statut, début, volume total.
*/
class WorkoutSessionsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('user.name')
->label('Utilisateur')
->searchable()
->sortable(),
TextColumn::make('status')
->label('Statut')
->badge()
->color(fn (WorkoutSessionStatus $state): string => match ($state) {
WorkoutSessionStatus::Active => 'warning',
WorkoutSessionStatus::Completed => 'success',
WorkoutSessionStatus::Abandoned => 'danger',
}),
TextColumn::make('started_at')
->label('Débutée le')
->dateTime()
->sortable(),
TextColumn::make('total_volume')
->label('Volume total')
->numeric()
->sortable(),
])
->filters([
SelectFilter::make('status')
->label('Statut')
->options(WorkoutSessionStatus::class),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->defaultSort('started_at', 'desc');
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\WorkoutSessions;
use App\Filament\Resources\WorkoutSessions\Pages\CreateWorkoutSession;
use App\Filament\Resources\WorkoutSessions\Pages\EditWorkoutSession;
use App\Filament\Resources\WorkoutSessions\Pages\ListWorkoutSessions;
use App\Filament\Resources\WorkoutSessions\Schemas\WorkoutSessionForm;
use App\Filament\Resources\WorkoutSessions\Tables\WorkoutSessionsTable;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Functional\Tracking\Models\WorkoutSession;
class WorkoutSessionResource extends Resource
{
protected static ?string $model = WorkoutSession::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{
return WorkoutSessionForm::configure($schema);
}
public static function table(Table $table): Table
{
return WorkoutSessionsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListWorkoutSessions::route('/'),
'create' => CreateWorkoutSession::route('/create'),
'edit' => EditWorkoutSession::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Filament\Widgets;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Functional\Exercises\Models\Exercise;
use Functional\Streaks\Models\Streak;
use Functional\Tracking\Models\WorkoutSession;
use Functional\Users\Models\User;
use Illuminate\Support\Carbon;
/**
* Vue d'ensemble du dashboard : chiffres clés de StreakFit.
* Nb d'utilisateurs, séances (total + cette semaine), plus long streak en cours, catalogue d'exos.
*/
class StreakFitStatsOverview extends StatsOverviewWidget
{
protected function getStats(): array
{
$sessionsThisWeek = WorkoutSession::query()
->where('started_at', '>=', Carbon::now()->startOfWeek())
->count();
return [
Stat::make('Utilisateurs', (string) User::query()->count())
->description('Comptes inscrits')
->descriptionIcon('heroicon-m-users')
->color('primary'),
Stat::make('Séances', (string) WorkoutSession::query()->count())
->description($sessionsThisWeek . ' cette semaine')
->descriptionIcon('heroicon-m-fire')
->color('warning'),
Stat::make('Meilleur streak en cours', (string) (Streak::query()->max('current_count') ?? 0))
->description('Jours consécutifs')
->descriptionIcon('heroicon-m-bolt')
->color('success'),
Stat::make('Exercices', (string) Exercise::query()->count())
->description('Catalogue disponible')
->descriptionIcon('heroicon-m-rectangle-stack')
->color('primary'),
];
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Providers\Filament;
use App\Filament\Widgets\StreakFitStatsOverview;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
/**
* Back-office StreakFit (Filament).
*
* Thème "flamme" : accent orange/amber sur base claire + dark mode.
* Auth via le guard 'web' standard (compatible avec le flow Sanctum SPA du front) ;
* l'accès effectif au panel sera verrouillé quand le model User implémentera
* Filament\Models\Contracts\FilamentUser::canAccessPanel().
*/
class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->brandName('StreakFit 🔥')
->login()
// Guard web standard : n'interfère pas avec l'auth Sanctum du front.
->authGuard('web')
// Accent "flamme" : orange en primaire, amber en secondaire (warning).
->colors([
'primary' => Color::Orange,
'warning' => Color::Amber,
'danger' => Color::Red,
'success' => Color::Emerald,
])
// Dark mode disponible via le sélecteur clair/sombre (activé par défaut).
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->pages([
Dashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\Filament\Widgets')
->widgets([
StreakFitStatsOverview::class,
])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
PreventRequestForgery::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
]);
}
}
Executable
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);
+24
View File
@@ -0,0 +1,24 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// Active le flow Sanctum SPA : cookie de session + protection CSRF pour les requêtes API du front.
$middleware->statefulApi();
// Pas de route web "login" nommée (SPA) : rediriger les invités vers le front plutôt que planter.
$middleware->redirectGuestsTo(fn () => config('app.front_url', 'https://streakfit.nfteam.ovh') . '/login');
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'),
);
})->create();
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+5
View File
@@ -0,0 +1,5 @@
<?php
return [
App\Providers\Filament\AdminPanelProvider::class,
];
+111
View File
@@ -0,0 +1,111 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.3",
"filament/filament": "^5.0",
"functional/exercises": "*",
"functional/programs": "*",
"functional/routines": "*",
"functional/streaks": "*",
"functional/tracking": "*",
"functional/users": "*",
"laravel/framework": "^13.8",
"laravel/octane": "^2.17",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0",
"league/flysystem-aws-s3-v3": "^3.0",
"lomkit/laravel-access-control": "^0.5.0",
"lomkit/laravel-rest-api": "^2.22",
"spatie/laravel-permission": "^8.3",
"technical/integrations": "*",
"technical/osdd": "*",
"xefi/laravel-osdd": "^2.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.5",
"laravel/pao": "^1.0.6",
"laravel/pint": "^1.27",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^12.5.12"
},
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install --ignore-scripts",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi @no_additional_args",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi",
"@php artisan filament:upgrade"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"repositories": [
{
"type": "path",
"url": "./functional/*"
},
{
"type": "path",
"url": "./technical/*"
}
]
}
Generated
+11615
View File
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Front-End Application URL
|--------------------------------------------------------------------------
|
| URL du front SPA vers laquelle rediriger l'utilisateur une fois le flow
| d'authentification OIDC terminé (voir Functional\Users\Http\Controllers\OidcController).
|
*/
'front_url' => env('APP_FRONT_URL', 'https://streakfit.nfteam.ovh'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];
+36
View File
@@ -0,0 +1,36 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Cross-Origin Resource Sharing (CORS) Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your settings for cross-origin resource sharing
| or "CORS". This determines what cross-origin operations may execute
| in web browsers. You are free to adjust these settings as needed.
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
*/
'paths' => ['sanctum/csrf-cookie', 'login', 'register', 'logout', 'auth/*', 'api/*'],
'allowed_methods' => ['*'],
// Origine du front autorisée à envoyer le cookie de session (SPA Sanctum).
'allowed_origins' => [env('FRONTEND_URL', 'https://streakfit.nfteam.ovh')],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
// Requis par Sanctum SPA : le cookie de session doit voyager avec les requêtes cross-origin du front.
'supports_credentials' => true,
];
+81
View File
@@ -0,0 +1,81 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim((string) env('APP_URL'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
// Médias StreakFit sur RustFS/S3 (thumbnails + gifs d'exercices).
'media' => [
'driver' => 's3',
'key' => env('S3_ACCESS_KEY'),
'secret' => env('S3_SECRET_KEY'),
'region' => env('S3_REGION', 'us-east-1'),
'bucket' => env('S3_BUCKET'),
'endpoint' => env('S3_ENDPOINT'),
'use_path_style_endpoint' => true,
'visibility' => 'public',
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];
+224
View File
@@ -0,0 +1,224 @@
<?php
use Laravel\Octane\Contracts\OperationTerminated;
use Laravel\Octane\Events\RequestHandled;
use Laravel\Octane\Events\RequestReceived;
use Laravel\Octane\Events\RequestTerminated;
use Laravel\Octane\Events\TaskReceived;
use Laravel\Octane\Events\TaskTerminated;
use Laravel\Octane\Events\TickReceived;
use Laravel\Octane\Events\TickTerminated;
use Laravel\Octane\Events\WorkerErrorOccurred;
use Laravel\Octane\Events\WorkerStarting;
use Laravel\Octane\Events\WorkerStopping;
use Laravel\Octane\Listeners\CloseMonologHandlers;
use Laravel\Octane\Listeners\CollectGarbage;
use Laravel\Octane\Listeners\DisconnectFromDatabases;
use Laravel\Octane\Listeners\EnsureUploadedFilesAreValid;
use Laravel\Octane\Listeners\EnsureUploadedFilesCanBeMoved;
use Laravel\Octane\Listeners\FlushOnce;
use Laravel\Octane\Listeners\FlushTemporaryContainerInstances;
use Laravel\Octane\Listeners\FlushUploadedFiles;
use Laravel\Octane\Listeners\ReportException;
use Laravel\Octane\Listeners\StopWorkerIfNecessary;
use Laravel\Octane\Octane;
return [
/*
|--------------------------------------------------------------------------
| Octane Server
|--------------------------------------------------------------------------
|
| This value determines the default "server" that will be used by Octane
| when starting, restarting, or stopping your server via the CLI. You
| are free to change this to the supported server of your choosing.
|
| Supported: "roadrunner", "swoole", "frankenphp"
|
*/
'server' => env('OCTANE_SERVER', 'roadrunner'),
/*
|--------------------------------------------------------------------------
| Force HTTPS
|--------------------------------------------------------------------------
|
| When this configuration value is set to "true", Octane will inform the
| framework that all absolute links must be generated using the HTTPS
| protocol. Otherwise your links may be generated using plain HTTP.
|
*/
'https' => env('OCTANE_HTTPS', false),
/*
|--------------------------------------------------------------------------
| Octane Listeners
|--------------------------------------------------------------------------
|
| All of the event listeners for Octane's events are defined below. These
| listeners are responsible for resetting your application's state for
| the next request. You may even add your own listeners to the list.
|
*/
'listeners' => [
WorkerStarting::class => [
EnsureUploadedFilesAreValid::class,
EnsureUploadedFilesCanBeMoved::class,
],
RequestReceived::class => [
...Octane::prepareApplicationForNextOperation(),
...Octane::prepareApplicationForNextRequest(),
//
],
RequestHandled::class => [
//
],
RequestTerminated::class => [
// FlushUploadedFiles::class,
],
TaskReceived::class => [
...Octane::prepareApplicationForNextOperation(),
//
],
TaskTerminated::class => [
//
],
TickReceived::class => [
...Octane::prepareApplicationForNextOperation(),
//
],
TickTerminated::class => [
//
],
OperationTerminated::class => [
FlushOnce::class,
FlushTemporaryContainerInstances::class,
// DisconnectFromDatabases::class,
// CollectGarbage::class,
],
WorkerErrorOccurred::class => [
ReportException::class,
StopWorkerIfNecessary::class,
],
WorkerStopping::class => [
CloseMonologHandlers::class,
],
],
/*
|--------------------------------------------------------------------------
| Warm / Flush Bindings
|--------------------------------------------------------------------------
|
| The bindings listed below will either be pre-warmed when a worker boots
| or they will be flushed before every new request. Flushing a binding
| will force the container to resolve that binding again when asked.
|
*/
'warm' => [
...Octane::defaultServicesToWarm(),
],
'flush' => [
//
],
/*
|--------------------------------------------------------------------------
| Octane Swoole Tables
|--------------------------------------------------------------------------
|
| While using Swoole, you may define additional tables as required by the
| application. These tables can be used to store data that needs to be
| quickly accessed by other workers on the particular Swoole server.
|
*/
'tables' => [
'example:1000' => [
'name' => 'string:1000',
'votes' => 'int',
],
],
/*
|--------------------------------------------------------------------------
| Octane Swoole Cache Table
|--------------------------------------------------------------------------
|
| While using Swoole, you may leverage the Octane cache, which is powered
| by a Swoole table. You may set the maximum number of rows as well as
| the number of bytes per row using the configuration options below.
|
*/
'cache' => [
'rows' => 1000,
'bytes' => 10000,
],
/*
|--------------------------------------------------------------------------
| File Watching
|--------------------------------------------------------------------------
|
| The following list of files and directories will be watched when using
| the --watch option offered by Octane. If any of the directories and
| files are changed, Octane will automatically reload your workers.
|
*/
'watch' => [
'app',
'bootstrap',
'config/**/*.php',
'database/**/*.php',
'public/**/*.php',
'resources/**/*.php',
'routes',
'composer.lock',
'.env',
],
/*
|--------------------------------------------------------------------------
| Garbage Collection Threshold
|--------------------------------------------------------------------------
|
| When executing long-lived PHP scripts such as Octane, memory can build
| up before being cleared by PHP. You can force Octane to run garbage
| collection if your application consumes this amount of megabytes.
|
*/
'garbage' => 50,
/*
|--------------------------------------------------------------------------
| Maximum Execution Time
|--------------------------------------------------------------------------
|
| The following setting configures the maximum execution time for requests
| being handled by Octane. You may set this value to 0 to indicate that
| there isn't a specific time limit on Octane request execution time.
|
*/
'max_execution_time' => 30,
];
+51
View File
@@ -0,0 +1,51 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'token' => env('POSTMARK_TOKEN'),
],
'resend' => [
'key' => env('RESEND_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
// SSO pocket-id (OpenID Connect), piloté 100% côté back — voir Functional\Users\Http\Controllers\OidcController.
'oidc' => [
'issuer' => env('OIDC_ISSUER', 'https://pocket.nfteam.ovh'),
'client_id' => env('OIDC_CLIENT_ID'),
'client_secret' => env('OIDC_CLIENT_SECRET'),
'redirect_uri' => env('OIDC_REDIRECT_URI'),
],
// Base publique du stockage objet (RustFS/S3) servant les médias (avatars, ...).
'media' => [
'base_url' => env('MEDIA_BASE_URL'),
],
];
+217
View File
@@ -0,0 +1,217 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::snake((string) env('APP_NAME', 'laravel')).'_session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain and all subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
];
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sessions');
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedSmallInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
}
};
+18
View File
@@ -0,0 +1,18 @@
# StreakFit API — Laravel 13 OSDD + Octane/FrankenPHP. Image buildée par la CI Gitea (neckfire/streakfit-api).
# Base : mysql-shared (base+rôle dédiés), médias RustFS. Secrets via Vault -> .env. Watchtower auto-deploy.
services:
streakfit-api:
image: git.nfteam.ovh/neckfire/streakfit-api:latest
pull_policy: always
container_name: streakfit-api
env_file: .env
ports:
- "192.168.1.136:8111:80"
labels:
- homepage.group=Développement
- homepage.name=StreakFit API
- homepage.icon=mdi-fire
- homepage.href=https://api.streakfit.nfteam.ovh
- homepage.description=API cross-training (Laravel OSDD / Octane)
- com.centurylinklabs.watchtower.enable=true
restart: unless-stopped
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Entrypoint StreakFit API (FrankenPHP/Octane) : prépare l'app puis lance le serveur.
set -e
php artisan config:cache
php artisan migrate --force || true
php artisan storage:link || true
php artisan filament:optimize || true
exec "$@"
+90
View File
@@ -0,0 +1,90 @@
<?php namespace Xefi\Faker\Container;
/**
* @method array wordsAsArray(int $words = 3)
* @method string words(int $words = 3)
* @method array sentencesAsArray(int $sentences = 3)
* @method string sentences(int $sentences = 3)
* @method array paragraphsAsArray(int $paragraphs = 3)
* @method string paragraphs(int $paragraphs = 3)
* @method ?string getLocale()
* @method int digit()
* @method int number(int $min, int $max)
* @method float float(float $min, float $max, int $decimals = 1)
* @method mixed letter()
* @method array|string shuffle(array|string $needle)
* @method string shuffleString(string $needle)
* @method array shuffleArray(array $needle)
* @method string convertCharacters(string $string)
* @method string semver()
* @method string emoji()
* @method string uuid()
* @method string ulid()
* @method string md5()
* @method string sha1()
* @method string sha256()
* @method string sha512()
* @method \DateTime dateTime(\DateTime|string|int $fromTimestamp = '-30 years', \DateTime|string|int $toTimestamp = 'now')
* @method int timestamp(\DateTime|string|int $fromTimestamp = '-30 years', \DateTime|string|int $toTimestamp = 'now')
* @method string timezone()
* @method string sdl()
* @method string tld()
* @method string domain()
* @method string ip()
* @method string ipv4()
* @method string ipv6()
* @method mixed macAddress()
* @method mixed email()
* @method string url(mixed $secure = true)
* @method string safeColorName()
* @method string colorName()
* @method string safeHexColor()
* @method string hexColor()
* @method array rgbColorAsArray()
* @method string rgbColor()
* @method string rgbCssColor()
* @method array rgbaColorAsArray()
* @method string rgbaColor()
* @method string rgbaCssColor()
* @method array hslColorAsArray()
* @method string hslColor()
* @method string hslCssColor()
* @method array hslaColorAsArray()
* @method string hslaColor()
* @method string hslaCssColor()
* @method string name(?string $gender = null)
* @method string firstName(?string $gender = null)
* @method string lastName()
* @method mixed title(?string $gender = null)
* @method string iban(?string $countryCode = null, ?string $format = null)
* @method mixed boolean(int $percentage = 50)
* @method mixed randomElement(array $array)
* @method mixed randomKey(array $array)
* @method string phoneNumber(?string $format = null, string $separator = '', ?string $prefix = null)
* @method string indicatoredPhoneNumber(?string $format = null, string $separator = '')
* @method string spacedPhoneNumber(?string $format = null, ?string $prefix = null)
* @method string spacedIndicatoredPhoneNumber(?string $format = null)
* @method string dottedPhoneNumber(?string $format = null, ?string $prefix = null)
* @method string dottedIndicatoredPhoneNumber(?string $format = null)
* @method string dashedPhoneNumber(?string $format = null, ?string $prefix = null)
* @method string dashedIndicatoredPhoneNumber(?string $format = null)
* @method string cellPhoneNumber(string $separator = '', ?string $prefix = null)
* @method string indicatoredCellPhoneNumber(string $separator = '')
* @method string spacedCellPhoneNumber(?string $prefix = null)
* @method string spacedIndicatoredCellPhoneNumber()
* @method string dottedCellPhoneNumber(?string $prefix = null)
* @method string dottedIndicatoredCellPhoneNumber()
* @method string dashedCellPhoneNumber(?string $prefix = null)
* @method string dashedIndicatoredCellPhoneNumber()
* @method string landlinePhoneNumber(string $separator = '', ?string $prefix = null)
* @method string indicatoredLandlinePhoneNumber(string $separator = '')
* @method string spacedLandlinePhoneNumber(?string $prefix = null)
* @method string spacedIndicatoredLandlinePhoneNumber()
* @method string dottedLandlinePhoneNumber(?string $prefix = null)
* @method string dottedIndicatoredLandlinePhoneNumber()
* @method string dashedLandlinePhoneNumber(?string $prefix = null)
* @method string dashedIndicatoredLandlinePhoneNumber()
* @method string geoLocation()
* @method float latitude()
* @method float longitude()
*/
class Container{}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "functional/exercises",
"description": "",
"type": "layer",
"version": "1.0.0",
"require": {
"xefi/laravel-osdd": "*"
},
"autoload": {
"psr-4": {
"Functional\\Exercises\\": "src/",
"Functional\\Exercises\\Database\\Seeders\\": "database/seeders/",
"Functional\\Exercises\\Database\\Factories\\": "database/factories/"
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"extra": {
"laravel": {
"providers": [
"Functional\\Exercises\\Providers\\ExercisesServiceProvider"
]
}
}
}
@@ -0,0 +1,9 @@
<?php
return [
// Base URL publique des médias (RustFS/S3). Ex: https://s3.nfteam.ovh/streakfit
'media_base_url' => env('MEDIA_BASE_URL', 'https://s3.nfteam.ovh/streakfit'),
// Préfixe des objets média dans le bucket.
'media_prefix' => env('EXERCISES_MEDIA_PREFIX', 'exercises'),
];
@@ -0,0 +1,39 @@
<?php
namespace Functional\Exercises\Database\Factories;
use Functional\Exercises\Models\Exercise;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Exercise>
*/
class ExerciseFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$name = fake()->unique()->words(3, true);
$ext = fake()->unique()->numerify('####');
return [
'external_id' => $ext,
'name' => $name,
'slug' => \Illuminate\Support\Str::slug($name . '-' . $ext),
'category' => fake()->randomElement(['waist', 'chest', 'back', 'upper legs', 'shoulders']),
'body_part' => fake()->randomElement(['waist', 'chest', 'back', 'upper legs', 'shoulders']),
'equipment' => fake()->randomElement(['body weight', 'dumbbell', 'barbell', 'cable', 'kettlebell']),
'target' => fake()->randomElement(['abs', 'pectorals', 'lats', 'quads', 'delts']),
'muscle_group' => fake()->word(),
'secondary_muscles' => fake()->randomElements(['hip flexors', 'lower back', 'triceps', 'glutes'], 2),
'instructions' => ['en' => [fake()->sentence()], 'fr' => [fake()->sentence()]],
'image_path' => 'exercises/' . $ext . '.jpg',
'gif_path' => 'exercises/' . $ext . '.gif',
'attribution' => '© Gym visual — https://gymvisual.com/',
];
}
}
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('exercises', function (Blueprint $table) {
$table->id();
$table->string('external_id')->unique(); // "0001" du dataset
$table->string('name');
$table->string('slug')->index();
$table->string('category')->nullable()->index(); // waist, chest...
$table->string('body_part')->nullable()->index(); // upper arms, back...
$table->string('equipment')->nullable()->index(); // dumbbell, body weight...
$table->string('target')->nullable()->index(); // muscle cible principal
$table->string('muscle_group')->nullable(); // synergistes primaires
$table->json('secondary_muscles')->nullable(); // ["deltoid", ...]
$table->json('instructions')->nullable(); // { en:[...], fr:[...] }
$table->string('image_path')->nullable(); // clé S3 / chemin média
$table->string('gif_path')->nullable();
$table->string('attribution')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('exercises');
}
};
@@ -0,0 +1,17 @@
<?php
namespace Functional\Exercises\Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class ExercisesSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
use Functional\Exercises\Http\Controllers\ExercisesController;
use Illuminate\Support\Facades\Route;
// Routes here are wrapped in the 'api' middleware group with the 'api'
// prefix by the layer service provider. To version your endpoints
// (/api/v1/...), nest Route::prefix('v1')->group(...) inside.
// Catalogue public, lecture seule — aucun middleware d'authentification.
// /facets doit être déclaré avant /{exercise} pour ne pas être capturé par le binding.
Route::get('/exercises/facets', [ExercisesController::class, 'facets']);
Route::get('/exercises', [ExercisesController::class, 'index']);
Route::get('/exercises/{exercise}', [ExercisesController::class, 'show']);
+6
View File
@@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Broadcast;
// Define broadcast channels here, e.g.:
// Broadcast::channel('App.Models.User.{id}', fn($user, $id) => (int) $user->id === (int) $id);
+6
View File
@@ -0,0 +1,6 @@
<?php
use Illuminate\Support\Facades\Artisan;
// Define artisan commands here, e.g.:
// Artisan::command('layer:hello', fn() => info('Hello from this layer.'));
+7
View File
@@ -0,0 +1,7 @@
<?php
use Illuminate\Support\Facades\Route;
// Routes here are wrapped in the 'web' middleware group by the layer
// service provider. For custom groups (prefix, domain, named groups, ...)
// use Route::middleware(...)->prefix(...)->group(function () { ... });
@@ -0,0 +1,115 @@
<?php
namespace Functional\Exercises\Console\Commands;
use Functional\Exercises\Models\Exercise;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Importe le dataset d'exercices (fork hasaneyldrm/exercises-dataset).
* JSON : upsert par external_id. --media : pousse aussi images+gifs vers RustFS/S3.
*/
class ImportExercisesCommand extends Command
{
protected $signature = 'exercises:import
{--path= : racine du dataset (contient data/exercises.json, images/, videos/)}
{--media : uploade aussi les images/gifs vers le disque S3 "media"}
{--langs=en,fr : langues d instructions à conserver (pt-BR retombe sur en)}
{--fresh : vide la table avant import}';
protected $description = "Importe le dataset d'exercices (JSON) et, avec --media, les médias vers RustFS/S3.";
public function handle(): int
{
$root = rtrim($this->option('path') ?: storage_path('app/dataset'), '/');
$json = is_file("$root/data/exercises.json") ? "$root/data/exercises.json" : "$root/exercises.json";
if (! is_file($json)) {
$this->error("exercises.json introuvable sous {$root}");
return self::FAILURE;
}
$langs = array_values(array_filter(array_map('trim', explode(',', (string) $this->option('langs')))));
$records = json_decode((string) file_get_contents($json), true);
if (! is_array($records)) {
$this->error('JSON invalide');
return self::FAILURE;
}
if ($this->option('fresh')) {
Exercise::query()->delete();
$this->warn('Table exercises vidée.');
}
$media = (bool) $this->option('media');
$prefix = trim((string) config('exercises.media_prefix', 'exercises'), '/');
$disk = $media ? Storage::disk('media') : null;
$bar = $this->output->createProgressBar(count($records));
$bar->start();
$imported = 0;
$uploaded = 0;
foreach ($records as $r) {
$ext = (string) ($r['id'] ?? '');
if ($ext === '') {
$bar->advance();
continue;
}
// Instructions : on garde les étapes (arrays) des langues demandées.
$steps = $r['instruction_steps'] ?? [];
$instructions = [];
foreach ($langs as $l) {
if (! empty($steps[$l])) {
$instructions[$l] = array_values($steps[$l]);
}
}
$imgKey = ! empty($r['image']) ? "{$prefix}/{$ext}.jpg" : null;
$gifKey = ! empty($r['gif_url']) ? "{$prefix}/{$ext}.gif" : null;
if ($media && $disk) {
foreach ([[$r['image'] ?? null, $imgKey], [$r['gif_url'] ?? null, $gifKey]] as [$rel, $key]) {
if ($rel && $key && is_file("{$root}/{$rel}") && ! $disk->exists($key)) {
$disk->put($key, (string) file_get_contents("{$root}/{$rel}"), 'public');
$uploaded++;
}
}
}
Exercise::updateOrCreate(
['external_id' => $ext],
[
'name' => $r['name'] ?? $ext,
'slug' => Str::slug(($r['name'] ?? $ext) . '-' . $ext),
'category' => $r['category'] ?? null,
'body_part' => $r['body_part'] ?? null,
'equipment' => $r['equipment'] ?? null,
'target' => $r['target'] ?? null,
'muscle_group' => $r['muscle_group'] ?? null,
'secondary_muscles' => $r['secondary_muscles'] ?? [],
'instructions' => $instructions,
'image_path' => $imgKey,
'gif_path' => $gifKey,
'attribution' => $r['attribution'] ?? null,
]
);
$imported++;
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info("Importé/à jour : {$imported} exercices" . ($media ? " (+ {$uploaded} médias S3)" : ''));
return self::SUCCESS;
}
}
@@ -0,0 +1,74 @@
<?php
namespace Functional\Exercises\Http\Controllers;
use Functional\Exercises\Http\Resources\ExerciseListResource;
use Functional\Exercises\Http\Resources\ExerciseResource;
use Functional\Exercises\Models\Exercise;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* Catalogue d'exercices : lecture seule et public, aucun scoping par utilisateur.
*/
class ExercisesController
{
private const DEFAULT_LOCALE = 'fr';
private const DEFAULT_PER_PAGE = 20;
/** GET /api/exercises — liste filtrable (search/body_part/equipment/target/category) et paginée. */
public function index(Request $request): AnonymousResourceCollection
{
$exercises = Exercise::query()
->search($request->query('search'))
->when($request->query('body_part'), fn ($query, $bodyPart) => $query->where('body_part', $bodyPart))
->when($request->query('equipment'), fn ($query, $equipment) => $query->where('equipment', $equipment))
->when($request->query('target'), fn ($query, $target) => $query->where('target', $target))
->when($request->query('category'), fn ($query, $category) => $query->where('category', $category))
->orderBy('name')
->paginate((int) $request->query('per_page', self::DEFAULT_PER_PAGE));
return ExerciseListResource::collection($exercises);
}
/** GET /api/exercises/{exercise} — détail avec instructions dans la locale demandée. */
public function show(Request $request, Exercise $exercise): ExerciseResource
{
return new ExerciseResource($exercise, $this->resolveLocale($request));
}
/** GET /api/exercises/facets — valeurs distinctes pour alimenter les filtres du front. */
public function facets(): JsonResponse
{
return response()->json([
'body_parts' => Exercise::query()->whereNotNull('body_part')->distinct()->orderBy('body_part')->pluck('body_part')->values(),
'equipments' => Exercise::query()->whereNotNull('equipment')->distinct()->orderBy('equipment')->pluck('equipment')->values(),
'targets' => Exercise::query()->whereNotNull('target')->distinct()->orderBy('target')->pluck('target')->values(),
'categories' => Exercise::query()->whereNotNull('category')->distinct()->orderBy('category')->pluck('category')->values(),
]);
}
/** Résout la locale demandée : paramètre `locale`, sinon header Accept-Language, sinon 'fr'. */
private function resolveLocale(Request $request): string
{
$locale = $request->query('locale');
if ($locale) {
return (string) $locale;
}
$acceptLanguage = $request->header('Accept-Language');
if (! $acceptLanguage) {
return self::DEFAULT_LOCALE;
}
// Le header peut lister plusieurs langues pondérées ("fr-FR,fr;q=0.9,en;q=0.8") : on ne garde que la première.
$firstLanguage = strtok($acceptLanguage, ',');
$languageTag = strtok($firstLanguage, ';');
return $languageTag !== false ? $languageTag : self::DEFAULT_LOCALE;
}
}
@@ -0,0 +1,26 @@
<?php
namespace Functional\Exercises\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Forme allégée d'un exercice pour les listes (GET /api/exercises).
*/
class ExerciseListResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'body_part' => $this->body_part,
'equipment' => $this->equipment,
'target' => $this->target,
'image_url' => $this->image_url,
'gif_url' => $this->gif_url,
];
}
}
@@ -0,0 +1,35 @@
<?php
namespace Functional\Exercises\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Détail complet d'un exercice (GET /api/exercises/{id}), instructions localisées.
*/
class ExerciseResource extends JsonResource
{
public function __construct($resource, private readonly string $locale)
{
parent::__construct($resource);
}
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'category' => $this->category,
'equipment' => $this->equipment,
'target' => $this->target,
'muscle_group' => $this->muscle_group,
'secondary_muscles' => $this->secondary_muscles ?? [],
'instructions' => $this->resource->instructionsFor($this->locale),
'image_url' => $this->image_url,
'gif_url' => $this->gif_url,
'attribution' => $this->attribution,
];
}
}
@@ -0,0 +1,101 @@
<?php
namespace Functional\Exercises\Models;
use Functional\Exercises\Database\Factories\ExerciseFactory;
use Illuminate\Database\Eloquent\Attributes\UseFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* Catalogue d'exercices (lecture seule, alimenté par l'import du dataset).
*
* @property string $external_id
* @property string $name
* @property array|null $secondary_muscles
* @property array|null $instructions
*/
#[UseFactory(ExerciseFactory::class)]
class Exercise extends Model
{
use HasFactory;
protected $fillable = [
'external_id', 'name', 'slug', 'category', 'body_part', 'equipment',
'target', 'muscle_group', 'secondary_muscles', 'instructions',
'image_path', 'gif_path', 'attribution',
];
protected $casts = [
'secondary_muscles' => 'array',
'instructions' => 'array',
];
protected $appends = ['image_url', 'gif_url'];
/** URL publique du thumbnail (média servi depuis RustFS/S3). */
public function imageUrl(): ?string
{
return $this->mediaUrl($this->image_path);
}
public function gifUrl(): ?string
{
return $this->mediaUrl($this->gif_path);
}
public function getImageUrlAttribute(): ?string
{
return $this->imageUrl();
}
public function getGifUrlAttribute(): ?string
{
return $this->gifUrl();
}
private function mediaUrl(?string $path): ?string
{
if (! $path) {
return null;
}
return rtrim((string) config('exercises.media_base_url'), '/') . '/' . ltrim($path, '/');
}
/**
* Instructions dans la locale demandée, avec repli.
* pt-BR n'existe pas dans le dataset repli en/fr.
*/
public function instructionsFor(string $locale): array
{
$data = $this->instructions ?? [];
$chain = [$locale, substr($locale, 0, 2), 'en', 'fr'];
foreach ($chain as $lang) {
if (! empty($data[$lang])) {
return (array) $data[$lang];
}
}
return [];
}
/** Recherche plein-texte simple (nom / muscle cible / équipement). */
public function scopeSearch(Builder $query, ?string $term): Builder
{
if (! $term) {
return $query;
}
$like = '%' . str_replace('%', '\%', $term) . '%';
return $query->where(function (Builder $q) use ($like) {
$q->where('name', 'like', $like)
->orWhere('target', 'like', $like)
->orWhere('equipment', 'like', $like)
->orWhere('body_part', 'like', $like);
});
}
}
@@ -0,0 +1,32 @@
<?php
namespace Functional\Exercises\Providers;
use Functional\Exercises\Console\Commands\ImportExercisesCommand;
use Functional\Exercises\Database\Seeders\ExercisesSeeder;
use Xefi\LaravelOSDD\LayerServiceProvider;
class ExercisesServiceProvider extends LayerServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
$this->loadSeeders([ExercisesSeeder::class]);
$this->commands([ImportExercisesCommand::class]);
}
$this->withRouting(
web: __DIR__ . '/../../routes/web.php',
api: __DIR__ . '/../../routes/api.php',
commands: __DIR__ . '/../../routes/console.php',
channels: __DIR__ . '/../../routes/channels.php',
);
$this->loadSeeders([\Functional\Exercises\Database\Seeders\ExercisesSeeder::class]);
}
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/exercises.php', 'exercises');
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "functional/programs",
"description": "",
"type": "layer",
"version": "1.0.0",
"require": {
"xefi/laravel-osdd": "*"
},
"autoload": {
"psr-4": {
"Functional\\Programs\\": "src/",
"Functional\\Programs\\Database\\Seeders\\": "database/seeders/",
"Functional\\Programs\\Database\\Factories\\": "database/factories/"
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"extra": {
"laravel": {
"providers": [
"Functional\\Programs\\Providers\\ProgramsServiceProvider"
]
}
}
}
@@ -0,0 +1,51 @@
<?php
namespace Functional\Programs\Database\Factories;
use Functional\Programs\Models\Program;
use Functional\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<Program>
*/
class ProgramFactory extends Factory
{
protected $model = Program::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$name = fake()->randomElement([
'Prise de masse 8 semaines', 'Sèche été', 'Préparation force',
'Remise en forme progressive', 'Cross-training intensif', 'Retour de blessure',
]) . ' ' . fake()->numberBetween(1, 9);
return [
'user_id' => User::factory(),
'name' => $name,
'slug' => Str::slug($name) . '-' . fake()->unique()->numberBetween(1000, 9999),
'description' => fake()->boolean(70) ? fake()->sentence(15) : null,
'duration_weeks' => fake()->numberBetween(4, 16),
'days_per_week' => fake()->optional(0.8)->numberBetween(2, 6),
'is_public' => fake()->boolean(25),
];
}
/** Programme partagé publiquement (visible du catalogue communautaire). */
public function public(): static
{
return $this->state(fn (array $attributes) => ['is_public' => true]);
}
/** Programme privé, réservé à son propriétaire. */
public function private(): static
{
return $this->state(fn (array $attributes) => ['is_public' => false]);
}
}
@@ -0,0 +1,41 @@
<?php
namespace Functional\Programs\Database\Factories;
use Functional\Programs\Models\Program;
use Functional\Programs\Models\ProgramSlot;
use Functional\Routines\Models\Routine;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<ProgramSlot>
*/
class ProgramSlotFactory extends Factory
{
protected $model = ProgramSlot::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'program_id' => Program::factory(),
'week' => fake()->numberBetween(1, 8),
'weekday' => fake()->numberBetween(1, 7),
'routine_id' => fake()->boolean(85) ? Routine::factory() : null,
'label' => fake()->optional(0.3)->randomElement(['Repos actif', 'Séance test', 'Deload']),
];
}
/** Case de repos, sans routine associée. */
public function restDay(): static
{
return $this->state(fn (array $attributes) => [
'routine_id' => null,
'label' => 'Repos',
]);
}
}
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('programs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('name');
$table->string('slug')->index();
$table->text('description')->nullable();
$table->unsignedInteger('duration_weeks');
$table->unsignedTinyInteger('days_per_week')->nullable();
$table->boolean('is_public')->default(false);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('programs');
}
};
@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Une case du planning : une semaine/jour donnée du programme, optionnellement liée à une routine.
Schema::create('program_slots', function (Blueprint $table) {
$table->id();
$table->foreignId('program_id')->constrained();
$table->unsignedInteger('week'); // 1..duration_weeks
$table->unsignedTinyInteger('weekday'); // 1 (lundi) .. 7 (dimanche)
$table->foreignId('routine_id')->nullable()->constrained();
$table->string('label')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('program_slots');
}
};
@@ -0,0 +1,17 @@
<?php
namespace Functional\Programs\Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class ProgramsSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
use Functional\Programs\Http\Controllers\ProgramsController;
use Illuminate\Support\Facades\Route;
// Routes here are wrapped in the 'api' middleware group with the 'api'
// prefix by the layer service provider. To version your endpoints
// (/api/v1/...), nest Route::prefix('v1')->group(...) inside.
// Scopés à l'utilisateur authentifié (auth()->id()).
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('programs', ProgramsController::class);
});

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