diff --git a/app/Enums/LegalDocumentType.php b/app/Enums/LegalDocumentType.php new file mode 100644 index 0000000..f29a480 --- /dev/null +++ b/app/Enums/LegalDocumentType.php @@ -0,0 +1,19 @@ + __('enums.legal_document_type.privacy_policy'), + self::TERMS => __('enums.legal_document_type.terms'), + }; + } +} diff --git a/app/Filament/Resources/LegalDocuments/LegalDocumentResource.php b/app/Filament/Resources/LegalDocuments/LegalDocumentResource.php new file mode 100644 index 0000000..9971395 --- /dev/null +++ b/app/Filament/Resources/LegalDocuments/LegalDocumentResource.php @@ -0,0 +1,88 @@ +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'), + ]; + } +} diff --git a/app/Filament/Resources/LegalDocuments/Pages/CreateLegalDocument.php b/app/Filament/Resources/LegalDocuments/Pages/CreateLegalDocument.php new file mode 100644 index 0000000..8392843 --- /dev/null +++ b/app/Filament/Resources/LegalDocuments/Pages/CreateLegalDocument.php @@ -0,0 +1,11 @@ +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(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/LegalDocuments/Schemas/LegalDocumentInfolist.php b/app/Filament/Resources/LegalDocuments/Schemas/LegalDocumentInfolist.php new file mode 100644 index 0000000..510c620 --- /dev/null +++ b/app/Filament/Resources/LegalDocuments/Schemas/LegalDocumentInfolist.php @@ -0,0 +1,63 @@ +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(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/LegalDocuments/Tables/LegalDocumentsTable.php b/app/Filament/Resources/LegalDocuments/Tables/LegalDocumentsTable.php new file mode 100644 index 0000000..71b5bcf --- /dev/null +++ b/app/Filament/Resources/LegalDocuments/Tables/LegalDocumentsTable.php @@ -0,0 +1,76 @@ +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(), + ]), + ]); + } +} diff --git a/app/Http/Controllers/LegalDocumentController.php b/app/Http/Controllers/LegalDocumentController.php new file mode 100644 index 0000000..9c9eca1 --- /dev/null +++ b/app/Http/Controllers/LegalDocumentController.php @@ -0,0 +1,34 @@ +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); + } +} diff --git a/app/Http/Resources/LegalDocumentResource.php b/app/Http/Resources/LegalDocumentResource.php new file mode 100644 index 0000000..07f9cdb --- /dev/null +++ b/app/Http/Resources/LegalDocumentResource.php @@ -0,0 +1,28 @@ + $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, + ]; + } +} diff --git a/app/Models/LegalDocument.php b/app/Models/LegalDocument.php new file mode 100644 index 0000000..0e70452 --- /dev/null +++ b/app/Models/LegalDocument.php @@ -0,0 +1,90 @@ + */ + 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', + ]; + } +} diff --git a/database/factories/LegalDocumentFactory.php b/database/factories/LegalDocumentFactory.php new file mode 100644 index 0000000..32ac0eb --- /dev/null +++ b/database/factories/LegalDocumentFactory.php @@ -0,0 +1,86 @@ + + */ +class LegalDocumentFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + 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 l’application 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 l’utilisation de Bowli.', + ], + 'sections' => [ + [ + 'title' => 'Compte utilisateur', + 'body' => [ + 'Tu es responsable des informations fournies et de l’utilisation 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, + ]); + } +} diff --git a/database/migrations/2026_05_24_105035_create_legal_documents_table.php b/database/migrations/2026_05_24_105035_create_legal_documents_table.php new file mode 100644 index 0000000..cc10cd5 --- /dev/null +++ b/database/migrations/2026_05_24_105035_create_legal_documents_table.php @@ -0,0 +1,38 @@ +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'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..fae673a 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,6 +15,8 @@ class DatabaseSeeder extends Seeder */ public function run(): void { + $this->call(LegalDocumentSeeder::class); + // User::factory(10)->create(); User::factory()->create([ diff --git a/database/seeders/LegalDocumentSeeder.php b/database/seeders/LegalDocumentSeeder.php new file mode 100644 index 0000000..b845dec --- /dev/null +++ b/database/seeders/LegalDocumentSeeder.php @@ -0,0 +1,126 @@ +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 l’application mobile Bowli.', + 'Elle est destinée aux utilisateurs de l’application 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 d’authentification, 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 l’analyse d’image, la photo du repas peut être transmise à un fournisseur d’intelligence 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 l’application.', + ], + ], + [ + '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 d’ajouter une image à un repas ou à ton profil.', + ], + ], + [ + 'title' => 'Suppression et droits', + 'body' => [ + 'Tu peux supprimer ton compte depuis l’application ou demander la suppression par email au support. Selon ta localisation, tu peux disposer de droits d’accès, de rectification, d’opposition, de limitation, d’effacement 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 l’utilisation de Bowli, une application de suivi alimentaire et sportif.', + 'En créant un compte ou en utilisant l’application, tu acceptes ces conditions dans leur version active.', + ], + 'sections' => [ + [ + 'title' => 'Compte utilisateur', + 'body' => [ + 'Tu dois fournir des informations exactes lors de l’inscription, garder ton accès confidentiel et nous prévenir en cas d’utilisation non autorisée.', + ], + ], + [ + 'title' => 'Usage personnel', + 'body' => [ + 'Bowli est conçu pour un suivi personnel des repas, objectifs et activités sportives. L’application 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 t’engages à 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 l’application. Une procédure web est aussi disponible pour les utilisateurs qui n’ont plus accès à l’application.', + ], + ], + ], + ], + ], + 'is_active' => true, + 'published_at' => now(), + ], + ); + } +} diff --git a/lang/en/admin.php b/lang/en/admin.php index 2c4f05d..18e9862 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -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' => '-', + ], + ], ]; diff --git a/lang/en/api.php b/lang/en/api.php index b8c28f6..b360204 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -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.', diff --git a/lang/en/enums.php b/lang/en/enums.php index 420dfd1..472d6cc 100644 --- a/lang/en/enums.php +++ b/lang/en/enums.php @@ -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', diff --git a/lang/fr/admin.php b/lang/fr/admin.php index 352afd8..0d30099 100644 --- a/lang/fr/admin.php +++ b/lang/fr/admin.php @@ -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' => 'L’API 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 d’introduction', + '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' => '-', + ], + ], ]; diff --git a/lang/fr/api.php b/lang/fr/api.php index 77fa318..3732f12 100644 --- a/lang/fr/api.php +++ b/lang/fr/api.php @@ -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.', diff --git a/lang/fr/enums.php b/lang/fr/enums.php index 00788af..0dc30af 100644 --- a/lang/fr/enums.php +++ b/lang/fr/enums.php @@ -45,6 +45,10 @@ return [ 'resolved' => 'Résolu', 'rejected' => 'Rejeté', ], + 'legal_document_type' => [ + 'privacy_policy' => 'Politique de confidentialité', + 'terms' => 'Conditions d’utilisation', + ], 'weight_goal' => [ 'lose_weight' => 'Perdre du poids', 'maintain_weight' => 'Maintenir le poids', diff --git a/routes/api.php b/routes/api.php index 024550e..940f6c2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); diff --git a/tests/Feature/LegalDocumentApiTest.php b/tests/Feature/LegalDocumentApiTest.php new file mode 100644 index 0000000..5f82ea5 --- /dev/null +++ b/tests/Feature/LegalDocumentApiTest.php @@ -0,0 +1,171 @@ +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 d’utilisation', + ], + ) + ->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(); +});