feat: legal sections

This commit is contained in:
Leon Morival 2026-05-24 13:20:04 +02:00
parent 3a74c2c34d
commit 8b162c8e4c
24 changed files with 1145 additions and 0 deletions

View File

@ -0,0 +1,19 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasLabel;
enum LegalDocumentType: string implements HasLabel
{
case PRIVACY_POLICY = 'privacy_policy';
case TERMS = 'terms';
public function getLabel(): string
{
return match ($this) {
self::PRIVACY_POLICY => __('enums.legal_document_type.privacy_policy'),
self::TERMS => __('enums.legal_document_type.terms'),
};
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Filament\Resources\LegalDocuments;
use App\Filament\Resources\LegalDocuments\Pages\CreateLegalDocument;
use App\Filament\Resources\LegalDocuments\Pages\EditLegalDocument;
use App\Filament\Resources\LegalDocuments\Pages\ListLegalDocuments;
use App\Filament\Resources\LegalDocuments\Pages\ViewLegalDocument;
use App\Filament\Resources\LegalDocuments\Schemas\LegalDocumentForm;
use App\Filament\Resources\LegalDocuments\Schemas\LegalDocumentInfolist;
use App\Filament\Resources\LegalDocuments\Tables\LegalDocumentsTable;
use App\Models\LegalDocument;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class LegalDocumentResource extends Resource
{
protected static ?string $model = LegalDocument::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentText;
public static function getNavigationLabel(): string
{
return __('admin.legal_documents.navigation.label');
}
public static function getModelLabel(): string
{
return __('admin.legal_documents.navigation.singular');
}
public static function getPluralModelLabel(): string
{
return __('admin.legal_documents.navigation.plural');
}
public static function form(Schema $schema): Schema
{
return LegalDocumentForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return LegalDocumentInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return LegalDocumentsTable::configure($table);
}
public static function createNewVersionAction(): Action
{
return Action::make('createNewVersion')
->label(__('admin.legal_documents.actions.create_new_version'))
->icon(Heroicon::OutlinedDocumentDuplicate)
->requiresConfirmation()
->modalDescription(__('admin.legal_documents.actions.create_new_version_confirmation'))
->successNotificationTitle(__('admin.legal_documents.actions.create_new_version_success'))
->action(function (LegalDocument $record, Action $action): void {
$newRecord = $record->createNextVersion();
$action->success();
$action->redirect(self::getUrl('edit', ['record' => $newRecord]));
});
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListLegalDocuments::route('/'),
'create' => CreateLegalDocument::route('/create'),
'view' => ViewLegalDocument::route('/{record}'),
'edit' => EditLegalDocument::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\LegalDocuments\Pages;
use App\Filament\Resources\LegalDocuments\LegalDocumentResource;
use Filament\Resources\Pages\CreateRecord;
class CreateLegalDocument extends CreateRecord
{
protected static string $resource = LegalDocumentResource::class;
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Filament\Resources\LegalDocuments\Pages;
use App\Filament\Resources\LegalDocuments\LegalDocumentResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\ViewAction;
use Filament\Resources\Pages\EditRecord;
class EditLegalDocument extends EditRecord
{
protected static string $resource = LegalDocumentResource::class;
protected function getHeaderActions(): array
{
return [
ViewAction::make(),
LegalDocumentResource::createNewVersionAction(),
DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\LegalDocuments\Pages;
use App\Filament\Resources\LegalDocuments\LegalDocumentResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListLegalDocuments extends ListRecords
{
protected static string $resource = LegalDocumentResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\LegalDocuments\Pages;
use App\Filament\Resources\LegalDocuments\LegalDocumentResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewLegalDocument extends ViewRecord
{
protected static string $resource = LegalDocumentResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
LegalDocumentResource::createNewVersionAction(),
];
}
}

View File

@ -0,0 +1,115 @@
<?php
namespace App\Filament\Resources\LegalDocuments\Schemas;
use App\Enums\LegalDocumentType;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class LegalDocumentForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make(__('admin.legal_documents.sections.identity'))
->columns(3)
->schema([
TextInput::make('slug')
->label(__('admin.legal_documents.fields.slug'))
->required()
->maxLength(255)
->helperText(__('admin.legal_documents.helpers.slug')),
Select::make('type')
->label(__('admin.legal_documents.fields.type'))
->options(LegalDocumentType::class)
->required(),
TextInput::make('version')
->label(__('admin.legal_documents.fields.version'))
->integer()
->minValue(1)
->default(1)
->required(),
]),
Section::make(__('admin.legal_documents.sections.publication'))
->columns(2)
->schema([
Toggle::make('is_active')
->label(__('admin.legal_documents.fields.is_active'))
->helperText(__('admin.legal_documents.helpers.is_active')),
DateTimePicker::make('published_at')
->label(__('admin.legal_documents.fields.published_at'))
->seconds(false)
->helperText(__('admin.legal_documents.helpers.published_at')),
]),
Section::make(__('admin.legal_documents.sections.content'))
->schema([
TextInput::make('title.fr')
->label(__('admin.legal_documents.fields.title'))
->default('')
->maxLength(255)
->required()
->columnSpanFull(),
Repeater::make('content.fr.intro')
->label(__('admin.legal_documents.fields.intro'))
->simple(
Textarea::make('paragraph')
->label(__('admin.legal_documents.fields.paragraph'))
->rows(3)
->required()
)
->default([
__('admin.legal_documents.defaults.intro'),
])
->addActionLabel(__('admin.legal_documents.actions.add_intro_paragraph'))
->reorderable()
->required()
->columnSpanFull(),
Repeater::make('content.fr.sections')
->label(__('admin.legal_documents.fields.sections'))
->schema([
TextInput::make('title')
->label(__('admin.legal_documents.fields.section_title'))
->default(__('admin.legal_documents.defaults.section_title'))
->required()
->maxLength(255),
Repeater::make('body')
->label(__('admin.legal_documents.fields.section_body'))
->simple(
Textarea::make('paragraph')
->label(__('admin.legal_documents.fields.paragraph'))
->rows(3)
->required()
)
->default([
__('admin.legal_documents.defaults.paragraph'),
])
->addActionLabel(__('admin.legal_documents.actions.add_section_paragraph'))
->reorderable()
->required()
->columnSpanFull(),
])
->default([
[
'title' => __('admin.legal_documents.defaults.section_title'),
'body' => [
__('admin.legal_documents.defaults.paragraph'),
],
],
])
->itemLabel(fn (array $state): ?string => $state['title'] ?? null)
->addActionLabel(__('admin.legal_documents.actions.add_section'))
->collapsible()
->reorderable()
->required()
->columnSpanFull(),
]),
]);
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Filament\Resources\LegalDocuments\Schemas;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class LegalDocumentInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make(__('admin.legal_documents.sections.identity'))
->columns(3)
->schema([
TextEntry::make('id')
->label(__('admin.legal_documents.fields.id'))
->copyable(),
TextEntry::make('slug')
->label(__('admin.legal_documents.fields.slug'))
->copyable(),
TextEntry::make('type')
->label(__('admin.legal_documents.fields.type'))
->badge(),
TextEntry::make('version')
->label(__('admin.legal_documents.fields.version')),
TextEntry::make('is_active')
->label(__('admin.legal_documents.fields.is_active'))
->badge()
->formatStateUsing(fn (bool $state): string => $state
? __('admin.legal_documents.status.active')
: __('admin.legal_documents.status.inactive')),
TextEntry::make('published_at')
->label(__('admin.legal_documents.fields.published_at'))
->dateTime()
->placeholder(__('admin.legal_documents.placeholders.empty')),
]),
Section::make(__('admin.legal_documents.sections.content'))
->schema([
KeyValueEntry::make('title')
->label(__('admin.legal_documents.fields.title')),
TextEntry::make('content')
->label(__('admin.legal_documents.fields.content'))
->formatStateUsing(fn (mixed $state): string => json_encode($state ?: [], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}')
->fontFamily('mono')
->columnSpanFull(),
]),
Section::make(__('admin.legal_documents.sections.metadata'))
->columns(2)
->schema([
TextEntry::make('created_at')
->label(__('admin.legal_documents.fields.created_at'))
->dateTime(),
TextEntry::make('updated_at')
->label(__('admin.legal_documents.fields.updated_at'))
->dateTime(),
]),
]);
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Filament\Resources\LegalDocuments\Tables;
use App\Enums\LegalDocumentType;
use App\Filament\Resources\LegalDocuments\LegalDocumentResource;
use App\Models\LegalDocument;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
class LegalDocumentsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('id')
->label(__('admin.legal_documents.fields.id'))
->copyable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('slug')
->label(__('admin.legal_documents.fields.slug'))
->searchable()
->sortable(),
TextColumn::make('type')
->label(__('admin.legal_documents.fields.type'))
->badge()
->sortable(),
TextColumn::make('version')
->label(__('admin.legal_documents.fields.version'))
->numeric()
->sortable(),
TextColumn::make('localized_title')
->label(__('admin.legal_documents.fields.localized_title'))
->state(fn (LegalDocument $record): string => (string) ($record->title['fr'] ?? collect($record->title)->first() ?? __('admin.legal_documents.placeholders.empty'))),
IconColumn::make('is_active')
->label(__('admin.legal_documents.fields.is_active'))
->boolean()
->sortable(),
TextColumn::make('published_at')
->label(__('admin.legal_documents.fields.published_at'))
->dateTime()
->sortable()
->placeholder(__('admin.legal_documents.placeholders.empty')),
TextColumn::make('updated_at')
->label(__('admin.legal_documents.fields.updated_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('type')
->label(__('admin.legal_documents.fields.type'))
->options(LegalDocumentType::class),
TernaryFilter::make('is_active')
->label(__('admin.legal_documents.fields.is_active')),
])
->recordActions([
ViewAction::make(),
EditAction::make(),
LegalDocumentResource::createNewVersionAction(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\LegalDocumentResource;
use App\Models\LegalDocument;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class LegalDocumentController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$query = LegalDocument::query()
->public()
->latest('published_at')
->latest('version');
if ($request->filled('type')) {
$query->where('type', $request->string('type')->toString());
}
return LegalDocumentResource::collection($query->get());
}
public function show(string $slug): LegalDocumentResource
{
$document = LegalDocument::publicForSlug($slug);
abort_if(! $document, 404, __('api.legal_documents.not_found'));
return new LegalDocumentResource($document);
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Resources;
use App\Models\LegalDocument;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin LegalDocument */
class LegalDocumentResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'slug' => $this->slug,
'type' => $this->type?->value,
'typeLabel' => $this->type?->getLabel(),
'version' => $this->version,
'title' => $this->title,
'content' => $this->content,
'isActive' => $this->is_active,
'publishedAt' => $this->published_at,
'createdAt' => $this->created_at,
'updatedAt' => $this->updated_at,
];
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace App\Models;
use App\Enums\LegalDocumentType;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class LegalDocument extends Model
{
/** @use HasFactory<\Database\Factories\LegalDocumentFactory> */
use HasFactory;
protected $fillable = [
'slug',
'type',
'version',
'title',
'content',
'is_active',
'published_at',
];
public function scopePublic(Builder $query): Builder
{
return $query
->where('is_active', true)
->whereNotNull('published_at')
->where('published_at', '<=', now());
}
public function scopeForSlug(Builder $query, string $slug): Builder
{
return $query->where('slug', $slug);
}
public static function publicForSlug(string $slug): ?self
{
return self::query()
->public()
->forSlug($slug)
->latest('version')
->first();
}
public function createNextVersion(): self
{
$nextVersion = ((int) self::query()
->where('slug', $this->slug)
->max('version')) + 1;
return self::query()->create([
'slug' => $this->slug,
'type' => $this->type,
'version' => $nextVersion,
'title' => $this->title,
'content' => $this->content,
'is_active' => false,
'published_at' => null,
]);
}
protected static function booted(): void
{
static::saved(function (LegalDocument $document): void {
if (! $document->is_active) {
return;
}
static::query()
->where('slug', $document->slug)
->whereKeyNot($document->getKey())
->where('is_active', true)
->update(['is_active' => false]);
});
}
protected function casts(): array
{
return [
'type' => LegalDocumentType::class,
'version' => 'integer',
'title' => 'array',
'content' => 'array',
'is_active' => 'boolean',
'published_at' => 'datetime',
];
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace Database\Factories;
use App\Enums\LegalDocumentType;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\LegalDocument>
*/
class LegalDocumentFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'slug' => 'privacy-policy',
'type' => LegalDocumentType::PRIVACY_POLICY,
'version' => 1,
'title' => [
'fr' => 'Politique de confidentialité',
],
'content' => [
'fr' => [
'intro' => [
'Cette politique explique comment Bowli traite les données de lapplication mobile.',
],
'sections' => [
[
'title' => 'Données collectées',
'body' => [
'Bowli traite les données nécessaires au fonctionnement du compte et du suivi nutritionnel.',
],
],
],
],
],
'is_active' => true,
'published_at' => now(),
];
}
public function terms(): static
{
return $this->state(fn (): array => [
'slug' => 'terms-of-service',
'type' => LegalDocumentType::TERMS,
'title' => [
'fr' => "Conditions d'utilisation",
],
'content' => [
'fr' => [
'intro' => [
'Ces conditions encadrent lutilisation de Bowli.',
],
'sections' => [
[
'title' => 'Compte utilisateur',
'body' => [
'Tu es responsable des informations fournies et de lutilisation de ton compte.',
],
],
],
],
],
]);
}
public function inactive(): static
{
return $this->state(fn (): array => [
'is_active' => false,
]);
}
public function unpublished(): static
{
return $this->state(fn (): array => [
'published_at' => null,
]);
}
}

View File

@ -0,0 +1,38 @@
<?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('legal_documents', function (Blueprint $table) {
$table->id();
$table->string('slug');
$table->string('type', 64);
$table->unsignedInteger('version');
$table->json('title');
$table->json('content');
$table->boolean('is_active')->default(false);
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->unique(['slug', 'version']);
$table->index(['slug', 'is_active', 'published_at']);
$table->index(['type', 'is_active', 'published_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('legal_documents');
}
};

View File

@ -15,6 +15,8 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
$this->call(LegalDocumentSeeder::class);
// User::factory(10)->create();
User::factory()->create([

View File

@ -0,0 +1,126 @@
<?php
namespace Database\Seeders;
use App\Enums\LegalDocumentType;
use App\Models\LegalDocument;
use Illuminate\Database\Seeder;
class LegalDocumentSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
LegalDocument::query()->updateOrCreate(
['slug' => 'privacy-policy', 'version' => 1],
[
'type' => LegalDocumentType::PRIVACY_POLICY,
'title' => [
'fr' => 'Politique de confidentialité',
],
'content' => [
'fr' => [
'intro' => [
'Cette politique explique comment Bowli traite les données utilisées par lapplication mobile Bowli.',
'Elle est destinée aux utilisateurs de lapplication et aux fiches App Store / Google Play.',
],
'sections' => [
[
'title' => 'Données de compte',
'body' => [
'Bowli traite les informations nécessaires à la création et à la sécurisation du compte : adresse email, nom, avatar, mot de passe chiffré, jetons dauthentification, statut de vérification, préférences de profil et réglages.',
],
],
[
'title' => 'Repas, photos et nutrition',
'body' => [
'Les repas, photos, ingrédients, calories, protéines, glucides, lipides, légendes, avis et statuts de visibilité servent à afficher ton journal, tes statistiques, ton profil et les contenus que tu choisis de publier.',
'Si tu utilises lanalyse dimage, la photo du repas peut être transmise à un fournisseur dintelligence artificielle configuré par Bowli afin de produire un brouillon nutritionnel éditable.',
],
],
[
'title' => 'Activités sportives et Strava',
'body' => [
'Si tu connectes Strava, Bowli demande les autorisations nécessaires pour lire ton profil et tes activités sportives, puis importer les séances utiles à ton suivi.',
'Les jetons Strava sont stockés chiffrés côté API. Tu peux révoquer cette connexion depuis lapplication.',
],
],
[
'title' => 'Notifications et appareil',
'body' => [
'Si tu actives les notifications, Bowli enregistre un token Expo Push pour envoyer des notifications liées à ton compte.',
'La caméra et la bibliothèque photo ne sont utilisées que lorsque tu choisis dajouter une image à un repas ou à ton profil.',
],
],
[
'title' => 'Suppression et droits',
'body' => [
'Tu peux supprimer ton compte depuis lapplication ou demander la suppression par email au support. Selon ta localisation, tu peux disposer de droits daccès, de rectification, dopposition, de limitation, deffacement et de portabilité.',
],
],
],
],
],
'is_active' => true,
'published_at' => now(),
],
);
LegalDocument::query()->updateOrCreate(
['slug' => 'terms-of-service', 'version' => 1],
[
'type' => LegalDocumentType::TERMS,
'title' => [
'fr' => "Conditions d'utilisation",
],
'content' => [
'fr' => [
'intro' => [
'Ces conditions encadrent lutilisation de Bowli, une application de suivi alimentaire et sportif.',
'En créant un compte ou en utilisant lapplication, tu acceptes ces conditions dans leur version active.',
],
'sections' => [
[
'title' => 'Compte utilisateur',
'body' => [
'Tu dois fournir des informations exactes lors de linscription, garder ton accès confidentiel et nous prévenir en cas dutilisation non autorisée.',
],
],
[
'title' => 'Usage personnel',
'body' => [
'Bowli est conçu pour un suivi personnel des repas, objectifs et activités sportives. Lapplication ne fournit pas de diagnostic, prescription ou conseil médical.',
'Les calories, macronutriments et estimations automatiques sont indicatifs.',
],
],
[
'title' => 'Contenus publiés',
'body' => [
'Tu conserves tes droits sur les photos, repas, avis et informations que tu ajoutes. Tu accordes à Bowli les droits nécessaires pour héberger, afficher, traiter et partager ces contenus selon tes réglages de visibilité.',
'Tu tengages à ne pas publier de contenu illégal, trompeur, haineux, violent, discriminatoire, attentatoire à la vie privée ou aux droits de tiers.',
],
],
[
'title' => 'Modération et disponibilité',
'body' => [
'Bowli peut masquer, supprimer ou limiter la diffusion de contenus qui enfreignent ces conditions, les règles des stores ou la loi applicable.',
'Le service peut être interrompu pour maintenance, incident, évolution technique ou contrainte externe.',
],
],
[
'title' => 'Suppression du compte',
'body' => [
'Tu peux supprimer ton compte depuis les réglages de lapplication. Une procédure web est aussi disponible pour les utilisateurs qui nont plus accès à lapplication.',
],
],
],
],
],
'is_active' => true,
'published_at' => now(),
],
);
}
}

View File

@ -115,4 +115,62 @@ return [
'empty' => '-',
],
],
'legal_documents' => [
'navigation' => [
'label' => 'Legal documents',
'singular' => 'Legal document',
'plural' => 'Legal documents',
],
'sections' => [
'identity' => 'Identity',
'publication' => 'Publication',
'content' => 'Content',
'metadata' => 'Metadata',
],
'fields' => [
'id' => 'ID',
'slug' => 'Slug',
'type' => 'Type',
'version' => 'Version',
'title' => 'Title',
'locale' => 'Locale',
'localized_title' => 'Localized title',
'content' => 'Content JSON',
'intro' => 'Introduction',
'paragraph' => 'Paragraph',
'sections' => 'Sections',
'section_title' => 'Section title',
'section_body' => 'Section paragraphs',
'is_active' => 'Active',
'published_at' => 'Published at',
'created_at' => 'Created at',
'updated_at' => 'Updated at',
],
'helpers' => [
'slug' => 'Public identifier used by the landing site, e.g. privacy-policy or terms-of-service.',
'is_active' => 'When enabled, the other active versions with the same slug are automatically deactivated.',
'published_at' => 'The public API only exposes active documents whose publication date is set and not in the future.',
'content' => 'Expected shape: {"fr":{"intro":["..."],"sections":[{"title":"...","body":["..."]}]}}',
],
'defaults' => [
'intro' => 'Introductory text for the document.',
'section_title' => 'Section title',
'paragraph' => 'First paragraph.',
],
'actions' => [
'add_intro_paragraph' => 'Add an intro paragraph',
'add_section' => 'Add a section',
'add_section_paragraph' => 'Add a paragraph',
'create_new_version' => 'Create new version',
'create_new_version_confirmation' => 'This will duplicate the content into a new inactive unpublished version.',
'create_new_version_success' => 'New version created.',
],
'status' => [
'active' => 'Active',
'inactive' => 'Inactive',
],
'placeholders' => [
'empty' => '-',
],
],
];

View File

@ -26,6 +26,9 @@ return [
'meal_image_analysis' => [
'failed' => 'Unable to analyze the image right now.',
],
'legal_documents' => [
'not_found' => 'Legal document not found.',
],
'notifications' => [
'test_title' => 'Test',
'test_body' => 'Test notification from the Laravel API.',

View File

@ -45,6 +45,10 @@ return [
'resolved' => 'Resolved',
'rejected' => 'Rejected',
],
'legal_document_type' => [
'privacy_policy' => 'Privacy policy',
'terms' => 'Terms of service',
],
'weight_goal' => [
'lose_weight' => 'Lose weight',
'maintain_weight' => 'Maintain weight',

View File

@ -115,4 +115,62 @@ return [
'empty' => '-',
],
],
'legal_documents' => [
'navigation' => [
'label' => 'Documents légaux',
'singular' => 'Document légal',
'plural' => 'Documents légaux',
],
'sections' => [
'identity' => 'Identité',
'publication' => 'Publication',
'content' => 'Contenu',
'metadata' => 'Métadonnées',
],
'fields' => [
'id' => 'ID',
'slug' => 'Slug',
'type' => 'Type',
'version' => 'Version',
'title' => 'Titre',
'locale' => 'Locale',
'localized_title' => 'Titre localisé',
'content' => 'Contenu JSON',
'intro' => 'Introduction',
'paragraph' => 'Paragraphe',
'sections' => 'Sections',
'section_title' => 'Titre de section',
'section_body' => 'Paragraphes de la section',
'is_active' => 'Actif',
'published_at' => 'Publié le',
'created_at' => 'Créé le',
'updated_at' => 'Mis à jour le',
],
'helpers' => [
'slug' => 'Identifiant public utilisé par le site landing, par exemple privacy-policy ou terms-of-service.',
'is_active' => 'Quand ce champ est activé, les autres versions actives avec le même slug sont automatiquement désactivées.',
'published_at' => 'LAPI publique expose uniquement les documents actifs avec une date de publication renseignée et non future.',
'content' => 'Format attendu : {"fr":{"intro":["..."],"sections":[{"title":"...","body":["..."]}]}}',
],
'defaults' => [
'intro' => 'Texte introductif du document.',
'section_title' => 'Titre de section',
'paragraph' => 'Premier paragraphe.',
],
'actions' => [
'add_intro_paragraph' => 'Ajouter un paragraphe dintroduction',
'add_section' => 'Ajouter une section',
'add_section_paragraph' => 'Ajouter un paragraphe',
'create_new_version' => 'Créer une nouvelle version',
'create_new_version_confirmation' => 'Cette action duplique le contenu dans une nouvelle version inactive et non publiée.',
'create_new_version_success' => 'Nouvelle version créée.',
],
'status' => [
'active' => 'Actif',
'inactive' => 'Inactif',
],
'placeholders' => [
'empty' => '-',
],
],
];

View File

@ -26,6 +26,9 @@ return [
'meal_image_analysis' => [
'failed' => "Impossible d'analyser l'image pour le moment.",
],
'legal_documents' => [
'not_found' => 'Document légal introuvable.',
],
'notifications' => [
'test_title' => 'Test',
'test_body' => 'Notification test depuis Laravel API.',

View File

@ -45,6 +45,10 @@ return [
'resolved' => 'Résolu',
'rejected' => 'Rejeté',
],
'legal_document_type' => [
'privacy_policy' => 'Politique de confidentialité',
'terms' => 'Conditions dutilisation',
],
'weight_goal' => [
'lose_weight' => 'Perdre du poids',
'maintain_weight' => 'Maintenir le poids',

View File

@ -2,6 +2,7 @@
use App\Http\Controllers\AuthController;
use App\Http\Controllers\DeviceTokenController;
use App\Http\Controllers\LegalDocumentController;
use App\Http\Controllers\MealImageAnalysisController;
use App\Http\Controllers\MealPostController;
use App\Http\Controllers\PostReviewsController;
@ -43,6 +44,12 @@ Route::prefix('auth')->group(function (): void {
Route::get('/health', function () {
return response()->json(['status' => 'ok']);
});
Route::prefix('legal-documents')->group(function (): void {
Route::get('/', [LegalDocumentController::class, 'index'])->name('legal-documents.index');
Route::get('{slug}', [LegalDocumentController::class, 'show'])->name('legal-documents.show');
});
Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void {
Route::post('device-tokens', [DeviceTokenController::class, 'store'])->middleware('throttle:device-token');
Route::delete('device-tokens', [DeviceTokenController::class, 'destroy'])->middleware('throttle:device-token');

View File

@ -0,0 +1,171 @@
<?php
use App\Enums\LegalDocumentType;
use App\Enums\UserRole;
use App\Filament\Resources\LegalDocuments\Pages\EditLegalDocument;
use App\Filament\Resources\LegalDocuments\Pages\ListLegalDocuments;
use App\Models\LegalDocument;
use App\Models\User;
use Filament\Facades\Filament;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\TextInput;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('returns the active published legal document by slug', function (): void {
LegalDocument::factory()->create([
'slug' => 'privacy-policy',
'version' => 1,
'is_active' => false,
]);
LegalDocument::factory()->create([
'slug' => 'privacy-policy',
'version' => 2,
'is_active' => true,
'published_at' => now(),
'title' => [
'fr' => 'Confidentialité Bowli',
],
]);
$this
->getJson('/api/legal-documents/privacy-policy')
->assertOk()
->assertJsonPath('data.slug', 'privacy-policy')
->assertJsonPath('data.type', LegalDocumentType::PRIVACY_POLICY->value)
->assertJsonPath('data.version', 2)
->assertJsonPath('data.title.fr', 'Confidentialité Bowli')
->assertJsonPath('data.isActive', true);
});
it('does not expose inactive or unpublished legal documents', function (): void {
LegalDocument::factory()->inactive()->create([
'slug' => 'privacy-policy',
]);
LegalDocument::factory()->unpublished()->create([
'slug' => 'terms-of-service',
]);
$this->getJson('/api/legal-documents/privacy-policy')->assertNotFound();
$this->getJson('/api/legal-documents/terms-of-service')->assertNotFound();
});
it('lists active published legal documents with an optional type filter', function (): void {
LegalDocument::factory()->create([
'slug' => 'privacy-policy',
'type' => LegalDocumentType::PRIVACY_POLICY,
]);
LegalDocument::factory()->terms()->create([
'slug' => 'terms-of-service',
'type' => LegalDocumentType::TERMS,
]);
$this
->getJson('/api/legal-documents?type=terms')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.slug', 'terms-of-service')
->assertJsonPath('data.0.type', LegalDocumentType::TERMS->value);
});
it('keeps only one active version per slug', function (): void {
$firstVersion = LegalDocument::factory()->create([
'slug' => 'privacy-policy',
'version' => 1,
'is_active' => true,
]);
LegalDocument::factory()->create([
'slug' => 'privacy-policy',
'version' => 2,
'is_active' => true,
]);
expect($firstVersion->fresh()->is_active)->toBeFalse();
});
it('exposes legal documents in filament', function (): void {
app()->setLocale('fr');
Filament::setCurrentPanel(Filament::getPanel('dashboard'));
$admin = User::factory()->create([
'role' => UserRole::ADMIN,
]);
$document = LegalDocument::factory()->create([
'slug' => 'privacy-policy',
]);
$this->actingAs($admin);
Livewire::test(ListLegalDocuments::class)
->assertTableColumnExists(
'slug',
fn (TextColumn $column): bool => $column->getLabel() === 'Slug',
)
->assertTableFilterExists(
'type',
fn (SelectFilter $filter): bool => $filter->getLabel() === 'Type'
&& $filter->getOptions() === [
'privacy_policy' => 'Politique de confidentialité',
'terms' => 'Conditions dutilisation',
],
)
->assertSee('privacy-policy');
Livewire::test(EditLegalDocument::class, ['record' => $document->getKey()])
->assertFormFieldExists(
'title.fr',
null,
fn (TextInput $field): bool => $field->getLabel() === 'Titre',
)
->assertFormFieldExists(
'content.fr.intro',
null,
fn (Repeater $field): bool => $field->getLabel() === 'Introduction',
)
->assertFormFieldExists(
'content.fr.sections',
null,
fn (Repeater $field): bool => $field->getLabel() === 'Sections',
);
});
it('creates a new inactive unpublished version from filament', function (): void {
app()->setLocale('fr');
Filament::setCurrentPanel(Filament::getPanel('dashboard'));
$admin = User::factory()->create([
'role' => UserRole::ADMIN,
]);
$document = LegalDocument::factory()->create([
'slug' => 'privacy-policy',
'version' => 3,
'is_active' => true,
'published_at' => now(),
]);
$this->actingAs($admin);
Livewire::test(ListLegalDocuments::class)
->callTableAction('createNewVersion', $document);
$newVersion = LegalDocument::query()
->where('slug', 'privacy-policy')
->where('version', 4)
->firstOrFail();
expect($newVersion->title)->toBe($document->title)
->and($newVersion->content)->toBe($document->content)
->and($newVersion->is_active)->toBeFalse()
->and($newVersion->published_at)->toBeNull();
});