diff --git a/.cursor/skills/align-docs-with-source/SKILL.md b/.cursor/skills/align-docs-with-source/SKILL.md new file mode 100644 index 00000000..8597f0bc --- /dev/null +++ b/.cursor/skills/align-docs-with-source/SKILL.md @@ -0,0 +1,40 @@ +--- +name: align-docs-with-source +description: >- + Aligns documentation under docs/ with the current Rails codebase (models, + schema, services, specs). Use when syncing docs after refactors, before + merge to dev/main, or when the user asks to reconcile docs/ with source. +disable-model-invocation: true +--- + +# Align docs with source (Le Circographe) + +## Goal + +Keep Markdown under `docs/` consistent with **running code**, not aspirational text. Prefer **minimal edits** and **existing files** (no new docs unless the user asks). + +## Sources of truth (read first) + +1. `db/schema.rb` — columns, nullability, FKs. +2. Relevant `app/models/*.rb` (especially `User`, `Person`, `Payment`, `PaymentLine`). +3. Orchestration: `app/services/people/*.rb`, `app/services/account_claim_management/*.rb`. +4. Project lexicon: `docs/glossary.md`, `docs/migrations/vocabulary_migration.md`. +5. Cursor rules if identity/testing changed: `.cursor/rules/naming-rules.mdc`, `testing-auth-rules.mdc`. + +## Checklist + +- [ ] **Headers**: bump `Dernière vérification` on every touched public doc (`stable`). +- [ ] **User ↔ Person**: every `User` has a `Person` (`person_id` NOT NULL); a `Person` may have zero or one `User`. Linking via `People::AttachUserToPerson` / `People::AccountLinker`, not ad hoc controller assigns. +- [ ] **Payments / donations**: describe what `People::PaymentCreator` actually persists (`item_type` for donations); separate **legacy DB rows** from **current code** (see `docs/payments.md`, migrations `*donation*`). +- [ ] **Legacy vocabulary**: keep Ruby names (`BookOfEntry`, `SubscriptionPlan`, …) with **(cible : …)** per glossary — do not rename models in prose alone. +- [ ] **Cross-links**: `docs/README.md` index stays consistent with edited pages. + +## Anti-patterns + +- Copy-pasting old paragraphs after a refactor without grepping `app/`. +- Stating « optional User » where the DB requires `person_id`. +- Documenting `dependent: :nullify` on `Person` ↔ `User` if code uses `restrict_with_error`. + +## Verification + +After edits: quick grep in `docs/` for stale phrases (`User sans Person`, `réécrit.*Payment`, `dependent: :nullify` on User) and fix or contextualize. diff --git a/.rubocop.yml b/.rubocop.yml index 17d09792..0b50b51a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -19,6 +19,7 @@ AllCops: - 'vendor/**/*' - 'tmp/**/*' - '.bundle/**/*' + - 'test/**/*' # Ezam override kept on purpose (not enforced by Omakase) Layout/EndOfLine: @@ -27,6 +28,8 @@ Layout/EndOfLine: # Phase 2 Batch 1 — LOW: Gemfile gem order within groups (non-domain) Bundler/OrderedGems: Enabled: true +Bundler/DuplicatedGem: + Enabled: true # Lot B2 — Omakase disables the Rails department by default except a few test-only # cops (e.g. AssertNot). We selectively enable high-value Rails cops here; add @@ -43,7 +46,27 @@ Rails/PluralizationGrammar: Enabled: true Rails/UniqBeforePluck: Enabled: true +Rails/Pluck: + Enabled: true +Rails/WhereEquals: + Enabled: true +Rails/Present: + Enabled: true +Rails/SkipsModelValidations: + Enabled: true + Exclude: + - 'docs/rake_archive/**/*.rake' Rails/HelperInstanceVariable: Enabled: true Rails/I18nLocaleTexts: Enabled: true +Performance/RedundantEqualityComparisonBlock: + Enabled: true +Performance/CollectionLiteralInLoop: + Enabled: true +Performance/MapMethodChain: + Enabled: true +Performance/Sum: + Enabled: true +Performance/Detect: + Enabled: true \ No newline at end of file diff --git a/README.md b/README.md index d0989f0a..d7301549 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Le projet utilise un vocabulaire DDD-light strict. **Avant toute contribution, l - [docs/payments.md](docs/payments.md) — paiements, lignes, dons. - [docs/migrations/vocabulary_migration.md](docs/migrations/vocabulary_migration.md) — plan de migration en cours. -> Résumé express : `Person` (CRM) ⟶ `User` (compte web optionnel) ⟶ `Membership` (adhésion annuelle) ⟶ `Contribution` (cotisation cirque) selon une `ContributionFormula`. Les paiements (`Payment`) regroupent une ou plusieurs `PaymentLine` (adhésion, cotisation, don). +> Résumé express : `Person` (CRM) ⟶ `User` (compte web, toujours rattaché à une `Person`) ⟶ `Membership` (adhésion annuelle) ⟶ `Contribution` (cotisation cirque) selon une `ContributionFormula`. Les paiements (`Payment`) regroupent une ou plusieurs `PaymentLine` (adhésion, cotisation, don). --- @@ -35,12 +35,17 @@ Application disponible sur `http://localhost:3000`. Letter Opener Web (emails de --- -## Tests +## Tests (RSpec only) + +Le projet utilise **RSpec uniquement**. Le legacy Minitest (`test/`) a ete retire. +La stack d'authentification reste **native Rails 8** (pas Devise). ```bash bin/test # suite complète + couverture bin/test_fast # models + services (rapide) bin/test --no-coverage # sans SimpleCov +bundle exec rspec # commande RSpec canonique +bundle exec rubocop --force-exclusion ``` --- diff --git a/app/controllers/account_claims_controller.rb b/app/controllers/account_claims_controller.rb index 7d6a8c95..a7c9e76b 100644 --- a/app/controllers/account_claims_controller.rb +++ b/app/controllers/account_claims_controller.rb @@ -21,7 +21,7 @@ def create redirect_to root_path, alert: result.message end rescue StandardError => e - redirect_to root_path, alert: "Erreur: #{e.message}" + redirect_to root_path, alert: t(".rescue_alert", message: e.message) end def confirm @@ -37,6 +37,6 @@ def confirm redirect_to root_path, alert: result.message end rescue StandardError => e - redirect_to root_path, alert: "Erreur lors de la réclamation: #{e.message}" + redirect_to root_path, alert: t(".rescue_alert", message: e.message) end end diff --git a/app/controllers/admin/attendance_lists_controller.rb b/app/controllers/admin/attendance_lists_controller.rb index b99c57d9..84d4a529 100644 --- a/app/controllers/admin/attendance_lists_controller.rb +++ b/app/controllers/admin/attendance_lists_controller.rb @@ -7,23 +7,23 @@ class AttendanceListsController < BaseController def index @attendance_list = AttendanceList.order(created_at: :desc) - add_breadcrumb "Listes de présence", nil + add_breadcrumb I18n.t("breadcrumbs.admin.attendance_lists.lists"), nil end def show - add_breadcrumb "Listes de présence", admin_attendance_lists_path + add_breadcrumb I18n.t("breadcrumbs.admin.attendance_lists.lists"), admin_attendance_lists_path add_breadcrumb @attendance_list.name, nil end def new - add_breadcrumb "Listes de présence", admin_attendance_lists_path - add_breadcrumb "Nouvelle liste", nil + add_breadcrumb I18n.t("breadcrumbs.admin.attendance_lists.lists"), admin_attendance_lists_path + add_breadcrumb I18n.t("breadcrumbs.admin.attendance_lists.new_list"), nil end def edit - add_breadcrumb "Listes de présence", admin_attendance_lists_path + add_breadcrumb I18n.t("breadcrumbs.admin.attendance_lists.lists"), admin_attendance_lists_path add_breadcrumb @attendance_list.name, admin_attendance_list_path(@attendance_list) - add_breadcrumb "Modifier", nil + add_breadcrumb I18n.t("breadcrumbs.admin.common.edit"), nil end def create diff --git a/app/controllers/admin/attendances_controller.rb b/app/controllers/admin/attendances_controller.rb index dc268532..17e3ecfc 100644 --- a/app/controllers/admin/attendances_controller.rb +++ b/app/controllers/admin/attendances_controller.rb @@ -19,12 +19,12 @@ def index # Pagination @attendances = @attendances.order(date: :desc).page(params[:page]).per(20) - add_breadcrumb "Gestion des présences", nil + add_breadcrumb I18n.t("breadcrumbs.admin.attendances.management"), nil end def show - add_breadcrumb "Gestion des présences", admin_attendances_path - add_breadcrumb "Présence ##{@attendance.id}", nil + add_breadcrumb I18n.t("breadcrumbs.admin.attendances.management"), admin_attendances_path + add_breadcrumb I18n.t("breadcrumbs.admin.attendances.attendance_number", id: @attendance.id), nil end def new @@ -32,8 +32,8 @@ def new @people = Person.order(:first_name, :last_name) @events = Event.upcoming.order(:date) - add_breadcrumb "Gestion des présences", admin_attendances_path - add_breadcrumb "Nouvelle présence", nil + add_breadcrumb I18n.t("breadcrumbs.admin.attendances.management"), admin_attendances_path + add_breadcrumb I18n.t("breadcrumbs.admin.attendances.new_attendance"), nil end def create diff --git a/app/controllers/admin/events_controller.rb b/app/controllers/admin/events_controller.rb index e0dcda10..055ed245 100644 --- a/app/controllers/admin/events_controller.rb +++ b/app/controllers/admin/events_controller.rb @@ -8,20 +8,20 @@ class EventsController < BaseController def index @events = Event.all - add_breadcrumb "Événements", nil + add_breadcrumb I18n.t("breadcrumbs.admin.events.events"), nil end def new @event = Event.new - add_breadcrumb "Événements", admin_events_path - add_breadcrumb "Nouvel événement", nil + add_breadcrumb I18n.t("breadcrumbs.admin.events.events"), admin_events_path + add_breadcrumb I18n.t("breadcrumbs.admin.events.new_event"), nil end def edit @event = Event.find params[:id] - add_breadcrumb "Événements", admin_events_path + add_breadcrumb I18n.t("breadcrumbs.admin.events.events"), admin_events_path add_breadcrumb @event.title, event_path(@event) - add_breadcrumb "Modifier", nil + add_breadcrumb I18n.t("breadcrumbs.admin.common.edit"), nil end def create diff --git a/app/controllers/admin/health_reports_controller.rb b/app/controllers/admin/health_reports_controller.rb index 72dcca6f..b174d57c 100644 --- a/app/controllers/admin/health_reports_controller.rb +++ b/app/controllers/admin/health_reports_controller.rb @@ -19,13 +19,13 @@ def index @duplicate_people_by_phone_groups = report.duplicate_people_by_phone.group_by { |person| person.phone.to_s } @list_limit = Admin::HealthReport::MAX_LIST - add_breadcrumb "Rapport d'intégrité", nil + add_breadcrumb I18n.t("breadcrumbs.admin.health_reports.integrity_report"), nil end private def set_breadcrumbs - add_breadcrumb "Administration", admin_dashboard_index_path + add_breadcrumb I18n.t("breadcrumbs.admin.common.administration"), admin_dashboard_index_path end end end diff --git a/app/controllers/admin/membership_types_controller.rb b/app/controllers/admin/membership_types_controller.rb index a6f54d37..7078a115 100644 --- a/app/controllers/admin/membership_types_controller.rb +++ b/app/controllers/admin/membership_types_controller.rb @@ -7,12 +7,12 @@ class MembershipTypesController < BaseController def index @membership_types = MembershipType.order(:category, :price_cents) - add_breadcrumb "Types d'Adhésion", nil + add_breadcrumb I18n.t("breadcrumbs.admin.membership_types.types"), nil end def show @memberships = @membership_type.memberships.includes(:person).order(created_at: :desc).limit(10) - add_breadcrumb "Type: #{@membership_type.name}", nil + add_breadcrumb I18n.t("breadcrumbs.admin.membership_types.type_named", name: @membership_type.name), nil end def new @@ -21,11 +21,11 @@ def new version: 1, created_by_user: Current.user ) - add_breadcrumb "Nouveau type d'adhésion", nil + add_breadcrumb I18n.t("breadcrumbs.admin.membership_types.new_type"), nil end def edit - add_breadcrumb "Modifier: #{@membership_type.name}", nil + add_breadcrumb I18n.t("breadcrumbs.admin.membership_types.edit_named", name: @membership_type.name), nil end def create @@ -69,8 +69,8 @@ def set_membership_type end def set_breadcrumbs - add_breadcrumb "Administration", admin_dashboard_index_path - add_breadcrumb "Types d'Adhésion", admin_membership_types_path + add_breadcrumb I18n.t("breadcrumbs.admin.common.administration"), admin_dashboard_index_path + add_breadcrumb I18n.t("breadcrumbs.admin.membership_types.types"), admin_membership_types_path end def membership_type_params diff --git a/app/controllers/admin/memberships_controller.rb b/app/controllers/admin/memberships_controller.rb index cfcfeaf7..d16b06b6 100644 --- a/app/controllers/admin/memberships_controller.rb +++ b/app/controllers/admin/memberships_controller.rb @@ -8,7 +8,7 @@ class MembershipsController < BaseController def index @people = Person.includes(:memberships, :user).all - add_breadcrumb "Gestion des Adhésions", nil + add_breadcrumb I18n.t("breadcrumbs.admin.memberships.management"), nil end def show @@ -16,9 +16,9 @@ def show @membership_types = MembershipType.all @contribution_formulas = ContributionFormula.all - add_breadcrumb "Liste d'adhérents", admin_users_path + add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path add_breadcrumb @person.full_name, admin_user_path("person_#{@person.id}") - add_breadcrumb "Adhésion", nil + add_breadcrumb I18n.t("breadcrumbs.admin.memberships.membership"), nil end def new @@ -30,19 +30,19 @@ def new @membership_types = MembershipType.circus_types.current_versions.order(:price_cents) @is_upgrade = true @current_membership = @person.current_membership - add_breadcrumb "Upgrade vers Cirque", nil + add_breadcrumb I18n.t("breadcrumbs.admin.memberships.upgrade_to_circus"), nil else # Pour une nouvelle adhésion, on propose tous les types @membership_types = MembershipType.current_versions.order(:price_cents) @is_upgrade = false - add_breadcrumb "Nouvelle adhésion", nil + add_breadcrumb I18n.t("breadcrumbs.admin.memberships.new_membership"), nil end end def edit @membership = @person.current_membership @membership_types = MembershipType.all - add_breadcrumb "Modifier adhésion", nil + add_breadcrumb I18n.t("breadcrumbs.admin.memberships.edit_membership"), nil end def create @@ -102,7 +102,7 @@ def set_person_for_create end def set_breadcrumbs - add_breadcrumb "Administration", admin_dashboard_index_path + add_breadcrumb I18n.t("breadcrumbs.admin.common.administration"), admin_dashboard_index_path end def membership_params @@ -146,7 +146,7 @@ def handle_upgrade_flow(person, membership_type) redirect_to admin_user_path("person_#{person.id}"), notice: build_upgrade_notice(membership_type, result, payment_method_from(params_hash)) else redirect_to new_admin_membership_path(person_id: person.id, upgrade: params_hash[:upgrade]), - alert: "Erreur lors de l'upgrade: #{result.message}" + alert: t("admin.memberships.create.upgrade_failed_alert", message: result.message) end end @@ -172,19 +172,30 @@ def handle_creation_flow(person, membership_type) end else redirect_to new_admin_membership_path(person_id: person.id), - alert: "Erreur lors de la création de l'adhésion: #{result.message}" + alert: t("admin.memberships.create.membership_creation_failed_alert", message: result.message) end end def build_upgrade_notice(membership_type, result, payment_method) - message = if payment_method == "offered" - "Adhésion upgradée avec succès ! #{membership_type.name} - Offert" - else - amount = result.payment&.total_cents || membership_type.price_cents - "Adhésion upgradée avec succès ! #{membership_type.name} - Montant: #{(amount / 100.0).round(2)}€" - end + message = + if payment_method == "offered" + I18n.t("admin.memberships.create.upgrade_notice_offered", name: membership_type.name) + else + amount = result.payment&.total_cents || membership_type.price_cents + I18n.t( + "admin.memberships.create.upgrade_notice_paid", + name: membership_type.name, + amount: (amount / 100.0).round(2) + ) + end - message += " | Numéro d'adhérent changé: #{result.old_member_number} → #{result.new_member_number}" if result.member_number_changed + if result.member_number_changed + message += I18n.t( + "admin.memberships.create.upgrade_notice_member_number_suffix", + old_number: result.old_member_number, + new_number: result.new_member_number + ) + end message end diff --git a/app/controllers/admin/opening_hours_controller.rb b/app/controllers/admin/opening_hours_controller.rb index 8f814fe7..3e46067d 100644 --- a/app/controllers/admin/opening_hours_controller.rb +++ b/app/controllers/admin/opening_hours_controller.rb @@ -7,12 +7,12 @@ class OpeningHoursController < BaseController include OpeningHoursHelper def show - add_breadcrumb "Horaires d'ouverture", nil + add_breadcrumb I18n.t("breadcrumbs.admin.opening_hours.title"), nil end def edit - add_breadcrumb "Horaires d'ouverture", admin_opening_hours_path - add_breadcrumb "Modifier", nil + add_breadcrumb I18n.t("breadcrumbs.admin.opening_hours.title"), admin_opening_hours_path + add_breadcrumb I18n.t("breadcrumbs.admin.common.edit"), nil end def update diff --git a/app/controllers/admin/payments_controller.rb b/app/controllers/admin/payments_controller.rb index 6f14e76a..097fb5e5 100644 --- a/app/controllers/admin/payments_controller.rb +++ b/app/controllers/admin/payments_controller.rb @@ -90,25 +90,31 @@ def create ] end else - format.html { redirect_to admin_payments_path, alert: "Erreur lors de la création du paiement: #{result.message}" } + fail_msg = t(".failure_alert", message: result.message) + err_detail = I18n.t("flash.generic.error_detail", message: result.message) + format.html { redirect_to admin_payments_path, alert: fail_msg } format.turbo_stream do - render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: "Erreur: #{result.message}" }) + render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: err_detail }) end end end rescue ActiveRecord::RecordNotFound => e respond_to do |format| - format.html { redirect_to admin_payments_path, alert: "Erreur lors de la création du paiement: #{e.message}" } + fail_msg = t("admin.payments.create.failure_alert", message: e.message) + err_detail = I18n.t("flash.generic.error_detail", message: e.message) + format.html { redirect_to admin_payments_path, alert: fail_msg } format.turbo_stream do - render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: "Erreur: #{e.message}" }) + render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: err_detail }) end end rescue StandardError => e Rails.logger.error("[Admin::PaymentsController#create] #{e.class}: #{e.message}") respond_to do |format| - format.html { redirect_to admin_payments_path, alert: "Erreur lors de la création du paiement: #{e.message}" } + fail_msg = t("admin.payments.create.failure_alert", message: e.message) + err_detail = I18n.t("flash.generic.error_detail", message: e.message) + format.html { redirect_to admin_payments_path, alert: fail_msg } format.turbo_stream do - render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: "Erreur: #{e.message}" }) + render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: err_detail }) end end end @@ -163,9 +169,11 @@ def update ] end else - format.html { redirect_to admin_payment_path(params[:id]), alert: "Échec de la mise à jour: #{result.message}" } + fail_msg = t(".failure_alert", message: result.message) + err_detail = I18n.t("flash.generic.error_detail", message: result.message) + format.html { redirect_to admin_payment_path(params[:id]), alert: fail_msg } format.turbo_stream do - render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: "Erreur: #{result.message}" }) + render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: err_detail }) end end end @@ -207,9 +215,11 @@ def destroy ] end else - format.html { redirect_to admin_payments_path, alert: "Échec de l'annulation du paiement: #{result.message}" } + fail_msg = t(".cancel_failed_alert", message: result.message) + err_detail = I18n.t("flash.generic.error_detail", message: result.message) + format.html { redirect_to admin_payments_path, alert: fail_msg } format.turbo_stream do - render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: "Erreur: #{result.message}" }) + render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: err_detail }) end end end @@ -238,7 +248,7 @@ def restore if result.success? redirect_to admin_payment_path(result.payment), notice: t(".restored_notice") else - redirect_to admin_payments_path, alert: "Échec de la restauration du paiement: #{result.message}" + redirect_to admin_payments_path, alert: t(".restore_failed_alert", message: result.message) end end @@ -256,9 +266,9 @@ def set_payments_breadcrumbs if params[:person_id].present? person = Person.find_by(id: params[:person_id]) if person - add_breadcrumb "Liste d'adhérents", admin_users_path + add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path add_breadcrumb person.full_name, admin_user_path("person_#{person.id}") - add_breadcrumb "Historique des paiements", nil + add_breadcrumb I18n.t("breadcrumbs.admin.payments.history"), nil return end end @@ -266,15 +276,15 @@ def set_payments_breadcrumbs if params[:user_id].present? user = User.find_by(id: params[:user_id]) if user - add_breadcrumb "Liste d'adhérents", admin_users_path + add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path label = user.full_name.presence || "Utilisateur ##{user.id}" add_breadcrumb label, admin_user_path(user) - add_breadcrumb "Historique des paiements", nil + add_breadcrumb I18n.t("breadcrumbs.admin.payments.history"), nil return end end - add_breadcrumb "Historique des paiements", nil + add_breadcrumb I18n.t("breadcrumbs.admin.payments.history"), nil end end end diff --git a/app/controllers/admin/subscription_plans_controller.rb b/app/controllers/admin/subscription_plans_controller.rb index d845d317..0c13477c 100644 --- a/app/controllers/admin/subscription_plans_controller.rb +++ b/app/controllers/admin/subscription_plans_controller.rb @@ -9,12 +9,12 @@ class SubscriptionPlansController < BaseController def index @contribution_formulas = ContributionFormula.includes(:membership_type).order(:duration, :price_cents) - add_breadcrumb "Plans de cotisation", nil + add_breadcrumb I18n.t("breadcrumbs.admin.subscription_plans.plans"), nil end def show @contributions = @contribution_formula.contributions.includes(:person) - add_breadcrumb "Plan: #{@contribution_formula.name}", nil + add_breadcrumb I18n.t("breadcrumbs.admin.subscription_plans.plan_named", name: @contribution_formula.name), nil end def new @@ -28,11 +28,11 @@ def new @contribution_formulas = ContributionFormula.available_for(@person) - add_breadcrumb "Nouvelle cotisation", nil + add_breadcrumb I18n.t("breadcrumbs.admin.subscription_plans.new_contribution"), nil end def edit - add_breadcrumb "Modifier: #{@contribution_formula.name}", nil + add_breadcrumb I18n.t("breadcrumbs.admin.subscription_plans.edit_named", name: @contribution_formula.name), nil end def create @@ -56,10 +56,10 @@ def create redirect_to admin_user_path("person_#{@person.id}"), notice: t(".purchased") else redirect_to new_admin_subscription_plan_path(person_id: @person.id), - alert: "Erreur lors de l'achat du plan: #{result.message}" + alert: t(".purchase_failed_alert", message: result.message) end rescue StandardError => e - flash[:alert] = "Erreur lors de l'achat du plan: #{e.message}" + flash[:alert] = t(".purchase_failed_alert", message: e.message) redirect_to new_admin_subscription_plan_path(person_id: @person.id) end @@ -97,8 +97,8 @@ def set_person end def set_breadcrumbs - add_breadcrumb "Administration", admin_dashboard_index_path - add_breadcrumb "Plans de cotisation", admin_subscription_plans_path + add_breadcrumb I18n.t("breadcrumbs.admin.common.administration"), admin_dashboard_index_path + add_breadcrumb I18n.t("breadcrumbs.admin.subscription_plans.plans"), admin_subscription_plans_path end def contribution_formula_params diff --git a/app/controllers/admin/subscriptions_controller.rb b/app/controllers/admin/subscriptions_controller.rb index dd63147f..f273f80f 100644 --- a/app/controllers/admin/subscriptions_controller.rb +++ b/app/controllers/admin/subscriptions_controller.rb @@ -14,16 +14,18 @@ def upgrade ).call if result.success? - credit_message = result.credit_applied.positive? ? " Crédit appliqué: #{result.credit_applied / 100.0}€" : "" - redirect_to admin_user_path("person_#{@person.id}"), - notice: "Cotisation upgradée avec succès.#{credit_message}" + notice = t(".success_notice") + if result.credit_applied.positive? + notice += t(".credit_applied_suffix", amount: (result.credit_applied / 100.0).round(2)) + end + redirect_to admin_user_path("person_#{@person.id}"), notice: notice else redirect_to admin_user_path("person_#{@person.id}"), - alert: "Erreur lors de l'upgrade: #{result.message}" + alert: t(".failure_alert", message: result.message) end rescue StandardError => e redirect_to admin_user_path("person_#{@person.id}"), - alert: "Erreur lors de l'upgrade: #{e.message}" + alert: t(".failure_alert", message: e.message) end private diff --git a/app/controllers/admin/users/payments_controller.rb b/app/controllers/admin/users/payments_controller.rb index 2aeedbf4..8035ed8f 100644 --- a/app/controllers/admin/users/payments_controller.rb +++ b/app/controllers/admin/users/payments_controller.rb @@ -66,13 +66,13 @@ def create else @membership_types = MembershipType.all @contribution_formulas = ContributionFormula.all - flash.now[:alert] = "Erreur lors de la création du paiement: #{result.message}" + flash.now[:alert] = t(".failure_alert", message: result.message) render :new, status: :unprocessable_content end rescue StandardError => e @membership_types = MembershipType.all @contribution_formulas = ContributionFormula.all - flash.now[:alert] = "Erreur lors de la création du paiement: #{e.message}" + flash.now[:alert] = t(".failure_alert", message: e.message) render :new, status: :unprocessable_content end @@ -95,7 +95,7 @@ def update if result.success? redirect_to admin_user_path("person_#{@person.id}"), notice: t(".success") else - redirect_to admin_user_path("person_#{@person.id}"), alert: "Erreur lors de la mise à jour: #{result.message}" + redirect_to admin_user_path("person_#{@person.id}"), alert: t(".failure_alert", message: result.message) end end @@ -112,7 +112,7 @@ def destroy if result.success? redirect_to admin_user_path("person_#{@person.id}"), notice: t(".destroyed") else - redirect_to admin_user_path("person_#{@person.id}"), alert: "Erreur lors de la suppression: #{result.message}" + redirect_to admin_user_path("person_#{@person.id}"), alert: t(".failure_alert", message: result.message) end end @@ -131,13 +131,13 @@ def process_payment if result.success? redirect_to admin_user_path("person_#{@person.id}"), notice: t(".processed") else - redirect_to admin_user_path("person_#{@person.id}"), alert: "Erreur lors du traitement: #{result.message}" + redirect_to admin_user_path("person_#{@person.id}"), alert: t(".failure_alert", message: result.message) end else redirect_to admin_user_path("person_#{@person.id}"), notice: t(".already_processed") end rescue StandardError => e - redirect_to admin_user_path("person_#{@person.id}"), alert: "Erreur lors du traitement: #{e.message}" + redirect_to admin_user_path("person_#{@person.id}"), alert: t(".failure_alert", message: e.message) end end @@ -157,9 +157,9 @@ def set_person end def set_breadcrumbs - add_breadcrumb "Liste d'adhérents", admin_users_path + add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path add_breadcrumb @person.full_name, admin_user_path("person_#{@person.id}") - add_breadcrumb "Gestion des paiements", nil + add_breadcrumb I18n.t("breadcrumbs.admin.payments.management"), nil end def payment_params diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index c7d7caab..5f2086af 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -48,7 +48,7 @@ def index @active_memberships = statistics[:active_memberships] @users_this_month = statistics[:users_this_month] - add_breadcrumb "Liste d'adhérents", nil + add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), nil end # GET /admin/users/1 or /admin/users/1.json @@ -94,7 +94,7 @@ def show @users = User.where(person: nil) # Users non liés @recent_payments = @person.payments.includes(:payment_lines, :recorded_by).order(created_at: :desc).limit(10) - add_breadcrumb "Liste d'adhérents", admin_users_path + add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path add_breadcrumb @person.full_name, nil else @@ -110,7 +110,7 @@ def show @is_person_without_user = false @recent_payments = @person&.payments&.includes(:payment_lines, :recorded_by)&.order(created_at: :desc)&.limit(10) || [] - add_breadcrumb "Liste d'adhérents", admin_users_path + add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path add_breadcrumb @user&.person&.full_name.present? ? @user.person.full_name : "Utilisateur ##{@user.id}", nil end @@ -131,12 +131,12 @@ def new @user.person = @person @user.email_address = @person.email @user.system_role = "web_visitor" # Rôle par défaut - add_breadcrumb "Liste d'adhérents", admin_users_path + add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path add_breadcrumb @person.full_name, admin_user_path("person_#{@person.id}") - add_breadcrumb "Créer un compte web", nil + add_breadcrumb I18n.t("breadcrumbs.admin.users.create_web_account"), nil else - add_breadcrumb "Liste d'adhérents", admin_users_path - add_breadcrumb "Nouvel adhérent", nil + add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path + add_breadcrumb I18n.t("breadcrumbs.admin.users.new_member"), nil end end @@ -144,9 +144,9 @@ def new def edit_person person_id = params[:id].to_s.gsub("person_", "") @person = PersonQuery.active.find(person_id) - add_breadcrumb "Liste d'adhérents", admin_users_path + add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path add_breadcrumb @person.full_name, admin_user_path("person_#{@person.id}") - add_breadcrumb "Modifier", nil + add_breadcrumb I18n.t("breadcrumbs.admin.common.edit"), nil end # GET /admin/users/1/edit @@ -180,9 +180,9 @@ def edit return end - add_breadcrumb "Liste d'adhérents", admin_users_path + add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path add_breadcrumb @user.person&.full_name.present? ? @user.person.full_name : "Utilisateur ##{@user.id}", admin_user_path(@user) - add_breadcrumb "Modifier", nil + add_breadcrumb I18n.t("breadcrumbs.admin.common.edit"), nil end # POST /admin/users or /admin/users.json @@ -361,7 +361,7 @@ def set_user end def set_breadcrumbs - add_breadcrumb "Dashboard", admin_dashboard_index_path + add_breadcrumb I18n.t("breadcrumbs.admin.common.dashboard"), admin_dashboard_index_path end # Check if current user has permission to delete the target user diff --git a/app/forms/admin/user_creation_form.rb b/app/forms/admin/user_creation_form.rb index ab7114f6..9aad83bd 100644 --- a/app/forms/admin/user_creation_form.rb +++ b/app/forms/admin/user_creation_form.rb @@ -51,7 +51,7 @@ class UserCreationForm validates :payment_method, inclusion: { in: %w[cash card cheque transfer offered] } def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("admin.users.create.invalid_data_alert", details: errors.full_messages.join(", "))) unless valid? existing_person = person_id.present? ? Person.active.find_by(id: person_id) : nil return failure("Person not found") if person_id.present? && existing_person.nil? @@ -145,19 +145,19 @@ def failure(message, error_list = nil) def translate_success_message(result) if result.user && result.membership - "Personne, compte web et adhésion créés avec succès !" + I18n.t("admin.users.create.success_person_user_membership") elsif result.user - "Personne et compte web créés avec succès !" + I18n.t("admin.users.create.success_person_user") elsif result.membership - "Personne et adhésion créées avec succès !" + I18n.t("admin.users.create.success_person_membership") else - "Personne créée avec succès !" + I18n.t("admin.users.create.success_person_only") end end def translate_error_message(message) - return "Cette personne a déjà un compte web." if message.include?("déjà un compte web") || message.include?("already has a user") - return "Un email est obligatoire pour créer un compte web." if message.include?("email is required") + return I18n.t("admin.users.create.error_existing_web_account") if message.include?("déjà un compte web") || message.include?("already has a user") + return I18n.t("admin.users.create.error_email_required") if message.include?("email is required") message end diff --git a/app/helpers/admin/users/display_helper.rb b/app/helpers/admin/users/display_helper.rb index 2fcc8aa0..b65cb4a1 100644 --- a/app/helpers/admin/users/display_helper.rb +++ b/app/helpers/admin/users/display_helper.rb @@ -5,7 +5,7 @@ module Users module DisplayHelper # Formatage du nom avec fallback def display_name(person) - person.full_name.presence || content_tag(:span, "Non renseigné", class: "text-gray-400 italic") + person.full_name.presence || content_tag(:span, I18n.t("helpers.admin.users.display.not_provided"), class: "text-gray-400 italic") end # Formatage de l'email avec fallback @@ -15,7 +15,7 @@ def display_email(person) elsif person.email.present? content_tag :span, person.email, class: "text-gray-600" else - content_tag :span, "Pas d'email", class: "text-gray-400 italic" + content_tag :span, I18n.t("helpers.admin.users.display.no_email"), class: "text-gray-400 italic" end end @@ -24,7 +24,7 @@ def display_phone(person) if person.phone.present? format_phone_number(person.phone) else - content_tag :span, "Non renseigné", class: "text-gray-400 italic" + content_tag :span, I18n.t("helpers.admin.users.display.not_provided"), class: "text-gray-400 italic" end end @@ -42,7 +42,7 @@ def format_full_name(person) elsif person.last_name.present? person.last_name else - "Nom non renseigné" + I18n.t("helpers.admin.users.display.name_not_provided") end end diff --git a/app/models/concerns/duplicatable.rb b/app/models/concerns/duplicatable.rb index a97250e4..ea33df8e 100644 --- a/app/models/concerns/duplicatable.rb +++ b/app/models/concerns/duplicatable.rb @@ -91,7 +91,9 @@ def self.transfer_relations(primary, secondary, options = {}) next unless secondary.respond_to?(relation) # Mettre à jour les clés étrangères + # rubocop:disable Rails/SkipsModelValidations secondary.send(relation).update_all("#{primary.class.name.downcase}_id" => primary.id) + # rubocop:enable Rails/SkipsModelValidations end end diff --git a/app/models/concerns/versionable.rb b/app/models/concerns/versionable.rb index bcc6b2f7..a2895caa 100644 --- a/app/models/concerns/versionable.rb +++ b/app/models/concerns/versionable.rb @@ -44,7 +44,9 @@ def version_at(date = Date.current) def create_version!(attributes, effective_from: Date.current, reason: nil, user: nil) transaction do # Fermer la version actuelle + # rubocop:disable Rails/SkipsModelValidations current_versions.update_all(effective_until: effective_from - 1.day) + # rubocop:enable Rails/SkipsModelValidations # Créer la nouvelle version create!(attributes.merge( diff --git a/app/models/payment.rb b/app/models/payment.rb index a8da4462..fc3d4b07 100644 --- a/app/models/payment.rb +++ b/app/models/payment.rb @@ -124,12 +124,15 @@ def handle_user_deletion # Instead of destroying the payment or losing the relationship, # we maintain the data but anonymize any personal identifiable information # This preserves payment history while protecting user privacy + # Intentional single-row bypass: we do not want status callbacks here. + # rubocop:disable Rails/SkipsModelValidations update_columns( # We keep the payment record but mark it as associated with a deleted user status: :cancel, # Add a note that the user was deleted notes: "User deleted - payment cancelled" ) + # rubocop:enable Rails/SkipsModelValidations # Log the user deletion effect on payment PaymentAuditLog.log(self, nil, "user_deleted") diff --git a/app/models/person.rb b/app/models/person.rb index 49de8b0b..19f3e14b 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -16,7 +16,7 @@ class Person < ApplicationRecord # TODO: Replace all newsletter_subscribed references with NewsletterSubscriber # =================================================================== - has_one :user, dependent: :nullify + has_one :user, dependent: :restrict_with_error has_many :memberships, dependent: :restrict_with_error has_many :payments, dependent: :restrict_with_error has_many :attendances, dependent: :destroy diff --git a/app/models/user.rb b/app/models/user.rb index a1e457fb..db732c85 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,6 +6,7 @@ class User < ApplicationRecord attr_accessor :cgu, :privacy_policy + before_validation :ensure_person_for_new_record, on: :create after_create :generate_password_reset_token after_create :welcome_send @@ -22,7 +23,7 @@ class User < ApplicationRecord alias_attribute :email, :email_address # Relation avec Person (nouvelle architecture) - belongs_to :person, optional: true + belongs_to :person # Relations Person-Based Architecture has_many :sessions, dependent: :destroy @@ -52,6 +53,7 @@ def newsletter_subscribed normalizes :email_address, with: ->(e) { e&.strip&.downcase } validates :email_address, presence: true + validates :person, presence: true validate :email_uniqueness_unless_person_email validates :cgu, acceptance: { message: :must_accept }, unless: :created_by_admin? validates :privacy_policy, acceptance: { message: :must_accept }, unless: :created_by_admin? @@ -70,12 +72,12 @@ def newsletter_subscribed # Anonymize personal data after soft deletion def anonymize_personal_data - update_columns( + update!( email_address: "deleted_#{id}@example.com" ) # Anonymiser les données de la Person liée - person&.update_columns( + person&.update!( first_name: "Deleted", last_name: "User", address: nil, @@ -84,7 +86,9 @@ def anonymize_personal_data ) # Deactivate any active memberships - memberships.where(status: "active").update_all(status: "inactive") + memberships.where(status: "active").find_each do |membership| + membership.update!(status: "inactive") + end end # Check if the user has any active payments @@ -241,6 +245,17 @@ def is_interested_in?(event_id) private + def ensure_person_for_new_record + return if person.present? + + self.person = Person.new( + first_name: "Web", + last_name: "User", + email: email_address + ) + person.skip_membership_validation = true + end + def generate_password_reset_token return if Rails.env.test? diff --git a/app/services/account_claim_management/account_claim_confirmer.rb b/app/services/account_claim_management/account_claim_confirmer.rb index a0597430..627d9b7c 100644 --- a/app/services/account_claim_management/account_claim_confirmer.rb +++ b/app/services/account_claim_management/account_claim_confirmer.rb @@ -7,7 +7,7 @@ class AccountClaimConfirmer < BaseService validates :confirmation_token, presence: true def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? begin claim = AccountClaim.find_by!(confirmation_token: confirmation_token) @@ -20,22 +20,24 @@ def call admin_person = claim.person if user_person.nil? - link_result = People::AccountLinker.new( + link_result = People::AttachUserToPerson.new( user: claim.user, - target_person: admin_person, - destroy_source_person: false, - audit_reason: "account_claim" + person: admin_person, + audit_reason: "account_claim_attach" ).call return failure("Erreur lors du rattachement du compte: #{link_result.message}") unless link_result.success? - user_person = link_result.target_person + user_person = link_result.person else + claim.update!(person: user_person) + merge_result = People::AccountMerger.new( source_person: admin_person, target_person: user_person, actor_id: claim.user.id, - merge_type: "account_claim" + merge_type: "account_claim", + destroy_source: true ).call return failure("Erreur lors de la fusion: #{merge_result.message}") unless merge_result.success? diff --git a/app/services/account_claim_management/account_claim_creator.rb b/app/services/account_claim_management/account_claim_creator.rb index 07025127..c2ace240 100644 --- a/app/services/account_claim_management/account_claim_creator.rb +++ b/app/services/account_claim_management/account_claim_creator.rb @@ -9,7 +9,7 @@ class AccountClaimCreator < BaseService validates :user_id, presence: true def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? begin user = User.find(user_id) diff --git a/app/services/attendance_list_management/attendance_list_creator.rb b/app/services/attendance_list_management/attendance_list_creator.rb index 42da8511..902f6fcd 100644 --- a/app/services/attendance_list_management/attendance_list_creator.rb +++ b/app/services/attendance_list_management/attendance_list_creator.rb @@ -13,7 +13,7 @@ class AttendanceListCreator < BaseService validates :created_by_id, presence: true def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? begin User.find(created_by_id) diff --git a/app/services/attendance_list_management/attendance_list_deleter.rb b/app/services/attendance_list_management/attendance_list_deleter.rb index dc3fe61a..2977f2f3 100644 --- a/app/services/attendance_list_management/attendance_list_deleter.rb +++ b/app/services/attendance_list_management/attendance_list_deleter.rb @@ -9,7 +9,7 @@ class AttendanceListDeleter < BaseService validates :deleted_by_id, presence: true def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? begin attendance_list = AttendanceList.find(attendance_list_id) diff --git a/app/services/attendance_list_management/attendance_list_updater.rb b/app/services/attendance_list_management/attendance_list_updater.rb index c065f2b8..54d73b5d 100644 --- a/app/services/attendance_list_management/attendance_list_updater.rb +++ b/app/services/attendance_list_management/attendance_list_updater.rb @@ -13,7 +13,7 @@ class AttendanceListUpdater < BaseService validates :updated_by_id, presence: true def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? begin attendance_list = AttendanceList.find(attendance_list_id) diff --git a/app/services/attendance_management/attendance_creator.rb b/app/services/attendance_management/attendance_creator.rb index e6b7a3e7..b28ee868 100644 --- a/app/services/attendance_management/attendance_creator.rb +++ b/app/services/attendance_management/attendance_creator.rb @@ -11,7 +11,7 @@ class AttendanceCreator < BaseService validates :person_id, presence: true def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? begin person = Person.find(person_id) diff --git a/app/services/attendance_management/attendance_remover.rb b/app/services/attendance_management/attendance_remover.rb index 0251910c..d72669f1 100644 --- a/app/services/attendance_management/attendance_remover.rb +++ b/app/services/attendance_management/attendance_remover.rb @@ -8,7 +8,7 @@ class AttendanceRemover < BaseService validates :attendance_id, presence: true def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? attendance = Attendance.find(attendance_id) Attendance.transaction do diff --git a/app/services/blog_management/blog_creator.rb b/app/services/blog_management/blog_creator.rb index 629b0638..1c4ae565 100644 --- a/app/services/blog_management/blog_creator.rb +++ b/app/services/blog_management/blog_creator.rb @@ -10,7 +10,7 @@ class BlogCreator < BaseService validates :content, presence: true def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? begin blog = Blog.create!( diff --git a/app/services/blog_management/blog_deleter.rb b/app/services/blog_management/blog_deleter.rb index 06b08cb2..d08bb224 100644 --- a/app/services/blog_management/blog_deleter.rb +++ b/app/services/blog_management/blog_deleter.rb @@ -9,7 +9,7 @@ class BlogDeleter < BaseService validates :deleted_by_id, presence: true def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? begin blog = Blog.find(blog_id) diff --git a/app/services/blog_management/blog_updater.rb b/app/services/blog_management/blog_updater.rb index 1b8ce6d8..c22e29cc 100644 --- a/app/services/blog_management/blog_updater.rb +++ b/app/services/blog_management/blog_updater.rb @@ -12,7 +12,7 @@ class BlogUpdater < BaseService validates :updated_by_id, presence: true def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? begin blog = Blog.find(blog_id) diff --git a/app/services/contribution_formula_management/contribution_formula_deleter.rb b/app/services/contribution_formula_management/contribution_formula_deleter.rb index d04ee2bb..60ae9807 100644 --- a/app/services/contribution_formula_management/contribution_formula_deleter.rb +++ b/app/services/contribution_formula_management/contribution_formula_deleter.rb @@ -9,7 +9,7 @@ class ContributionFormulaDeleter < BaseService validates :deleted_by_id, presence: true def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? begin contribution_formula = ContributionFormula.find(contribution_formula_id) diff --git a/app/services/contribution_formula_management/contribution_formula_updater.rb b/app/services/contribution_formula_management/contribution_formula_updater.rb index 57efb8d5..1187e219 100644 --- a/app/services/contribution_formula_management/contribution_formula_updater.rb +++ b/app/services/contribution_formula_management/contribution_formula_updater.rb @@ -10,7 +10,7 @@ class ContributionFormulaUpdater < BaseService validates :updated_by_id, presence: true def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? begin contribution_formula = ContributionFormula.find(contribution_formula_id) diff --git a/app/services/member_management_service.rb b/app/services/member_management_service.rb index 95cf7cc0..aee310cf 100644 --- a/app/services/member_management_service.rb +++ b/app/services/member_management_service.rb @@ -115,37 +115,25 @@ def self.merge_duplicate_persons(primary_person, secondary_person) # Préparer les mises à jour de contact if merged_data.any? - secondary_person.update_columns(email: nil) if merged_data.key?(:email) - secondary_person.update_columns(phone: nil) if merged_data.key?(:phone) + secondary_person.update!(email: nil) if merged_data.key?(:email) + secondary_person.update!(phone: nil) if merged_data.key?(:phone) primary_person.skip_membership_validation = true primary_person.update!(merged_data) end - # 2. Transférer toutes les relations - transferred_count = 0 - - # Transférer les adhésions - memberships_count = secondary_person.memberships.count - secondary_person.memberships.update_all(person_id: primary_person.id) - transferred_count += memberships_count - - # Transférer les paiements - payments_count = secondary_person.payments.count - secondary_person.payments.update_all(person_id: primary_person.id) - transferred_count += payments_count - - # Transférer les présences - attendances_count = secondary_person.attendances.count - secondary_person.attendances.update_all(person_id: primary_person.id) - transferred_count += attendances_count - - contributions_count = secondary_person.contributions.count - secondary_person.contributions.update_all(person_id: primary_person.id) - transferred_count += contributions_count - - # 3. Supprimer la Person secondaire - secondary_person.destroy! + transferred_count = secondary_person.memberships.count + + secondary_person.payments.count + + secondary_person.attendances.count + + secondary_person.contributions.count + + merge_result = People::AccountMerger.new( + source_person: secondary_person, + target_person: primary_person, + merge_type: "member_management_cleanup", + destroy_source: true + ).call + raise merge_result.message unless merge_result.success? { success: true, diff --git a/app/services/member_number_management/member_number_changer.rb b/app/services/member_number_management/member_number_changer.rb index 04be9ae1..a87c1bd5 100644 --- a/app/services/member_number_management/member_number_changer.rb +++ b/app/services/member_number_management/member_number_changer.rb @@ -15,7 +15,7 @@ class MemberNumberChanger < BaseService validate :member_number_uniqueness def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? begin person = Person.find(person_id) diff --git a/app/services/membership_type_management/membership_type_creator.rb b/app/services/membership_type_management/membership_type_creator.rb index 5cb391f1..7e92516d 100644 --- a/app/services/membership_type_management/membership_type_creator.rb +++ b/app/services/membership_type_management/membership_type_creator.rb @@ -17,7 +17,7 @@ class MembershipTypeCreator < BaseService validates :effective_from, presence: true def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? begin created_by = User.find(created_by_user_id) diff --git a/app/services/membership_type_management/membership_type_deleter.rb b/app/services/membership_type_management/membership_type_deleter.rb index 93027f6a..0ebfba7c 100644 --- a/app/services/membership_type_management/membership_type_deleter.rb +++ b/app/services/membership_type_management/membership_type_deleter.rb @@ -9,7 +9,7 @@ class MembershipTypeDeleter < BaseService validates :deleted_by_id, presence: true def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? begin membership_type = MembershipType.find(membership_type_id) diff --git a/app/services/membership_type_management/membership_type_updater.rb b/app/services/membership_type_management/membership_type_updater.rb index 27e6fc08..a7a911b1 100644 --- a/app/services/membership_type_management/membership_type_updater.rb +++ b/app/services/membership_type_management/membership_type_updater.rb @@ -14,7 +14,7 @@ class MembershipTypeUpdater < BaseService validates :updated_by_id, presence: true def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? begin membership_type = MembershipType.find(membership_type_id) diff --git a/app/services/newsletter_management/newsletter_updater.rb b/app/services/newsletter_management/newsletter_updater.rb index 5c877dc7..b80c78bf 100644 --- a/app/services/newsletter_management/newsletter_updater.rb +++ b/app/services/newsletter_management/newsletter_updater.rb @@ -12,7 +12,7 @@ class NewsletterUpdater < BaseService validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, if: -> { email.present? } def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? begin updated_by = User.find(updated_by_id) diff --git a/app/services/opening_hours_management/opening_hours_updater.rb b/app/services/opening_hours_management/opening_hours_updater.rb index e983aa3e..0f3a36bd 100644 --- a/app/services/opening_hours_management/opening_hours_updater.rb +++ b/app/services/opening_hours_management/opening_hours_updater.rb @@ -9,7 +9,7 @@ class OpeningHoursUpdater < BaseService validates :updated_by_id, presence: true def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? begin updated_by = User.find(updated_by_id) diff --git a/app/services/people/account_linker.rb b/app/services/people/account_linker.rb index 403ec8b0..ce82d6de 100644 --- a/app/services/people/account_linker.rb +++ b/app/services/people/account_linker.rb @@ -21,7 +21,7 @@ class AccountLinker validates :target_person_id, presence: true, unless: -> { target_person.present? } def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? resolved_user = resolve_user resolved_target = resolve_target_person @@ -32,7 +32,12 @@ def call return failure("Target person already has a user account") if resolved_target.user.present? && resolved_target.user != resolved_user ActiveRecord::Base.transaction do - resolved_user.update!(person: resolved_target) + attach_result = People::AttachUserToPerson.new( + user: resolved_user, + person: resolved_target, + audit_reason: audit_reason + ).call + return failure(attach_result.message, attach_result.errors) unless attach_result.success? cleanup_source_person(previous_person) if previous_person.present? && previous_person != resolved_target @@ -65,10 +70,15 @@ def resolve_target_person def cleanup_source_person(previous_person) return unless destroy_source_person - - previous_person.update!(email: nil, phone: nil) if anonymize_source_person - - previous_person.destroy! + previous_person.reload + + merge_result = People::AccountMerger.new( + source_person: previous_person, + target_person: target_person, + merge_type: "account_linker_cleanup", + destroy_source: true + ).call + raise ActiveRecord::RecordInvalid, previous_person unless merge_result.success? end def instrument_success(user, target_person, source_person) diff --git a/app/services/people/account_merger.rb b/app/services/people/account_merger.rb index 2c5e5409..30cbb2d1 100644 --- a/app/services/people/account_merger.rb +++ b/app/services/people/account_merger.rb @@ -21,7 +21,7 @@ class AccountMerger validates :target_person_id, presence: true, unless: -> { target_person.present? } def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? source = resolve_source_person target = resolve_target_person @@ -32,6 +32,7 @@ def call merge_memberships(source, target) merge_payments(source, target) merge_contributions(source, target) + merge_attendances(source, target) merge_newsletter(source, target) merge_attributes(source, target) @@ -69,15 +70,19 @@ def resolve_target_person end def merge_memberships(source, target) - source.memberships.update_all(person_id: target.id) + transfer_relation(source.memberships, target.id) end def merge_payments(source, target) - source.payments.update_all(person_id: target.id) + transfer_relation(source.payments, target.id) end def merge_contributions(source, target) - source.contributions.update_all(person_id: target.id) + transfer_relation(source.contributions, target.id) + end + + def merge_attendances(source, target) + transfer_relation(source.attendances, target.id) end def merge_newsletter(source, target) @@ -88,9 +93,36 @@ def merge_newsletter(source, target) end def merge_attributes(source, target) - return if target.email.present? + attrs = {} + if source.email.present? && (target.email.blank? || target_email_is_auth_placeholder?(target)) + source_email = source.email + # When we intend to destroy source, free unique email first. + # rubocop:disable Rails/SkipsModelValidations + source.update_column(:email, nil) if destroy_source + # rubocop:enable Rails/SkipsModelValidations + attrs[:email] = source_email + end + attrs[:first_name] = source.first_name if should_replace_identity_value?(target.first_name) && source.first_name.present? + attrs[:last_name] = source.last_name if should_replace_identity_value?(target.last_name) && source.last_name.present? + return if attrs.empty? + + target.update!(attrs) + end + + def should_replace_identity_value?(value) + value.blank? || value.in?(%w[Web User]) + end + + def target_email_is_auth_placeholder?(target) + target.user.present? && target.email.present? && target.email.casecmp?(target.user.email_address.to_s) + end - target.update!(email: source.email) + # Intentional batch reassignment for large merges. + # This runs inside a transaction and avoids callback storms. + def transfer_relation(relation_scope, target_person_id) + # rubocop:disable Rails/SkipsModelValidations + relation_scope.update_all(person_id: target_person_id) + # rubocop:enable Rails/SkipsModelValidations end def instrument_success(source, target) diff --git a/app/services/people/attach_user_to_person.rb b/app/services/people/attach_user_to_person.rb new file mode 100644 index 00000000..00e68a9e --- /dev/null +++ b/app/services/people/attach_user_to_person.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "ostruct" + +module People + class AttachUserToPerson + include ActiveModel::Model + include ActiveModel::Attributes + + Result = Struct.new(:success?, :user, :person, :errors, :message, keyword_init: true) + + attr_accessor :user, :person + + attribute :user_id, :integer + attribute :person_id, :integer + attribute :audit_reason, :string, default: "manual_attach" + + validates :user_id, presence: true, unless: -> { user.present? } + validates :person_id, presence: true, unless: -> { person.present? } + + def call + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? + + resolved_user = user.presence || User.find(user_id) + resolved_person = person.presence || Person.find(person_id) + + return success(resolved_user, resolved_person, "User already attached to this person") if resolved_user.person == resolved_person + return failure("Target person already has a user account") if resolved_person.user.present? && resolved_person.user != resolved_user + + ActiveRecord::Base.transaction do + resolved_user.update!(person: resolved_person) + + ActiveSupport::Notifications.instrument( + "people.user_attached", + user_id: resolved_user.id, + person_id: resolved_person.id, + audit_reason: audit_reason + ) + + success(resolved_user, resolved_person, "User attached successfully") + end + rescue ActiveRecord::RecordNotFound => e + failure("Record not found: #{e.message}") + rescue ActiveRecord::RecordInvalid => e + failure("Validation error: #{e.message}") + rescue StandardError => e + Rails.logger.error("[People::AttachUserToPerson] #{e.class}: #{e.message}\n#{e.backtrace.take(5).join("\n")}") + failure("Error attaching user: #{e.message}") + end + + private + + def success(user, person, message) + Result.new(success?: true, user: user, person: person, errors: [], message: message) + end + + def failure(message, error_list = nil) + Result.new(success?: false, user: nil, person: nil, errors: Array(error_list || message), message: message) + end + end +end diff --git a/app/services/people/contribution_creator.rb b/app/services/people/contribution_creator.rb index 92554fe4..b4b5101b 100644 --- a/app/services/people/contribution_creator.rb +++ b/app/services/people/contribution_creator.rb @@ -26,7 +26,7 @@ class ContributionCreator validate :recorded_by_present def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? target_person = resolve_person contribution_formula = ContributionFormula.find(contribution_formula_id) diff --git a/app/services/people/contribution_upgrader.rb b/app/services/people/contribution_upgrader.rb index 99d523a5..90c15d4e 100644 --- a/app/services/people/contribution_upgrader.rb +++ b/app/services/people/contribution_upgrader.rb @@ -24,7 +24,7 @@ class ContributionUpgrader validate :person_identifier_present def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? target_person = resolve_person recorded_by = resolve_user diff --git a/app/services/people/membership_creator.rb b/app/services/people/membership_creator.rb index 34386a65..d8b04f96 100644 --- a/app/services/people/membership_creator.rb +++ b/app/services/people/membership_creator.rb @@ -22,7 +22,7 @@ class MembershipCreator validates :payment_method, inclusion: { in: %w[cash card cheque transfer offered] } def call - return failure("Invalid data", errors.full_messages) unless valid? + return failure(I18n.t("services.validation.invalid_data"), errors.full_messages) unless valid? if person.memberships.active.current.exists? ActiveSupport::Notifications.instrument( diff --git a/app/services/people/membership_deactivator.rb b/app/services/people/membership_deactivator.rb index 42b23586..bd14d2c7 100644 --- a/app/services/people/membership_deactivator.rb +++ b/app/services/people/membership_deactivator.rb @@ -19,7 +19,7 @@ class MembershipDeactivator validate :membership_presence def call - return failure("Invalid data", errors.full_messages) unless valid? + return failure(I18n.t("services.validation.invalid_data"), errors.full_messages) unless valid? target_membership = membership || Membership.find(membership_id) user = resolve_user diff --git a/app/services/people/membership_updater.rb b/app/services/people/membership_updater.rb index 8636d301..57e7cb68 100644 --- a/app/services/people/membership_updater.rb +++ b/app/services/people/membership_updater.rb @@ -21,7 +21,7 @@ class MembershipUpdater validate :membership_presence def call - return failure("Invalid data", errors.full_messages) unless valid? + return failure(I18n.t("services.validation.invalid_data"), errors.full_messages) unless valid? target_membership = membership || Membership.find(membership_id) membership_type = MembershipType.find(membership_type_id) diff --git a/app/services/people/membership_upgrader.rb b/app/services/people/membership_upgrader.rb index 9a41a869..2037a31a 100644 --- a/app/services/people/membership_upgrader.rb +++ b/app/services/people/membership_upgrader.rb @@ -22,7 +22,7 @@ class MembershipUpgrader validates :payment_method, inclusion: { in: %w[cash card cheque transfer offered] } def call - return failure("Invalid data", errors.full_messages) unless valid? + return failure(I18n.t("services.validation.invalid_data"), errors.full_messages) unless valid? new_membership_type = MembershipType.find(new_membership_type_id) recorded_by = resolve_recorded_by diff --git a/app/services/people/person_creator.rb b/app/services/people/person_creator.rb index 54738a2e..21f25e11 100644 --- a/app/services/people/person_creator.rb +++ b/app/services/people/person_creator.rb @@ -44,7 +44,7 @@ def initialize(attributes = {}) validates :last_name, presence: true, unless: :person_present? def call - return failure("Invalid data", errors.full_messages) unless valid? + return failure(I18n.t("services.validation.invalid_data"), errors.full_messages) unless valid? ActiveRecord::Base.transaction do target_person, created = resolve_person diff --git a/app/services/people/user_account_creator.rb b/app/services/people/user_account_creator.rb index e131fc68..78a64aef 100644 --- a/app/services/people/user_account_creator.rb +++ b/app/services/people/user_account_creator.rb @@ -21,7 +21,7 @@ class UserAccountCreator validates :system_role, inclusion: { in: %w[super_admin admin volunteer web_visitor] }, allow_blank: true def call - return failure("Invalid data", errors.full_messages) unless valid? + return failure(I18n.t("services.validation.invalid_data"), errors.full_messages) unless valid? ActiveRecord::Base.transaction do target_person = person.reload diff --git a/app/services/user_management/user_updater.rb b/app/services/user_management/user_updater.rb index b056f1d9..4dff15ff 100644 --- a/app/services/user_management/user_updater.rb +++ b/app/services/user_management/user_updater.rb @@ -13,7 +13,7 @@ class UserUpdater < BaseService validates :updated_by_id, presence: true def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? begin user = User.find(user_id) diff --git a/app/services/web/user_registration.rb b/app/services/web/user_registration.rb index c74c440e..6dbb3b88 100644 --- a/app/services/web/user_registration.rb +++ b/app/services/web/user_registration.rb @@ -31,7 +31,7 @@ class UserRegistration < BaseService validate :password_confirmation_matches def call - return failure("Invalid data: #{errors.full_messages.join(', ')}") unless valid? + return failure(I18n.t("services.validation.invalid_data_with_details", details: errors.full_messages.join(", "))) unless valid? existing_person = Person.active.find_by(email: email) diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index 3f4ffe90..0b09c43a 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -1,4 +1,4 @@ -<% content_for :title, "Gestion des Adhérents" %> +<% content_for :title, t("views.admin.users.index.page_title") %>
@@ -8,23 +8,23 @@
-

Gestion des Adhérents

+

<%= t("views.admin.users.index.page_title") %>

- Interface unifiée pour gérer les personnes, adhésions et cotisations + <%= t("views.admin.users.index.page_subtitle") %>

-

Statistiques

+

<%= t("views.admin.users.index.statistics") %>

-

Total personnes

+

<%= t("views.admin.users.index.total_people") %>

<%= @total_people %>

@@ -33,7 +33,7 @@ data-admin-users-target="statCard" data-stat-type="" data-action="click->admin-users#statCardClick"> -

Nouveaux (hier)

+

<%= t("views.admin.users.index.new_yesterday") %>

<%= @new_users_yesterday %>

@@ -42,7 +42,7 @@ data-admin-users-target="statCard" data-stat-type="basic" data-action="click->admin-users#statCardClick"> -

Adhésions Basic

+

<%= t("views.admin.users.index.basic_memberships") %>

<%= @basic_memberships %>

@@ -51,7 +51,7 @@ data-admin-users-target="statCard" data-stat-type="circus" data-action="click->admin-users#statCardClick"> -

Adhésions Circus

+

<%= t("views.admin.users.index.circus_memberships") %>

<%= @circus_memberships %>

@@ -60,7 +60,7 @@ data-admin-users-target="statCard" data-stat-type="with_active_membership" data-action="click->admin-users#statCardClick"> -

Adhésions actives

+

<%= t("views.admin.users.index.active_memberships") %>

<%= @active_memberships %>

@@ -69,7 +69,7 @@ data-admin-users-target="statCard" data-stat-type="without_user_account" data-action="click->admin-users#statCardClick"> -

Sans compte

+

<%= t("views.admin.users.index.without_account") %>

<%= @people_without_user %>

@@ -77,7 +77,7 @@
- <%= link_to "Créer un Adhérent", new_admin_user_path, + <%= link_to t("views.admin.users.index.create_member"), new_admin_user_path, class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" %> <%= link_to "Export CSV", all_users_admin_exports_path, diff --git a/app/views/admin/users/new.html.erb b/app/views/admin/users/new.html.erb index a2e65406..7260bee0 100644 --- a/app/views/admin/users/new.html.erb +++ b/app/views/admin/users/new.html.erb @@ -1,4 +1,4 @@ -<% content_for :title, "Créer un nouvel utilisateur" %> +<% content_for :title, t("views.admin.users.new.page_title") %>
@@ -7,14 +7,14 @@
-

Créer un nouvel utilisateur

-

Ajoutez un nouvel utilisateur au système

+

<%= t("views.admin.users.new.page_title") %>

+

<%= t("views.admin.users.new.page_subtitle") %>

-

Informations de l'utilisateur

+

<%= t("views.admin.users.new.user_information") %>

<%= form_with(model: [:admin, @user],method: :post, data: { turbo: false }, class: "space-y-6") do |form| %> @@ -48,8 +48,8 @@ <%= form.check_box :create_web_account, { class: "focus:ring-[#1F5C55] h-4 w-4 text-[#1F5C55] border-gray-300 rounded", checked: @person.present?, onclick: "toggleWebAccountFields()" }, "true", "false" %>
-

Créer un compte web

-

Permettre à cette personne de se connecter et gérer son profil en ligne

+

<%= t("views.admin.users.new.create_web_account") %>

+

<%= t("views.admin.users.new.create_web_account_help") %>

@@ -59,12 +59,12 @@
- <%= form.label :system_role, "Rôle système", class: "block text-sm font-medium text-gray-700" %> + <%= form.label :system_role, t("views.admin.users.new.system_role"), class: "block text-sm font-medium text-gray-700" %> <%= form.select :system_role, options_for_select([ - ['Visiteur web', 'web_visitor'], - ['Bénévole', 'volunteer'], - ['Administrateur', 'admin'] + [t('views.admin.users.new.roles.web_visitor'), 'web_visitor'], + [t('views.admin.users.new.roles.volunteer'), 'volunteer'], + [t('views.admin.users.new.roles.admin'), 'admin'] ], 'web_visitor'), {}, { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-[#1F5C55] focus:ring-[#1F5C55] sm:text-sm" } %> @@ -76,7 +76,7 @@
-

Un mot de passe temporaire sera généré et envoyé par email

+

<%= t("views.admin.users.new.temporary_password_notice") %>

diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index a3300cdd..f10221e6 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,8 +1,8 @@ - + - <%= content_for(:title) || "Le Circographe" %> + <%= content_for(:title) || t("layouts.application.title") %> diff --git a/app/views/pages/about.html.erb b/app/views/pages/about.html.erb index 2842758c..95e664c9 100644 --- a/app/views/pages/about.html.erb +++ b/app/views/pages/about.html.erb @@ -1,15 +1,15 @@ -<% content_for :title, "Le Circographe - À propos" %> +<% content_for :title, t("views.pages.about.page_title") %>
<%= image_tag hero_image(:about_hero), class: "absolute inset-0 w-full h-full object-cover hero-img-filter", alt: "" %>
-

Rejoindre la communauté

-

À propos du Circographe

-

Laboratoire de pratiques artistiques partagées où cirque et arts graphiques se rencontrent — autogestion et solidarité.

+

<%= t("views.pages.about.join_community") %>

+

<%= t("views.pages.about.heading") %>

+

<%= t("views.pages.about.hero_description") %>

- <%= link_to "Nous rejoindre", page_path('become_member'), class: "btn-primary shadow-lg" %> - <%= link_to "Découvrir le lieu", page_path('association'), class: "btn-secondary shadow-lg" %> + <%= link_to t("views.pages.about.join"), page_path('become_member'), class: "btn-primary shadow-lg" %> + <%= link_to t("views.pages.about.discover_place"), page_path('association'), class: "btn-secondary shadow-lg" %>
@@ -17,10 +17,10 @@
-

Notre mission

-

Un espace de création partagé et accessible

-

Offrir un espace de pratique, de création et de transmission accessible à toutes et tous.

-

Gouvernance autogérée : les membres s’engagent bénévolement, partagent les clés et co‑programment la vie du lieu.

+

<%= t("views.pages.about.our_mission") %>

+

<%= t("views.pages.about.mission_heading") %>

+

<%= t("views.pages.about.mission_text_1") %>

+

<%= t("views.pages.about.mission_text_2") %>

Nos valeurs

@@ -145,5 +145,5 @@
- <%= link_to "Nous rejoindre", page_path('become_member'), class: "btn-primary px-8 py-4 tracking-wide font-bold shadow-lg" %> + <%= link_to t("views.pages.about.join"), page_path('become_member'), class: "btn-primary px-8 py-4 tracking-wide font-bold shadow-lg" %>
\ No newline at end of file diff --git a/app/views/shared/_flash.html.erb b/app/views/shared/_flash.html.erb index 484af64a..a0d6e1cd 100644 --- a/app/views/shared/_flash.html.erb +++ b/app/views/shared/_flash.html.erb @@ -70,7 +70,7 @@
<%= Current.user.email %>
- <%= button_to "Déconnexion", session_path, method: :delete, class: "block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer" %> + <%= button_to t("views.shared.navbar.logout"), session_path, method: :delete, class: "block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer" %>
<% else %>
- <%= link_to "Se connecter", new_session_path, class: "btn-tertiary text-sm" %> - <%= link_to "S'inscrire", new_registration_path, class: "btn-primary text-sm shadow-lg" %> + <%= link_to t("views.shared.navbar.sign_in"), new_session_path, class: "btn-tertiary text-sm" %> + <%= link_to t("views.shared.navbar.sign_up"), new_registration_path, class: "btn-primary text-sm shadow-lg" %>
<% end %> @@ -149,7 +149,7 @@

@@ -252,7 +252,7 @@ <% if Current.user.first_name.present? || Current.user.last_name.present? %> <%= [Current.user.first_name, Current.user.last_name].compact.join(' ') %> <% else %> - Utilisateur + <%= t("views.shared.navbar.user") %> <% end %>
<%= Current.user.email %>
@@ -261,23 +261,23 @@ <% if Current.user.has_privileges? %>
- <%= link_to "Profil", user_path(Current.user), class: "text-center py-2 px-4 bg-gray-100 rounded-lg hover:bg-gray-200" %> - <%= link_to "Tableau de bord", admin_dashboard_index_path, class: "text-center py-2 px-4 bg-gray-100 rounded-lg hover:bg-gray-200" %> - <%= link_to "Paramètres", settings_path, class: "text-center py-2 px-4 bg-gray-100 rounded-lg hover:bg-gray-200" %> - <%= button_to "Déconnexion", session_path, method: :delete, class: "text-center py-2 px-4 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 cursor-pointer" %> + <%= link_to t("views.shared.navbar.profile"), user_path(Current.user), class: "text-center py-2 px-4 bg-gray-100 rounded-lg hover:bg-gray-200" %> + <%= link_to t("views.shared.navbar.dashboard"), admin_dashboard_index_path, class: "text-center py-2 px-4 bg-gray-100 rounded-lg hover:bg-gray-200" %> + <%= link_to t("views.shared.navbar.settings"), settings_path, class: "text-center py-2 px-4 bg-gray-100 rounded-lg hover:bg-gray-200" %> + <%= button_to t("views.shared.navbar.logout"), session_path, method: :delete, class: "text-center py-2 px-4 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 cursor-pointer" %>
<% else %>
- <%= link_to "Profil", user_path(Current.user), class: "text-center py-2 px-4 bg-gray-100 rounded-lg hover:bg-gray-200" %> - <%= link_to "Paramètres", settings_path, class: "text-center py-2 px-4 bg-gray-100 rounded-lg hover:bg-gray-200" %> - <%= button_to "Déconnexion", session_path, method: :delete, class: "text-center py-2 px-4 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 col-span-2 cursor-pointer" %> + <%= link_to t("views.shared.navbar.profile"), user_path(Current.user), class: "text-center py-2 px-4 bg-gray-100 rounded-lg hover:bg-gray-200" %> + <%= link_to t("views.shared.navbar.settings"), settings_path, class: "text-center py-2 px-4 bg-gray-100 rounded-lg hover:bg-gray-200" %> + <%= button_to t("views.shared.navbar.logout"), session_path, method: :delete, class: "text-center py-2 px-4 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 col-span-2 cursor-pointer" %>
<% end %> <% else %>
- <%= link_to "Se connecter", new_session_path, class: "text-center font-bold py-2 px-4 border border-gray-300 rounded-lg hover:bg-gray-50" %> - <%= link_to "S'inscrire", new_registration_path, class: "text-center px-6 py-2 tracking-wide text-white font-bold bg-[#1F5C55] border-2 border-[#1F5C55] rounded-lg shadow-lg hover:bg-gray-100 hover:text-[#1F5C55] hover:border-[#1F5C55] transition-colors duration-300" %> + <%= link_to t("views.shared.navbar.sign_in"), new_session_path, class: "text-center font-bold py-2 px-4 border border-gray-300 rounded-lg hover:bg-gray-50" %> + <%= link_to t("views.shared.navbar.sign_up"), new_registration_path, class: "text-center px-6 py-2 tracking-wide text-white font-bold bg-[#1F5C55] border-2 border-[#1F5C55] rounded-lg shadow-lg hover:bg-gray-100 hover:text-[#1F5C55] hover:border-[#1F5C55] transition-colors duration-300" %>
<% end %> diff --git a/config/application.rb b/config/application.rb index 8da3c590..f081f699 100644 --- a/config/application.rb +++ b/config/application.rb @@ -17,6 +17,8 @@ class Application < Rails::Application config.i18n.default_locale = :fr config.i18n.available_locales = %i[fr en] + # Until en.yml mirrors all fr keys, :en lookups fall back to French copy. + config.i18n.fallbacks = { en: %i[fr] } # Please, add to the `ignore` list any other `lib` subdirectories that do # not contain `.rb` files, or that should not be reloaded or eager loaded. diff --git a/config/environments/development.rb b/config/environments/development.rb index 02d0f52b..16823968 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -61,7 +61,7 @@ config.active_job.verbose_enqueue_logs = true # Raises error for missing translations. - # config.i18n.raise_on_missing_translations = true + config.i18n.raise_on_missing_translations = true # Annotate rendered view with file names. config.action_view.annotate_rendered_view_with_filenames = true diff --git a/config/locales/en.yml b/config/locales/en.yml index 6c349ae5..654374a1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,31 +1,400 @@ -# Files in the config/locales directory are used for internationalization and -# are automatically loaded by Rails. If you want to use locales other than -# English, add the necessary files in this directory. -# -# To use the locales, use `I18n.t`: -# -# I18n.t "hello" -# -# In views, this is aliased to just `t`: -# -# <%= t("hello") %> -# -# To use a different locale, set it with `I18n.locale`: -# -# I18n.locale = :es -# -# This would use the information in config/locales/es.yml. -# -# To learn more about the API, please read the Rails Internationalization guide -# at https://guides.rubyonrails.org/i18n.html. -# -# Be aware that YAML interprets the following case-insensitive strings as -# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings -# must be quoted to be interpreted as strings. For example: -# -# en: -# "yes": yup -# enabled: "ON" - +--- en: - hello: "Hello world" + flash: + generic: + error_detail: 'Error: %{message}' + account_claims: + create: + rescue_alert: 'Error: %{message}' + success: Demande de réclamation envoyée. Vérifiez vos emails. + confirm: + rescue_alert: 'Error while claiming account: %{message}' + success: "✅ Compte revendiqué ! Votre historique est maintenant disponible." + admin: + memberships: + create: + upgrade_failed_alert: 'Error during upgrade: %{message}' + membership_creation_failed_alert: 'Error creating membership: %{message}' + upgrade_notice_offered: Membership upgraded successfully! %{name} — Complimentary + upgrade_notice_paid: 'Membership upgraded successfully! %{name} — Amount: %{amount}€' + upgrade_notice_member_number_suffix: " | Member number changed: %{old_number} → %{new_number}" + duplicate_active: Cette personne possède déjà une adhésion active. + success_with_contribution_hint: Adhésion créée avec succès ! Vous pouvez maintenant ajouter une cotisation depuis la fiche utilisateur. + update: + success: Adhésion mise à jour avec succès. + destroy: + deactivated: Adhésion désactivée avec succès. + payments: + create: + failure_alert: 'Error creating payment: %{message}' + created_notice: Paiement créé avec succès + update: + failure_alert: 'Update failed: %{message}' + success_notice: Mise à jour réussie + destroy: + cancel_failed_alert: 'Failed to cancel payment: %{message}' + cancelled_notice: Paiement annulé avec succès + restore: + restore_failed_alert: 'Failed to restore payment: %{message}' + restored_notice: Paiement restauré avec succès + show: + use_inline_notice: Utilisez l'édition inline pour modifier les paiements + new: + creation_disabled_notice: Création de paiement temporairement désactivée + subscription_plans: + create: + purchase_failed_alert: 'Error purchasing plan: %{message}' + purchased: Plan de cotisation acheté avec succès ! + new: + needs_circus_membership_alert: Cette personne doit avoir une adhésion Cirque pour acheter des plans de cotisation + update: + updated: Plan de cotisation mis à jour avec succès ! + destroy: + destroyed: Plan de cotisation supprimé avec succès ! + require_super_admin: + forbidden: Seul le super-admin peut modifier ou supprimer des cotisations. + subscriptions: + upgrade: + success_notice: Contribution upgraded successfully. + credit_applied_suffix: " Credit applied: %{amount}€" + failure_alert: 'Error during upgrade: %{message}' + users: + create: + invalid_data_alert: 'Invalid data: %{details}' + success_person_user_membership: Person, web account and membership created successfully! + success_person_user: Person and web account created successfully! + success_person_membership: Person and membership created successfully! + success_person_only: Person created successfully! + error_existing_web_account: This person already has a web account. + error_email_required: An email is required to create a web account. + payments: + create: + failure_alert: 'Error creating payment: %{message}' + success: Paiement créé avec succès + update: + failure_alert: 'Error updating payment: %{message}' + success: Paiement mis à jour avec succès + destroy: + failure_alert: 'Error deleting payment: %{message}' + destroyed: Paiement supprimé avec succès + process_payment: + failure_alert: 'Error processing payment: %{message}' + processed: Paiement traité avec succès + already_processed: Paiement déjà traité + show: + merged_person_notice: Cette fiche a été fusionnée avec une autre. Retour à la liste des utilisateurs. + edit: + user_not_found: Utilisateur non trouvé. + edit_error: Une erreur est survenue lors de l'édition de l'utilisateur. + update: + person_saved_notice: Informations mises à jour avec succès. + ajax_success_json_message: Informations mises à jour avec succès. + html_updated: Utilisateur mis à jour avec succès. + turbo_notice: Utilisateur mis à jour avec succès. + destroy: + person_not_found_alert: Personne non trouvée. + person_deleted_notice: Personne supprimée avec succès. + destruction_failed_alert_html: "❌ %{message}" + user_archived_notice: Utilisateur archivé avec succès. + archive_failed_alert: Impossible d'archiver cet utilisateur. + restore: + restored_notice: Utilisateur restauré avec succès. + restore_failed_alert: Impossible de restaurer cet utilisateur. + set_user: + person_or_user_missing_alert: Utilisateur non trouvé. + check_deletion_permissions: + higher_privileges: Impossible de supprimer un utilisateur avec des privilèges égaux ou supérieurs. + require_super_admin: + restore_denied_alert: Seul le super-admin peut restaurer des utilisateurs. + base: + unauthorized_alert: Vous n'avez pas accès à cette page. + attendances: + create: + success: Présence enregistrée avec succès + destroy: + destroyed: Présence supprimée avec succès + failure: Erreur lors de la suppression + blogs: + create: + created: Blog créé avec succès + update: + updated: Blog mis à jour avec succès + destroy: + destroyed: Blog supprimé avec succès + donations: + create: + recorded: Donation prise en compte + events: + create: + created: Événement créé avec succès + update: + updated: Événement modifié avec succès + destroy: + destroyed_notice: Événement supprimé avec succès + membership_types: + create: + html_notice: Type d'adhésion créé avec succès ! + turbo_notice: Type d'adhésion créé avec succès ! + update: + html_notice: Type d'adhésion mis à jour avec succès ! + turbo_notice: Type d'adhésion mis à jour avec succès ! + destroy: + destroyed: Type d'adhésion supprimé avec succès ! + notepads: + edit: + breadcrumb_modify: Modifier le bloc-note + update: + updated: Bloc-note mis à jour ! + opening_hours: + update: + success: Horaires mis à jour avec succès + sessions: + rate_limited_alert: Rééssayez plus tard + create: + success: Connexion réussie ! + invalid_credentials: Email ou mot de passe invalide + destroy: + signed_out: Déconnecté avec succès ! + breadcrumbs: + admin: + common: + administration: Administration + dashboard: Dashboard + edit: Edit + users: + members_list: Members list + create_web_account: Create web account + new_member: New member + memberships: + management: Membership management + membership: Membership + upgrade_to_circus: Upgrade to Circus + new_membership: New membership + edit_membership: Edit membership + payments: + history: Payment history + management: Payments management + subscription_plans: + plans: Contribution plans + plan_named: 'Plan: %{name}' + new_contribution: New contribution + edit_named: 'Edit: %{name}' + opening_hours: + title: Opening hours + events: + events: Events + new_event: New event + attendances: + management: Attendance management + attendance_number: 'Attendance #%{id}' + new_attendance: New attendance + membership_types: + types: Membership types + type_named: 'Type: %{name}' + new_type: New membership type + edit_named: 'Edit: %{name}' + attendance_lists: + lists: Attendance lists + new_list: New list + health_reports: + integrity_report: Integrity report + helpers: + admin: + users: + display: + not_provided: Not provided + no_email: No email + name_not_provided: Name not provided + layouts: + application: + title: Le Circographe + views: + shared: + flash: + close: Close + navbar: + open_menu: Open menu + place: The Place + circus: Circus + graphic_arts: Graphic Arts + how_it_works: How it works + activities: Our Activities + join: Join + about: About + contact: Contact us + faq: FAQ + my_space: My Space + user: User + profile: Profile + dashboard: Dashboard + settings: Settings + logout: Sign out + sign_in: Sign in + sign_up: Sign up + association: Association + news: News + our_news: Our news + blog_newsletters: Blog & Newsletters + photo_gallery: Photo gallery + events: Our Events + footer: + newsletter_cta: Stay informed by subscribing to our newsletter + email_placeholder: Your email address + sign_up: Sign up + discover: Discover + about: About + place: The Place + activities: Our activities + participate: Participate + join: Join + contact: Contact us + map: Map + resources: Resources + faq: FAQ + terms: Legal notice + privacy: Privacy policy + brand: Le Circographe + all_rights_reserved: All rights reserved. + admin: + users: + new: + page_title: Create a new user + page_subtitle: Add a new user to the system + user_information: User information + create_web_account: Create a web account + create_web_account_help: Allow this person to sign in and manage their online profile + system_role: System role + roles: + web_visitor: Web visitor + volunteer: Volunteer + admin: Administrator + temporary_password_notice: A temporary password will be generated and sent by email + index: + page_title: Members Management + page_subtitle: Unified interface to manage people, memberships and contributions + statistics: Statistics + total_people: Total people + new_yesterday: New (yesterday) + basic_memberships: Basic memberships + circus_memberships: Circus memberships + active_memberships: Active memberships + without_account: Without account + create_member: Create a Member + pages: + about: + page_title: Le Circographe - About + join_community: Join the community + heading: About Le Circographe + hero_description: A laboratory of shared artistic practices where circus and graphic arts meet — self-management and solidarity. + join: Join us + discover_place: Discover the place + our_mission: Our mission + mission_heading: A shared and accessible creative space + mission_text_1: Provide a space for practice, creation and transmission that is accessible to everyone. + mission_text_2: 'Self-managed governance: members volunteer, share responsibilities, and co-program the life of the venue.' + services: + validation: + invalid_data: Invalid data + invalid_data_with_details: 'Invalid data: %{details}' + web: + user_registration: + accept_cgu: Vous devez accepter les CGU pour continuer. + accept_privacy: Vous devez accepter la politique de confidentialité pour continuer. + checkout: + success: + payment_ok: Paiement réussi et commande mise à jour. + payment_failed_alert: Paiement non réussi, commande non mise à jour. + cancel: + cancelled_alert: Le paiement a été annulé. + contacts: + create: + blank_fields: Veuillez remplir tous les champs du formulaire. + sent_notice: Votre message a été envoyé avec succès ! Nous revenons vers vous rapidement. + send_error: Une erreur est survenue lors de l'envoi. Réessaie dans quelques instants. + event_interests: + create: + profile_incomplete: Votre profil n'est pas complet. Veuillez contacter l'administration. + interest_added: Vous êtes maintenant intéressé par cet événement ! + interest_error: Erreur lors de l'ajout de votre intérêt + destroy: + interest_removed: Vous n'êtes plus intéressé par cet événement + interest_remove_error: Erreur lors de la suppression de votre intérêt + passwords: + create: + reset_instructions_sent_generic: Instructions de réinitialisation du mot de passe envoyées (si un utilisateur avec cette adresse e-mail existe). + update: + password_reset_success: Le mot de passe a été réinitialisé. + request_reset: + sent_to_email_notice: Instructions de réinitialisation du mot de passe envoyées à %{email}. + must_be_signed_in_alert: Vous devez être connecté pour effectuer cette action. + invalid_token_alert: Le lien de réinitialisation du mot de passe est invalide ou a expiré. + registrations: + create: + success_notice: Inscription réussie ! + sessions: + rate_limited_alert: Rééssayez plus tard + create: + login_success_notice: Connexion réussie ! + invalid_credentials_flash: Email ou mot de passe invalide + invalid_credentials_redirect: Email ou mot de passe invalide + destroy: + signed_out_notice: Déconnecté avec succès ! + settings: + update: + saved_notice: Vos modifications ont été enregistrées avec succès + users: + update: + profile_updated: Votre profil a été mis à jour avec succès. + destroy: + deleted_notice: Votre compte a été supprimé avec succès. + destroy_failed_alert: Impossible de supprimer votre compte. Veuillez contacter l'assistance. + change_newsletter_status: + redirect_manage_in_settings_alert: Gérez votre newsletter depuis vos paramètres. + newsletter_signup: + honeypot_thanks_notice: Merci pour votre inscription! + email_blank_alert: Veuillez entrer une adresse email valide. + manage_newsletter_notice: Gérez votre newsletter depuis vos paramètres en cochant/décochant la case. + unsubscribe_by_token: + invalid_token_alert: Token de désinscription invalide. + mailers: + account_claim_mailer: + confirmation_email: + subject: Confirmez votre revendication de compte - Le Circographe + passwords_mailer: + reset: + subject: Réinitialisez votre mot de passe + user_mailer: + welcome_by_admin: + subject: 'Bienvenue au Circographe ! ' + welcome_email: + subject: 'Bienvenue au Circographe ! ' + membership_expiration_reminder: + subject: Votre adhésion arrive à expiration ! + activerecord: + errors: + models: + attendance: + attributes: + person_id: + event_interest_taken: est déjà intéressé par cet événement + daily_presence_taken: est déjà marqué présent aujourd'hui + payment_line: + attributes: + item_type: + not_allowed: "%{value} n'est pas un type d'article pris en charge" + user: + attributes: + cgu: + must_accept: Vous devez accepter les CGU pour continuer. + privacy_policy: + must_accept: Vous devez accepter la politique de confidentialité pour continuer. + payment_line: + item_description: + membership_fallback: Adhésion + membership_type_fallback: Type d'adhésion + contribution_fallback: Cotisation + donation_fallback: Donation + descriptions: + membership: Adhésion %{name} + contribution_formula: "%{name} (%{duration})" + membership_type: Adhésion %{name} diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 189b961d..f97de3a4 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -1,22 +1,23 @@ -# French UI strings — Le Circographe (Rails/I18nLocaleTexts compliant) -# -# Controllers use lazy lookups under fr...* unless noted. -# +--- fr: + flash: + generic: + error_detail: 'Erreur: %{message}' account_claims: create: - success: "Demande de réclamation envoyée. Vérifiez vos emails." + success: Demande de réclamation envoyée. Vérifiez vos emails. + rescue_alert: 'Erreur: %{message}' confirm: success: "✅ Compte revendiqué ! Votre historique est maintenant disponible." - + rescue_alert: 'Erreur lors de la réclamation: %{message}' activerecord: errors: models: attendance: attributes: person_id: - event_interest_taken: "est déjà intéressé par cet événement" - daily_presence_taken: "est déjà marqué présent aujourd'hui" + event_interest_taken: est déjà intéressé par cet événement + daily_presence_taken: est déjà marqué présent aujourd'hui payment_line: attributes: item_type: @@ -24,235 +25,376 @@ fr: user: attributes: cgu: - must_accept: "Vous devez accepter les CGU pour continuer." + must_accept: Vous devez accepter les CGU pour continuer. privacy_policy: - must_accept: "Vous devez accepter la politique de confidentialité pour continuer." - + must_accept: Vous devez accepter la politique de confidentialité pour continuer. admin: base: - unauthorized_alert: "Vous n'avez pas accès à cette page." - + unauthorized_alert: Vous n'avez pas accès à cette page. attendances: create: - success: "Présence enregistrée avec succès" + success: Présence enregistrée avec succès destroy: - destroyed: "Présence supprimée avec succès" - failure: "Erreur lors de la suppression" - + destroyed: Présence supprimée avec succès + failure: Erreur lors de la suppression blogs: create: - created: "Blog créé avec succès" + created: Blog créé avec succès update: - updated: "Blog mis à jour avec succès" + updated: Blog mis à jour avec succès destroy: - destroyed: "Blog supprimé avec succès" - + destroyed: Blog supprimé avec succès donations: create: - recorded: "Donation prise en compte" - + recorded: Donation prise en compte events: create: - created: "Événement créé avec succès" + created: Événement créé avec succès update: - updated: "Événement modifié avec succès" + updated: Événement modifié avec succès destroy: - destroyed_notice: "Événement supprimé avec succès" - + destroyed_notice: Événement supprimé avec succès membership_types: create: - html_notice: "Type d'adhésion créé avec succès !" - turbo_notice: "Type d'adhésion créé avec succès !" + html_notice: Type d'adhésion créé avec succès ! + turbo_notice: Type d'adhésion créé avec succès ! update: - html_notice: "Type d'adhésion mis à jour avec succès !" - turbo_notice: "Type d'adhésion mis à jour avec succès !" + html_notice: Type d'adhésion mis à jour avec succès ! + turbo_notice: Type d'adhésion mis à jour avec succès ! destroy: - destroyed: "Type d'adhésion supprimé avec succès !" - + destroyed: Type d'adhésion supprimé avec succès ! memberships: update: - success: "Adhésion mise à jour avec succès." + success: Adhésion mise à jour avec succès. destroy: - deactivated: "Adhésion désactivée avec succès." + deactivated: Adhésion désactivée avec succès. create: - duplicate_active: "Cette personne possède déjà une adhésion active." - success_with_contribution_hint: "Adhésion créée avec succès ! Vous pouvez maintenant ajouter une cotisation depuis la fiche utilisateur." - + duplicate_active: Cette personne possède déjà une adhésion active. + success_with_contribution_hint: Adhésion créée avec succès ! Vous pouvez maintenant ajouter une cotisation depuis la fiche utilisateur. + upgrade_failed_alert: 'Erreur lors de l''upgrade: %{message}' + membership_creation_failed_alert: 'Erreur lors de la création de l''adhésion: %{message}' + upgrade_notice_offered: Adhésion upgradée avec succès ! %{name} - Offert + upgrade_notice_paid: 'Adhésion upgradée avec succès ! %{name} - Montant: %{amount}€' + upgrade_notice_member_number_suffix: " | Numéro d'adhérent changé: %{old_number} → %{new_number}" notepads: edit: - breadcrumb_modify: "Modifier le bloc-note" + breadcrumb_modify: Modifier le bloc-note update: - updated: "Bloc-note mis à jour !" - + updated: Bloc-note mis à jour ! opening_hours: update: - success: "Horaires mis à jour avec succès" - + success: Horaires mis à jour avec succès payments: show: - use_inline_notice: "Utilisez l'édition inline pour modifier les paiements" + use_inline_notice: Utilisez l'édition inline pour modifier les paiements new: - creation_disabled_notice: "Création de paiement temporairement désactivée" + creation_disabled_notice: Création de paiement temporairement désactivée create: - created_notice: "Paiement créé avec succès" + created_notice: Paiement créé avec succès + failure_alert: 'Erreur lors de la création du paiement: %{message}' update: - success_notice: "Mise à jour réussie" + success_notice: Mise à jour réussie + failure_alert: 'Échec de la mise à jour: %{message}' destroy: - cancelled_notice: "Paiement annulé avec succès" + cancelled_notice: Paiement annulé avec succès + cancel_failed_alert: 'Échec de l''annulation du paiement: %{message}' restore: - restored_notice: "Paiement restauré avec succès" - + restored_notice: Paiement restauré avec succès + restore_failed_alert: 'Échec de la restauration du paiement: %{message}' sessions: - rate_limited_alert: "Rééssayez plus tard" + rate_limited_alert: Rééssayez plus tard create: - success: "Connexion réussie !" - invalid_credentials: "Email ou mot de passe invalide" + success: Connexion réussie ! + invalid_credentials: Email ou mot de passe invalide destroy: - signed_out: "Déconnecté avec succès !" - + signed_out: Déconnecté avec succès ! subscription_plans: new: - needs_circus_membership_alert: "Cette personne doit avoir une adhésion Cirque pour acheter des plans de cotisation" + needs_circus_membership_alert: Cette personne doit avoir une adhésion Cirque pour acheter des plans de cotisation create: - purchased: "Plan de cotisation acheté avec succès !" + purchased: Plan de cotisation acheté avec succès ! + purchase_failed_alert: 'Erreur lors de l''achat du plan: %{message}' update: - updated: "Plan de cotisation mis à jour avec succès !" + updated: Plan de cotisation mis à jour avec succès ! destroy: - destroyed: "Plan de cotisation supprimé avec succès !" + destroyed: Plan de cotisation supprimé avec succès ! require_super_admin: - forbidden: "Seul le super-admin peut modifier ou supprimer des cotisations." - + forbidden: Seul le super-admin peut modifier ou supprimer des cotisations. + subscriptions: + upgrade: + success_notice: Cotisation upgradée avec succès. + credit_applied_suffix: " Crédit appliqué: %{amount}€" + failure_alert: 'Erreur lors de l''upgrade: %{message}' users: + create: + invalid_data_alert: 'Données invalides : %{details}' + success_person_user_membership: Personne, compte web et adhésion créés avec succès ! + success_person_user: Personne et compte web créés avec succès ! + success_person_membership: Personne et adhésion créées avec succès ! + success_person_only: Personne créée avec succès ! + error_existing_web_account: Cette personne a déjà un compte web. + error_email_required: Un email est obligatoire pour créer un compte web. show: - merged_person_notice: "Cette fiche a été fusionnée avec une autre. Retour à la liste des utilisateurs." + merged_person_notice: Cette fiche a été fusionnée avec une autre. Retour à la liste des utilisateurs. edit: - user_not_found: "Utilisateur non trouvé." - edit_error: "Une erreur est survenue lors de l'édition de l'utilisateur." + user_not_found: Utilisateur non trouvé. + edit_error: Une erreur est survenue lors de l'édition de l'utilisateur. update: - person_saved_notice: "Informations mises à jour avec succès." - ajax_success_json_message: "Informations mises à jour avec succès." - html_updated: "Utilisateur mis à jour avec succès." - turbo_notice: "Utilisateur mis à jour avec succès." + person_saved_notice: Informations mises à jour avec succès. + ajax_success_json_message: Informations mises à jour avec succès. + html_updated: Utilisateur mis à jour avec succès. + turbo_notice: Utilisateur mis à jour avec succès. destroy: - person_not_found_alert: "Personne non trouvée." - person_deleted_notice: "Personne supprimée avec succès." + person_not_found_alert: Personne non trouvée. + person_deleted_notice: Personne supprimée avec succès. destruction_failed_alert_html: "❌ %{message}" - user_archived_notice: "Utilisateur archivé avec succès." - archive_failed_alert: "Impossible d'archiver cet utilisateur." + user_archived_notice: Utilisateur archivé avec succès. + archive_failed_alert: Impossible d'archiver cet utilisateur. restore: - restored_notice: "Utilisateur restauré avec succès." - restore_failed_alert: "Impossible de restaurer cet utilisateur." + restored_notice: Utilisateur restauré avec succès. + restore_failed_alert: Impossible de restaurer cet utilisateur. set_user: - person_or_user_missing_alert: "Utilisateur non trouvé." + person_or_user_missing_alert: Utilisateur non trouvé. check_deletion_permissions: - higher_privileges: "Impossible de supprimer un utilisateur avec des privilèges égaux ou supérieurs." + higher_privileges: Impossible de supprimer un utilisateur avec des privilèges égaux ou supérieurs. require_super_admin: - restore_denied_alert: "Seul le super-admin peut restaurer des utilisateurs." + restore_denied_alert: Seul le super-admin peut restaurer des utilisateurs. payments: create: - success: "Paiement créé avec succès" + success: Paiement créé avec succès + failure_alert: 'Erreur lors de la création du paiement: %{message}' update: - success: "Paiement mis à jour avec succès" + success: Paiement mis à jour avec succès + failure_alert: 'Erreur lors de la mise à jour: %{message}' destroy: - destroyed: "Paiement supprimé avec succès" + destroyed: Paiement supprimé avec succès + failure_alert: 'Erreur lors de la suppression: %{message}' process_payment: - processed: "Paiement traité avec succès" - already_processed: "Paiement déjà traité" - + processed: Paiement traité avec succès + already_processed: Paiement déjà traité + failure_alert: 'Erreur lors du traitement: %{message}' checkout: success: - payment_ok: "Paiement réussi et commande mise à jour." - payment_failed_alert: "Paiement non réussi, commande non mise à jour." + payment_ok: Paiement réussi et commande mise à jour. + payment_failed_alert: Paiement non réussi, commande non mise à jour. cancel: - cancelled_alert: "Le paiement a été annulé." - + cancelled_alert: Le paiement a été annulé. contacts: create: - blank_fields: "Veuillez remplir tous les champs du formulaire." - sent_notice: "Votre message a été envoyé avec succès ! Nous revenons vers vous rapidement." - send_error: "Une erreur est survenue lors de l'envoi. Réessaie dans quelques instants." - + blank_fields: Veuillez remplir tous les champs du formulaire. + sent_notice: Votre message a été envoyé avec succès ! Nous revenons vers vous rapidement. + send_error: Une erreur est survenue lors de l'envoi. Réessaie dans quelques instants. event_interests: create: - profile_incomplete: "Votre profil n'est pas complet. Veuillez contacter l'administration." - interest_added: "Vous êtes maintenant intéressé par cet événement !" - interest_error: "Erreur lors de l'ajout de votre intérêt" + profile_incomplete: Votre profil n'est pas complet. Veuillez contacter l'administration. + interest_added: Vous êtes maintenant intéressé par cet événement ! + interest_error: Erreur lors de l'ajout de votre intérêt destroy: - interest_removed: "Vous n'êtes plus intéressé par cet événement" - interest_remove_error: "Erreur lors de la suppression de votre intérêt" - + interest_removed: Vous n'êtes plus intéressé par cet événement + interest_remove_error: Erreur lors de la suppression de votre intérêt passwords: create: - reset_instructions_sent_generic: "Instructions de réinitialisation du mot de passe envoyées (si un utilisateur avec cette adresse e-mail existe)." + reset_instructions_sent_generic: Instructions de réinitialisation du mot de passe envoyées (si un utilisateur avec cette adresse e-mail existe). update: - password_reset_success: "Le mot de passe a été réinitialisé." + password_reset_success: Le mot de passe a été réinitialisé. request_reset: - sent_to_email_notice: "Instructions de réinitialisation du mot de passe envoyées à %{email}." - must_be_signed_in_alert: "Vous devez être connecté pour effectuer cette action." - invalid_token_alert: "Le lien de réinitialisation du mot de passe est invalide ou a expiré." - + sent_to_email_notice: Instructions de réinitialisation du mot de passe envoyées à %{email}. + must_be_signed_in_alert: Vous devez être connecté pour effectuer cette action. + invalid_token_alert: Le lien de réinitialisation du mot de passe est invalide ou a expiré. registrations: create: - success_notice: "Inscription réussie !" - + success_notice: Inscription réussie ! sessions: - rate_limited_alert: "Rééssayez plus tard" + rate_limited_alert: Rééssayez plus tard create: - login_success_notice: "Connexion réussie !" - invalid_credentials_flash: "Email ou mot de passe invalide" - invalid_credentials_redirect: "Email ou mot de passe invalide" + login_success_notice: Connexion réussie ! + invalid_credentials_flash: Email ou mot de passe invalide + invalid_credentials_redirect: Email ou mot de passe invalide destroy: - signed_out_notice: "Déconnecté avec succès !" - + signed_out_notice: Déconnecté avec succès ! settings: update: - saved_notice: "Vos modifications ont été enregistrées avec succès" - + saved_notice: Vos modifications ont été enregistrées avec succès users: update: - profile_updated: "Votre profil a été mis à jour avec succès." + profile_updated: Votre profil a été mis à jour avec succès. destroy: - deleted_notice: "Votre compte a été supprimé avec succès." - destroy_failed_alert: "Impossible de supprimer votre compte. Veuillez contacter l'assistance." + deleted_notice: Votre compte a été supprimé avec succès. + destroy_failed_alert: Impossible de supprimer votre compte. Veuillez contacter l'assistance. change_newsletter_status: - redirect_manage_in_settings_alert: "Gérez votre newsletter depuis vos paramètres." + redirect_manage_in_settings_alert: Gérez votre newsletter depuis vos paramètres. newsletter_signup: - honeypot_thanks_notice: "Merci pour votre inscription!" - email_blank_alert: "Veuillez entrer une adresse email valide." - manage_newsletter_notice: "Gérez votre newsletter depuis vos paramètres en cochant/décochant la case." + honeypot_thanks_notice: Merci pour votre inscription! + email_blank_alert: Veuillez entrer une adresse email valide. + manage_newsletter_notice: Gérez votre newsletter depuis vos paramètres en cochant/décochant la case. unsubscribe_by_token: - invalid_token_alert: "Token de désinscription invalide." - + invalid_token_alert: Token de désinscription invalide. payment_line: item_description: - membership_fallback: "Adhésion" - membership_type_fallback: "Type d'adhésion" - contribution_fallback: "Cotisation" - donation_fallback: "Donation" + membership_fallback: Adhésion + membership_type_fallback: Type d'adhésion + contribution_fallback: Cotisation + donation_fallback: Donation descriptions: - membership: "Adhésion %{name}" + membership: Adhésion %{name} contribution_formula: "%{name} (%{duration})" - membership_type: "Adhésion %{name}" - + membership_type: Adhésion %{name} services: web: user_registration: - accept_cgu: "Vous devez accepter les CGU pour continuer." - accept_privacy: "Vous devez accepter la politique de confidentialité pour continuer." - + accept_cgu: Vous devez accepter les CGU pour continuer. + accept_privacy: Vous devez accepter la politique de confidentialité pour continuer. + validation: + invalid_data: Données invalides + invalid_data_with_details: 'Données invalides : %{details}' mailers: account_claim_mailer: confirmation_email: - subject: "Confirmez votre revendication de compte - Le Circographe" - + subject: Confirmez votre revendication de compte - Le Circographe passwords_mailer: reset: - subject: "Réinitialisez votre mot de passe" - + subject: Réinitialisez votre mot de passe user_mailer: welcome_by_admin: - subject: "Bienvenue au Circographe ! " + subject: 'Bienvenue au Circographe ! ' welcome_email: - subject: "Bienvenue au Circographe ! " + subject: 'Bienvenue au Circographe ! ' membership_expiration_reminder: - subject: "Votre adhésion arrive à expiration !" + subject: Votre adhésion arrive à expiration ! + breadcrumbs: + admin: + common: + administration: Administration + dashboard: Tableau de bord + edit: Modifier + users: + members_list: Liste d'adhérents + create_web_account: Créer un compte web + new_member: Nouvel adhérent + memberships: + management: Gestion des Adhésions + membership: Adhésion + upgrade_to_circus: Upgrade vers Cirque + new_membership: Nouvelle adhésion + edit_membership: Modifier adhésion + payments: + history: Historique des paiements + management: Gestion des paiements + subscription_plans: + plans: Plans de cotisation + plan_named: 'Plan : %{name}' + new_contribution: Nouvelle cotisation + edit_named: 'Modifier : %{name}' + opening_hours: + title: Horaires d'ouverture + events: + events: Événements + new_event: Nouvel événement + attendances: + management: Gestion des présences + attendance_number: 'Présence #%{id}' + new_attendance: Nouvelle présence + membership_types: + types: Types d'Adhésion + type_named: 'Type : %{name}' + new_type: Nouveau type d'adhésion + edit_named: 'Modifier : %{name}' + attendance_lists: + lists: Listes de présence + new_list: Nouvelle liste + health_reports: + integrity_report: Rapport d'intégrité + helpers: + admin: + users: + display: + not_provided: Non renseigné + no_email: Pas d'email + name_not_provided: Nom non renseigné + layouts: + application: + title: Le Circographe + views: + shared: + flash: + close: Fermer + navbar: + open_menu: Ouvrir le menu + place: Le Lieu + circus: Le Cirque + graphic_arts: Les Arts Graphiques + how_it_works: Fonctionnement + activities: Nos Activités + join: Adhérer + about: À Propos + contact: Nous contacter + faq: F.A.Q + my_space: Mon Espace + user: Utilisateur + profile: Profil + dashboard: Tableau de bord + settings: Paramètres + logout: Déconnexion + sign_in: Se connecter + sign_up: S'inscrire + association: L'Association + news: Actualités + our_news: Nos actualités + blog_newsletters: Blog & Newsletters + photo_gallery: Galerie Photos + events: Nos Événements + footer: + newsletter_cta: Restez informé en souscrivant à notre newsletter + email_placeholder: Votre Adresse Email + sign_up: S'inscrire + discover: Découvrir + about: À propos + place: Le Lieu + activities: Nos activités + participate: Participer + join: Adhérer + contact: Nous contacter + map: Plan d’accès + resources: Ressources + faq: F.A.Q + terms: Mentions légales + privacy: Politique de confidentialité + brand: Le Circographe + all_rights_reserved: Tous Droits Réservés. + admin: + users: + new: + page_title: Créer un nouvel utilisateur + page_subtitle: Ajoutez un nouvel utilisateur au système + user_information: Informations de l'utilisateur + create_web_account: Créer un compte web + create_web_account_help: Permettre à cette personne de se connecter et gérer son profil en ligne + system_role: Rôle système + roles: + web_visitor: Visiteur web + volunteer: Bénévole + admin: Administrateur + temporary_password_notice: Un mot de passe temporaire sera généré et envoyé par email + index: + page_title: Gestion des Adhérents + page_subtitle: Interface unifiée pour gérer les personnes, adhésions et cotisations + statistics: Statistiques + total_people: Total personnes + new_yesterday: Nouveaux (hier) + basic_memberships: Adhésions Basic + circus_memberships: Adhésions Circus + active_memberships: Adhésions actives + without_account: Sans compte + create_member: Créer un Adhérent + pages: + about: + page_title: Le Circographe - À propos + join_community: Rejoindre la communauté + heading: À propos du Circographe + hero_description: Laboratoire de pratiques artistiques partagées où cirque et arts graphiques se rencontrent — autogestion et solidarité. + join: Nous rejoindre + discover_place: Découvrir le lieu + our_mission: Notre mission + mission_heading: Un espace de création partagé et accessible + mission_text_1: Offrir un espace de pratique, de création et de transmission accessible à toutes et tous. + mission_text_2: 'Gouvernance autogérée : les membres s’engagent bénévolement, partagent les clés et co-programment la vie du lieu.' diff --git a/db/migrate/20260430204500_backfill_user_person_and_enforce_not_null.rb b/db/migrate/20260430204500_backfill_user_person_and_enforce_not_null.rb new file mode 100644 index 00000000..77f10e28 --- /dev/null +++ b/db/migrate/20260430204500_backfill_user_person_and_enforce_not_null.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class BackfillUserPersonAndEnforceNotNull < ActiveRecord::Migration[8.1] + class MigrationUser < ApplicationRecord + self.table_name = "users" + end + + class MigrationPerson < ApplicationRecord + self.table_name = "people" + end + + def up + MigrationUser.where(person_id: nil).find_each do |user| + person = MigrationPerson.create!( + first_name: "Web", + last_name: "User", + email: user.email_address + ) + user.update_columns(person_id: person.id) + end + + change_column_null :users, :person_id, false + end + + def down + change_column_null :users, :person_id, true + end +end diff --git a/db/schema.rb b/db/schema.rb index ec4bd163..968cadbf 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_27_092706) do +ActiveRecord::Schema[8.1].define(version: 2026_04_30_204500) do create_table "account_claims", force: :cascade do |t| t.string "confirmation_token", null: false t.datetime "created_at", null: false @@ -360,7 +360,7 @@ t.datetime "password_reset_sent_at" t.string "password_reset_token" t.string "password_salt" - t.bigint "person_id" + t.bigint "person_id", null: false t.integer "system_role", default: 3, null: false t.datetime "updated_at", null: false t.index ["deleted"], name: "index_users_on_deleted" diff --git a/docs/README.md b/docs/README.md index cc70cd58..3d8883ae 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ > **Statut** : stable > **Public cible** : contributeur, équipe -> **Dernière vérification** : 2026-04-27 +> **Dernière vérification** : 2026-05-01 > **Sources de vérité** : structure réelle du dossier `docs/`. Index de la documentation Markdown du projet. Pour le démarrage et le déploiement, voir le [`README.md`](../README.md) à la racine. @@ -77,7 +77,7 @@ Chaque document Markdown déclare un statut dans son header : ## Domaine et vocabulaire - [`glossary.md`](glossary.md) — lexique canonique FR/EN, termes interdits. -- [`domain_model.md`](domain_model.md) — diagramme Mermaid + responsabilités des agrégats. +- [`domain_model.md`](domain_model.md) — diagramme Mermaid + responsabilités des agrégats (invariant `User` → `Person`). - [`payments.md`](payments.md) — `Payment`, `PaymentLine`, `Donation` et la dette legacy `item_type:"Payment"`. - [`domain/business_logic.md`](domain/business_logic.md) — règles métier complètes (adhésion, cotisation, paiements). diff --git a/docs/architecture/models.md b/docs/architecture/models.md index 0b1ad4f0..9ebf0632 100644 --- a/docs/architecture/models.md +++ b/docs/architecture/models.md @@ -2,7 +2,7 @@ > **Statut** : stable > **Public cible** : contributeur -> **Dernière vérification** : 2026-04-27 +> **Dernière vérification** : 2026-05-01 > **Sources de vérité** : `app/models/`, `app/models/concerns/`, `db/schema.rb`, `spec/models/`. > **Vocabulaire DDD-light** (voir [`../glossary.md`](../glossary.md)) @@ -20,7 +20,7 @@ Ce document remplace l'ancien trio `docs/MODEL_EVALUATION.md` + `docs/CONCERNS_A ``` Person (CRM, données personnelles) - ├─> User (authentification, optionnel) + ├─> User (authentification — au plus un ; tout User a une Person) ├─> Membership (adhésion annuelle) ├─> Payment (transactions) ├─> BookOfEntry (cible : Contribution — cotisation cirque) @@ -33,9 +33,10 @@ Les formules de cotisation sont stockées dans `SubscriptionPlan` *(cible : `Con ### Points forts confirmés - Séparation claire **auth** (`User`) vs **profil** (`Person`). +- **Invariant** : `User` → `Person` obligatoire (`person_id` NOT NULL) ; `Person` peut exister sans `User`. - One Source of Truth pour les données personnelles. - Délégation propre `User → Person`. -- Soft-delete `Person` sans perdre `User`. +- Côté `Person`, `has_one :user, dependent: :restrict_with_error` : pas de suppression incompatible tant qu’un compte web existe (archive / RGPD). - `Payment` → `PaymentLine` polymorphique = un paiement peut regrouper adhésion + cotisation + don. - Audit trail complet via `PaymentAuditLog` + UUID externe. - Versioning sur `MembershipType` et `SubscriptionPlan` *(cible : `ContributionFormula`)* (`version`, `effective_from/until`, `change_reason`, `created_by_user_id`). @@ -136,7 +137,7 @@ Cadre de **stabilité / risque** pour prioriser les tests. Pour les priorités a #### Zone 2 — fonctionnels mais à stabiliser -`Web::UserRegistration`, `People::Register`, `People::PaymentUpdater`, `People::PaymentCanceller`, `People::PaymentRestorer`, `People::AccountLinker`, `UserManagement::UserDeleter`, `People::AccountMerger`. +`Web::UserRegistration`, `People::Register`, `People::PaymentUpdater`, `People::PaymentCanceller`, `People::PaymentRestorer`, `People::AttachUserToPerson`, `People::AccountLinker`, `UserManagement::UserDeleter`, `People::AccountMerger`. > Les classes `EventManagement::EventCreator/Updater/Deleter` existent dans `app/services/event_management/` mais sont **orphelines** : aucun contrôleur ne les appelle (CRUD inline dans `Admin::EventsController`). Cleanup tracé dans [`../internal/todo.md`](../internal/todo.md). diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 13429780..3e29cd96 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -2,7 +2,7 @@ > **Statut** : stable > **Public cible** : contributeur -> **Dernière vérification** : 2026-04-27 +> **Dernière vérification** : 2026-05-01 > **Sources de vérité** : `app/models/person.rb`, `app/models/user.rb`, `app/components/`, `app/services/people/`. > **Vocabulaire** : le composant a été renommé `contribution_status_badge_component` (cible). Les services `People::Subscription*` restent à renommer en `People::Contribution*` lors de la migration DB. Voir [`../glossary.md`](../glossary.md). @@ -24,12 +24,12 @@ Ce document consolide les bonnes pratiques et l'architecture mise en place lors ## 👤 Person / User - Règles de Cycle de Vie - **Person = source de vérité** pour l'identité et la finance. -- **User = compte web** (authentification + permissions), optionnel. +- **User = compte web** (authentification + permissions). **Données : chaque User a une Person** (`person_id` NOT NULL) ; le compte web reste **optionnel au niveau métier** pour une Person donnée (CRM sans login). - **Cas supportés** : - Person sans User (inscription IRL d'abord). - - User sans Person (inscription web d'abord). -- **Lien explicite uniquement** : le lien User ↔ Person se fait via un service dédié. -- **Pas de reliaison implicite** si une Person a déjà un User lié. + - Inscription web : création d’un **couple User + Person** (personne minimale puis enrichissement / rattachement à une fiche existante). +- **Lien / rattachement explicite** : `People::AttachUserToPerson` (nominal), `People::AccountLinker` (orchestration), jamais d’assign direct dans un controller. +- **Pas de reliaison implicite** si une Person a déjà un User lié (garde-fous dans `AttachUserToPerson`). - **Pas d'orphelins financiers** : paiements et adhésions restent rattachés à la Person. --- @@ -37,7 +37,7 @@ Ce document consolide les bonnes pratiques et l'architecture mise en place lors ## 🧭 Service Entry Points (Flux Unifiés) - **Création Person / User / Membership** : `People::Register` -- **Lien User ↔ Person** : `People::AccountLinker` +- **Rattachement User ↔ Person** : `People::AttachUserToPerson` ; **orchestration** : `People::AccountLinker` - **Achat adhésion** : `People::MembershipCreator` - **Achat cotisation** : `People::SubscriptionCreator` *(cible : `People::ContributionCreator`)* - **Mise à jour User + Person** : `UserManagement::UserUpdater` diff --git a/docs/architecture/services.md b/docs/architecture/services.md index 1097c7f3..f5516f71 100644 --- a/docs/architecture/services.md +++ b/docs/architecture/services.md @@ -2,7 +2,7 @@ > **Statut** : stable > **Public cible** : contributeur -> **Dernière vérification** : 2026-04-27 +> **Dernière vérification** : 2026-05-01 > **Sources de vérité** : `app/services/people/`, `app/services/event_management/`, `app/services/attendance_management/`, `app/services/user_management/`. **Création initiale:** 2025-01-31 @@ -23,17 +23,18 @@ Les services suivent le pattern **Service Object avec ActiveModel::Model** : ### Person (Entity) vs User (Account) - **Person = Entity CRM** : fiche métier unique qui contient l'identité, l'historique financier (adhésions, cotisations, paiements) et tous les attributs d’usage. -- **User = Account** : accès web optionnel (email, mot de passe, rôle) qui délègue tous ses attributs de profil à `Person` via `delegate`. +- **User = Account** : accès web (email, mot de passe, rôle) qui délègue tous ses attributs de profil à `Person` via `delegate`. **Données : tout `User` a une `Person`** (`belongs_to :person`, NOT NULL) ; à la création web sans fiche existante, une `Person` minimale est créée par callback sur `User`. - **Règles clés** : - Créer/éditer la fiche métier via `People::Register` / `People::PersonCreator` ; le compte web est créé via `People::UserAccountCreator` si besoin. - - Supprimer un `User` ne détruit pas la `Person` (relation `has_one :user, dependent: :nullify`). + - Rattacher un compte existant à une fiche CRM : **`People::AttachUserToPerson`** (nominal) ; **`People::AccountLinker`** encapsule attach + nettoyage éventuel (`People::AccountMerger`). + - Supprimer un `User` ne détruit pas la `Person`. Côté `Person`, `has_one :user, dependent: :restrict_with_error` empêche une suppression de fiche incompatible tant qu’un `User` existe — passer par archive / RGPD. - Supprimer une `Person` passe par `SoftDeletable` (`Person#archive!`) avec garde-fous financiers (`has_financial_data?`). - Toutes les opérations financières (`People::Payment*`, `People::Subscription*` *(cible : `People::Contribution*`)*, `People::Register`) travaillent **exclusivement** sur `Person`. Cette séparation “Entity / Account” garantit : - pas de perte d’historique quand un utilisateur supprime son compte web, - la possibilité de gérer des personnes sans compte web (inscriptions papier, mineurs, bénévoles), -- une liaison safe quand un compte web est créé après coup ou par l’admin. +- une liaison explicite et auditée (`AttachUserToPerson` / `AccountLinker`) quand un compte web doit être relié à une fiche existante. ## Organisation par Domaine @@ -66,17 +67,18 @@ Cette séparation “Entity / Account” garantit : - `Admin::Users::PaymentsController` (create, update, destroy via `People::PaymentCreator` multi-lignes) **Donations — état actuel et cible** -- **Cible** : une donation est une `PaymentLine` avec `item_type: "Donation"`. Aucune `PaymentLine` ne doit avoir `item_type: "Payment"`. -- **Code actuel** : `People::PaymentCreator` réécrit silencieusement les lignes de don en `item_type: "Payment"` et `item_id: payment.id` (cf. `app/services/people/payment_creator.rb` L92). Cette dette technique est tracée dans `phase1-donation-fix` (voir [`../payments.md`](../payments.md)). -- **Comportement attendu côté appelant** : passer `item_type: "Donation"` et `item_id: payment.id` (ou `person.id` selon le flow). Le service fait le reste — y compris la réécriture legacy temporaire. +- **Cible** : une donation est une `PaymentLine` avec `item_type: "Donation"` et `item_id` adapté (souvent `payment.id` pour les dons « libres »). Aucune nouvelle ligne ne doit utiliser `item_type: "Payment"` pour un don. +- **Code actuel** : `People::PaymentCreator` garde `item_type: "Donation"` sur la ligne simple lorsque le flux est un don (`donation_line?`). Les anciennes lignes en base peuvent encore être `item_type: "Payment"` — backfill / reporting : voir [`../payments.md`](../payments.md) et `phase1-donation-fix`. +- **Comportement attendu côté appelant** : passer `item_type: "Donation"` (ou laisser le défaut du service) ; fournir montants cohérents avec `total_cents`. - **Validation** : la somme des `payment_lines` doit égaler `total_cents` ; sinon, `failure`. -### ✅ People::AccountLinker (Support CRM) -- `People::AccountLinker` relie un compte web existant à une fiche CRM (`people.account_linked`) -- Utilisé par scripts de maintenance (`scripts/fix_person_user_merge.rb`) +### ✅ People::AttachUserToPerson & People::AccountLinker (liaison CRM) +- **`People::AttachUserToPerson`** : rattache un `User` à une `Person` cible (refuse si la cible a déjà un autre `User`), instrumentation `people.user_attached`. +- **`People::AccountLinker`** : orchestration (attach via `AttachUserToPerson`, merge optionnel de l’ancienne fiche avec `People::AccountMerger`). Événement `people.account_linked`. +- Utilisé aussi par `AccountClaimManagement::AccountClaimConfirmer` (attach direct possible) et scripts (`scripts/fix_person_user_merge.rb`). ### ⚠️ PersonManagement (Legacy ciblé) -- Ancien namespace conservé uniquement pour compatibilité. Les nouvelles fusions doivent passer par `People::AccountLinker` ou `People::AccountMerger`. +- Ancien namespace conservé uniquement pour compatibilité. Les nouvelles fusions / liaisons doivent passer par `People::AttachUserToPerson`, `People::AccountLinker` ou `People::AccountMerger`. ### ✅ UserManagement (Stable) - `UserDeleter` - Suppression d'utilisateurs (Person) diff --git a/docs/design/color_system.md b/docs/design/color_system.md index bb2c4b17..9050203b 100644 --- a/docs/design/color_system.md +++ b/docs/design/color_system.md @@ -186,7 +186,7 @@ Exemple : 2. Appliquer les étapes ci-dessus progressivement (commit par step). 3. Tester en local : - `bin/dev` + vérifier pages `home`, `faq`, `about`, `news`, formulaires. - - `bin/rails test` pour s’assurer qu’aucune régression JavaScript. + - `bundle exec rspec` pour s’assurer qu’aucune régression applicative. 4. Ouvrir une PR pour revue, intégrer feedback, merger une fois validé. ## 10. Bonnes Pratiques diff --git a/docs/development/testing.md b/docs/development/testing.md index c8fdbd65..dcd9a4b9 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -310,10 +310,10 @@ bundle exec rspec bundle exec rubocop app --only Rails/HelperInstanceVariable --force-exclusion ``` -- **Prévoir l’auto-correction sans écrire** : +- **Prévisualiser un lot de cops sans écrire** : ```bash - bundle exec rubocop -A --dry-run --force-exclusion + bundle exec rubocop --only NomDuCop chemins... --force-exclusion ``` Puis corriger avec `bundle exec rubocop -a` (**safe**) ou `-A` (plus agressif — à utiliser avec prudence). @@ -436,10 +436,14 @@ L'ensemble des services `People::*`, `AccountClaimManagement::*`, `AttendanceMan ### RuboCop — rollout progressif (Phase 2+) -- **Baseline** : `rubocop-rails-omakase` dans [`.rubocop.yml`](../../.rubocop.yml) comme **seule** base héritée ; exclusions projet documentées (permanent / temporaire) + override Ezam `Layout/EndOfLine: lf`. Le dossier `test/**/*` est **linté** avec le reste du Ruby applicatif (`spec/` l’était déjà). +- **Baseline** : `rubocop-rails-omakase` dans [`.rubocop.yml`](../../.rubocop.yml) comme **seule** base héritée ; exclusions projet documentées (permanent / temporaire) + override Ezam `Layout/EndOfLine: lf`. Le projet est en mode **RSpec-only** et le dossier legacy `test/` est retire. -- **Lot B2 (élargissement)** — périmètre + conventions Rails cumulées : `test/**/*` inclus ; cops `Rails/` activés localement avec prudence (Omakase laisse la plupart du département `Rails` désactivé) — actuellement : `Rails/HttpStatus`, `Rails/Output`, `Rails/Delegate`, `Rails/StrongParametersExpect`, `Rails/PluralizationGrammar`, `Rails/UniqBeforePluck`, `Rails/HelperInstanceVariable`, `Rails/I18nLocaleTexts` (messages utilisateur en `t()` / YAML, pas de chaînes littérales dans contrôleurs, mailers et modèles). Étendre la liste uniquement via petites PR après `bundle exec rubocop` / `bundle exec rspec` verts. +- **Lot B2 (historique)** — le rollout `Rails/*` a ete fait par petits lots. Aujourd'hui, conserver la meme approche: petites PR, puis `bundle exec rubocop` + `bundle exec rspec` verts. - **Specs et locale** : `config.i18n.default_locale` est `:fr` ; les messages ActiveRecord / `number_to_currency` suivent `rails-i18n` (fr). Dans les tests de validations, préférer `I18n.t('errors.messages.blank')`, `I18n.t('errors.messages.required')`, etc., plutôt que du texte anglais codé en dur ; pour les flashes ou sujets de mail, utiliser `I18n.t('…')` avec la même clé que l’application. +- **Parite FR/EN** : `config/locales/en.yml` est maintenu en parite de cles avec `config/locales/fr.yml`. Utiliser `bundle exec rake i18n:check_keys` avant merge. +- **Auth** : la stack d'authentification reste le systeme natif Rails 8 ; ne pas introduire Devise. +- **Fallback transitoire** : `config.i18n.fallbacks = { en: %i[fr] }` est volontairement temporaire pendant la complétion de `config/locales/en.yml`. Objectif long terme : parité `fr/en` sans fallback implicite pour l’UX anglaise. +- **Parité des clés locales** : utiliser `bundle exec rake i18n:check_keys` pour lister les clés manquantes entre `fr.yml` et `en.yml` et prioriser les traductions manquantes. - **Jobs** : [`.github/workflows/ci-lint-audit.yml`](../../.github/workflows/ci-lint-audit.yml) (`lint`) et [`.github/workflows/ci-auto-lint.yml`](../../.github/workflows/ci-auto-lint.yml) — RuboCop est **bloquant** lorsque la baseline est verte (`bundle exec rubocop --format github --force-exclusion`). - **Élargissement** : activer les cops **par petits lots**, une PR par lot ; pas de refactors métier ni renommage de vocabulaire domaine sans accord ([glossaire](../glossary.md)). - **Lots suivants suggérés** : après vérif des offenses — LOW (`Style/TrailingCommaInArrayLiteral` / `HashLiteral` si pertinent), puis MEDIUM (`Performance/*`, `Rails/*` au cas par cas), puis HIGH (`Lint/*` sur flux, `Metrics/*`, fichiers `app/models` / `app/services` sensibles) en revue manuelle uniquement. @@ -490,3 +494,11 @@ L'ensemble des services `People::*`, `AccountClaimManagement::*`, `AttendanceMan - [`../architecture/services.md`](../architecture/services.md) — catalogue de services. - [`../architecture/controllers.md`](../architecture/controllers.md) — état des contrôleurs. - [`../architecture/models.md`](../architecture/models.md) — modèles, concerns, zones de stabilité. + +## 11. Audit `Rails/SkipsModelValidations` (classification) + +- `risky_bypass` (priorité traitée): `app/models/user.rb` (anonymisation), suivi ciblé `app/models/payment.rb`. +- `intentional_batch` (gardé sous transaction + commentaire explicite): `app/services/people/account_merger.rb`, `app/models/concerns/versionable.rb`, `app/models/concerns/duplicatable.rb`. +- `legacy/archive` (exclusion ciblée possible): `docs/rake_archive/migrate_to_person_architecture.rake`. +- `consolidated_path`: la fusion applicative doit passer par `People::AccountMerger` (éviter les duplications de logique dans d'autres services). +- `identity_invariant`: en cible projet, un `User` est toujours rattaché à une `Person` (phase de verrouillage en cours). diff --git a/docs/domain/business_logic.md b/docs/domain/business_logic.md index ab0731f9..2fde1cfd 100644 --- a/docs/domain/business_logic.md +++ b/docs/domain/business_logic.md @@ -2,7 +2,7 @@ > **Statut** : stable > **Public cible** : contributeur, métier -> **Dernière vérification** : 2026-04-27 +> **Dernière vérification** : 2026-05-01 > **Sources de vérité** : `app/models/person.rb`, `app/models/membership.rb`, `app/services/people/*.rb`, `db/seeds/membership_types.rb`, `db/seeds/subscription_plans.rb`. **Application:** Gestion complète pour association de cirque @@ -181,12 +181,13 @@ enum system_role: [:super_admin, :admin, :volunteer, :web_visitor] #### Person Architecture (Nouvelle) - **Entity / Account pattern:** - **Person = Entity CRM** (identité unique, historique financier, soft delete via `SoftDeletable`). - - **User = Account** (accès web optionnel) qui référence une `Person` existante (`belongs_to :person`). + - **User = Account** (accès web) **toujours lié à une `Person`** (`belongs_to :person`, NOT NULL). Une Person peut exister sans User ; l’inverse non. - **Conséquences :** - - Création front : on `find_or_create_by` Person avant de créer User. + - Création web : `People::Register` / `Web::UserRegistration` ou équivalent ; sinon callback sur `User` crée une **Person minimale** si absente. - Création admin : `People::Register` orchestre Person + User (+ Membership optionnel) ; `People::PersonCreator` disponible pour les scripts. + - Rattachement / enrichissement : `People::AttachUserToPerson`, `People::AccountLinker`, fusions `People::AccountMerger`. - Suppression User : coupe l’accès web (`destroy`), la Person et ses paiements restent. - - Suppression Person : passe par `UserManagement::UserDeleter` qui archive la Person (`Person#archive!`) seulement si aucune donnée financière (sauf super_admin). + - Suppression Person : passe par `UserManagement::UserDeleter` qui archive la Person (`Person#archive!`) seulement si aucune donnée financière (sauf super_admin). Tant qu’un `User` existe, `Person` ne peut pas être détruite implicitement (`restrict_with_error`). - **Délégation:** User délègue attributs à Person (`delegate :full_name, :phone, ...`). #### Tarifs Réduits @@ -641,7 +642,7 @@ Admin::UserCreationForm | `PersonManagement::PersonCreator` (backend, web) | `People::PersonCreator` | ❌ Supprimé | | `UserManagement::AccountCreator` | `People::UserAccountCreator` | ❌ Supprimé | | `UserManagement::UserCreator` | `People::UserAccountCreator` | ✅ Branché | -| `People::AccountLinker` (script merge) | `People::AccountLinker` | ✅ Branché | +| `People::AccountLinker` (script merge) | `People::AccountLinker` (+ `People::AttachUserToPerson`) | ✅ Branché | | `MembershipManagement::MembershipCreator` | `People::MembershipCreator` | ❌ Supprimé | | `MembershipManagement::MembershipUpgrader` | `People::MembershipUpgrader` | ❌ Supprimé | | `MembershipManagement::MembershipUpdater` | `People::MembershipUpdater` | ❌ Supprimé | @@ -689,8 +690,8 @@ Admin::UserCreationForm ## Architecture Actuelle -**Person-Based:** User → Person (relation 1-1) -**Résultat:** Séparation données authentification vs profil +**Person-Based:** `User` `belongs_to` `Person` (obligatoire) ; `Person` `has_one` `User` (optionnel). Relation 1‑to‑0..1 du point de vue Person. +**Résultat:** Séparation données authentification vs profil, sans « User sans Person » en base. ## Concerns Utilisés diff --git a/docs/domain_model.md b/docs/domain_model.md index cd295dae..2da81c59 100644 --- a/docs/domain_model.md +++ b/docs/domain_model.md @@ -2,7 +2,7 @@ > **Statut** : stable > **Public cible** : contributeur -> **Dernière vérification** : 2026-04-27 +> **Dernière vérification** : 2026-05-01 > **Sources de vérité** : `db/schema.rb`, `app/models/person.rb`, `app/models/membership.rb`, `app/models/payment.rb`, `app/models/payment_line.rb`. > Vocabulaire utilisé : voir [glossary.md](glossary.md). Quand le code n'est pas encore aligné sur le vocabulaire cible, l'alias legacy est indiqué entre parenthèses. @@ -14,7 +14,7 @@ ```mermaid erDiagram - Person ||--o| User : "compte web (optionnel)" + Person ||--o| User : "≤1 compte web par Person" Person ||--o{ Membership : "souscrit" Person ||--o{ Contribution : "achète" Person ||--o{ Attendance : "présence" @@ -40,7 +40,7 @@ erDiagram > **Légende** : > - `Contribution` : code actuel `BookOfEntry` (rename planifié `phase3-model-rename`). > - `ContributionFormula` : code actuel `SubscriptionPlan` (rename planifié `phase3-model-rename`). -> - `Donation` : pas encore matérialisé en modèle distinct ; dette legacy `item_type: "Payment"` à éliminer (voir [payments.md](payments.md)). +> - `Donation` : pas encore un modèle ActiveRecord dédié ; lignes `PaymentLine` en `"Donation"` à la création ; legacy DB `item_type: "Payment"` à éliminer (voir [payments.md](payments.md)). --- @@ -49,6 +49,7 @@ erDiagram ### 2.1 Personnes et comptes #### `Person` — Aggregate Root CRM +- **Compte web** : `has_one :user, dependent: :restrict_with_error` — au plus un `User` ; pas de suppression « dure » de la fiche tant qu'un compte web existe (flux RGPD / archive à utiliser). - **Identité** : `full_name`, `phone`, `email`, `address`, `birth_date`. - **Tarif réduit** : `reduced_rate_eligible`, `reduced_rate_reason`, `reduced_rate_proof`. - **Soft delete** : `deleted_at` (concern `SoftDeletable`). @@ -61,10 +62,12 @@ erDiagram - `Person#archive!` / `Person#restore!`. - **Garde-fou** : `has_financial_data?` empêche la suppression dure si l'historique financier est non vide. -#### `User` — Compte web (optionnel) +#### `User` — Compte web +- **Invariant** : `belongs_to :person` **obligatoire** en base (`users.person_id` NOT NULL). À la création sans `person` explicite, le modèle attache une `Person` minimale (prénom/nom stub + email aligné sur le compte). - **Authentification** : email, password, sessions, password_reset_token. - **Rôle** : `system_role` enum `:super_admin | :admin | :volunteer | :web_visitor`. - **Délégation** : `delegate :full_name, :phone, ... to: :person`. +- **Liaison / fusion** : rattachement nominal `People::AttachUserToPerson` ; orchestration admin/scripts `People::AccountLinker` ; fusion de fiches `People::AccountMerger`. - **Soft delete** : `User#archive!` (admin uniquement). --- @@ -117,7 +120,7 @@ erDiagram - **Invariant** : `payment.payment_lines.sum(:amount_cents) == payment.total_cents`. #### `Donation` (cible) -- Pas encore un modèle distinct. Représenté actuellement par une `PaymentLine` avec `item_type: "Payment"` (réécriture par `People::PaymentCreator`). Migration en `phase1-donation-fix` (voir [payments.md](payments.md)). +- Pas encore un modèle ActiveRecord distinct. Représenté par une `PaymentLine` avec `item_type: "Donation"` à la création (`People::PaymentCreator`). Des lignes historiques peuvent encore avoir `item_type: "Payment"` jusqu’au backfill complet — voir [payments.md](payments.md) et `phase1-donation-fix`. --- @@ -220,6 +223,8 @@ sequenceDiagram Reg-->>UI: success(person, user?, membership?) ``` +> **Inscription web** : les flux qui ne passent pas par `People::Register` créent tout de même un couple `User` + `Person` via le callback `User` (personne minimale), puis enrichissement CRM au fil du temps. + --- ## 5. Documents liés @@ -228,4 +233,4 @@ sequenceDiagram - [payments.md](payments.md) — détail Payment / PaymentLine / Donation. - [migrations/vocabulary_migration.md](migrations/vocabulary_migration.md) — mapping ancien → nouveau. - [domain/business_logic.md](domain/business_logic.md) — règles métier complètes. -- [architecture/services.md](architecture/services.md) — services `People::*` et orchestrateurs. +- [architecture/services.md](architecture/services.md) — services `People::*` et orchestrateurs (`Register`, `AttachUserToPerson`, `AccountLinker`, paiements). diff --git a/docs/glossary.md b/docs/glossary.md index 1085e34c..67b5adc6 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -2,7 +2,7 @@ > **Statut** : stable (canonique) > **Public cible** : contributeur, métier -> **Dernière vérification** : 2026-04-27 +> **Dernière vérification** : 2026-05-01 > **Sources de vérité** : `app/models/*.rb`, `db/schema.rb`, [`migrations/vocabulary_migration.md`](migrations/vocabulary_migration.md). > **Source de vérité unique** pour le vocabulaire du domaine. Toute nouvelle PR (code, doc, UI, tests) doit utiliser ce vocabulaire. Les termes listés en section « Termes interdits » sont rejetés en revue. Utilisé pendant la migration `phase0` → `phase4`. @@ -24,8 +24,8 @@ - Confondre `Person` avec `User`. #### Compte web — `User` -- **Définition** : compte d'authentification web (email, mot de passe, rôle système). **Optionnel** — relié à une `Person` existante via `belongs_to :person`. Tous les attributs de profil (`full_name`, `phone`…) sont délégués vers `Person`. -- **Cycle de vie** : créé via `People::UserAccountCreator` (souvent dans `People::Register`). Sa suppression (`User#destroy`) coupe l'accès web mais laisse intact le `Person` et son historique. +- **Définition** : compte d'authentification web (email, mot de passe, rôle système). **Chaque `User` est obligatoirement relié à une `Person`** (`belongs_to :person`, `person_id` NOT NULL) ; une `Person` peut exister **sans** `User` (adhésion papier, mineurs, etc.). Tous les attributs de profil (`full_name`, `phone`…) sont délégués vers `Person`. +- **Cycle de vie** : créé via `People::UserAccountCreator` ou callback sur `User` (personne minimale) puis enrichissement CRM ; rattachement explicite via `People::AttachUserToPerson` / `People::AccountLinker`. La suppression du compte (`User#destroy`) coupe l'accès web mais laisse intact le `Person` et son historique (tant que les flux RGPD ne désactivent pas autrement). - **Usage correct** : - « Cette personne n'a pas encore de compte web ». - « Le compte web a été archivé, la fiche personne reste active ». @@ -104,15 +104,15 @@ - `"MembershipType"` → renouvellement / achat sur le catalogue. - `"ContributionFormula"` (cible) / `"SubscriptionPlan"` (legacy) → achat d'une cotisation. - `"Contribution"` (cible) / `"BookOfEntry"` (legacy, rare) → cotisation existante. - - `"Donation"` → don (cible). **Attention** : actuellement `People::PaymentCreator` réécrit les lignes de don en `item_type: "Payment"` (dette technique tracée — voir [payments.md](payments.md)). + - `"Donation"` → don. **Création** : `People::PaymentCreator` conserve `"Donation"` sur les lignes de don. Des lignes **historiques** peuvent encore avoir `item_type: "Payment"` jusqu’au backfill complet — voir [payments.md](payments.md). - **Invariant** : la somme des lignes = `payment.total_cents`. - **À éviter** : - `item_type: "Payment"` pour un don dans toute nouvelle documentation ou code (utiliser `"Donation"`). #### Don — `Donation` - **Définition** : paiement volontaire sans contrepartie, conservé pour reçu fiscal éventuel. -- **Représentation actuelle** : matérialisé par une `PaymentLine` dont l'item est conceptuellement un don, mais stocké techniquement avec `item_type: "Payment"` (legacy à éliminer). -- **Représentation cible** : `PaymentLine` avec `item_type: "Donation"` et `item_id` stable (id du paiement parent ou modèle dédié — décision en `phase1-donation-fix`). +- **Représentation actuelle (code)** : `PaymentLine` avec `item_type: "Donation"` (création via `People::PaymentCreator`). Données anciennes : encore `item_type: "Payment"` sur certaines lignes jusqu’à migration — voir `phase1-donation-fix` et [payments.md](payments.md). +- **Représentation cible** : même schéma polymorphique ; nettoyage DB (`Payment` legacy, champ `payments.donation`, validations strictes sur `item_type`). - **Usage correct** : - « Don de 5 € lors d'une adhésion ». - « Le paiement contient deux lignes : adhésion + don ». @@ -171,7 +171,7 @@ | Terme français | Terme anglais (code) | Statut | Note | | --- | --- | --- | --- | | Personne | `Person` | canonique | source de vérité CRM | -| Compte web | `User` | canonique | optionnel | +| Compte web | `User` | canonique | optionnel **pour une Person** (pas toujours de compte) ; **obligatoire pour un User** (toujours une Person liée) | | Adhésion | `Membership` | canonique | annuel | | Type d'adhésion | `MembershipType` | canonique | catalogue versionné | | Cotisation | `Contribution` | **cible** (legacy : `BookOfEntry`) | instance achetée | diff --git a/docs/internal/todo.md b/docs/internal/todo.md index 5d02be00..c6ef1480 100644 --- a/docs/internal/todo.md +++ b/docs/internal/todo.md @@ -2,9 +2,11 @@ > **Statut** : internal > **Public cible** : équipe dev -> **Dernière mise à jour** : 2026-04-27 +> **Dernière mise à jour** : 2026-05-01 > **Provenance** : fusion de l'ancien `to-do.md` (racine) et `docs/TODO.md` (doublon). > +> **Identity note (2026-05)** — tout `User` a une `Person` (création minimale si besoin, DB `users.person_id` NOT NULL). Pas de « User sans Person » en données. Voir résumé dans le [README](../../README.md) et la règle [naming-rules.mdc](../../.cursor/rules/naming-rules.mdc). +> > **Vocabulary note** — the words `subscription`, `SubscriptionCreator`, `SubscriptionUpgrader`, `SubscriptionPlan`, `BookOfEntry` below refer to the **current code**. > Target domain vocabulary: `contribution`, `ContributionCreator`, `ContributionUpgrader`, `ContributionFormula`, `Contribution`. > Single exception: `subscription` is legitimate in the **newsletter** context. @@ -13,7 +15,7 @@ Ordered from quick wins to long-term work. Each item can be handled incrementally. ## 0) Ground Rules (Architecture + MVC) -- Document the Person/User lifecycle and ownership rules. +- Document the Person/User lifecycle and ownership rules. (partial: résumé README + règles Cursor ; manque encore un doc « happy-path » dédié, cf. §3) - Enforce “Person is source of truth for identity + finance.” - Ensure controllers remain thin: call services, render/redirect. - Keep domain logic in models and workflows in services. @@ -32,14 +34,14 @@ Ordered from quick wins to long-term work. Each item can be handled incrementall ## 2) Medium (flow consistency + integrity) - Ensure admin registration uses `People::Register` only. -- Ensure account linking uses `People::AccountLinker` only. +- Ensure account linking goes through services — **no ad-hoc `user.person = …` in controllers.** Nominal attach: `People::AttachUserToPerson` ; orchestration / compat: `People::AccountLinker` (délègue à `AttachUserToPerson` + merge cleanup si besoin). Les flux « account claim » peuvent appeler `AttachUserToPerson` directement. - Ensure membership creation uses `People::MembershipCreator` only. - Ensure subscription purchase uses `People::SubscriptionCreator` only. - Ensure upgrades use `People::MembershipUpgrader` / `People::SubscriptionUpgrader`. - Support Person without User (real-life registration first). -- Support User without Person (web-first signup). -- Provide explicit admin action to link User ↔ Person. -- Prevent implicit relinks when a Person already has a User. +- Web signup: **chaque `User` a une `Person`** (stub minimale à la création) ; enrichissement via édition Person / rattachement / fusion — plus de cas métier « user orphelin sans Person ». +- Provide explicit admin action to link User ↔ Person. (partial: services prêts ; vérifier couverture UI + permissions) +- Prevent implicit relinks when a Person already has a User. (partial: `AttachUserToPerson` refuse si la Person cible a déjà un autre User) - Show offer reason in payment history for offered payments. - Enforce offer_reason on payments when payment_method == offered (admin edit too). - Display offer reason in membership/subscription history when offered. @@ -57,10 +59,10 @@ Ordered from quick wins to long-term work. Each item can be handled incrementall - Add “Data integrity rules” checklist (no orphans, no overlap, unlimited plan rules). - Add “Role permissions” doc (who can offer, delete, link, anonymize). - Maintain `docs/README.md` as the index of truth. - - Keep `docs/development/testing.md` + `docs/architecture/controllers.md` revalidated against current tests. +- Keep `docs/development/testing.md` + `docs/architecture/controllers.md` revalidated against current tests (invariant User→Person, RSpec-only, auth native Rails 8 résumés dans README / testing.md). ## 4) Tests (medium -> long) -- Service specs: Register, AccountLinker, MembershipCreator, SubscriptionCreator. +- Service specs: Register, AttachUserToPerson, AccountLinker, MembershipCreator, SubscriptionCreator. - Controller specs for admin flows (success + failure). - ViewComponent specs for badges/contextual actions. - Integrity specs for orphan queries. @@ -73,6 +75,7 @@ Ordered from quick wins to long-term work. Each item can be handled incrementall - Spec for payment_lines sum == payment total. ## 5) Payments Accountability (longer) +- Align legacy donation lines: aucune ligne ne doit rester avec `item_type: "Payment"` pour un don (canonique `Donation` ; migration de backfill existante — vérifier jeux de données réels et reporting). - Remove any user_id references for payments (payments belong to Person). (partially done: User#destroy) - Require `recorded_by` in all payment flows. - Use “void/cancel” instead of delete. @@ -99,7 +102,7 @@ Ordered from quick wins to long-term work. Each item can be handled incrementall ## 8) Rollout Order 1. Health Report panel (visibility first). (done) 2. Fix invalid payment/user linking logic. (done: admin payment/donation links use person_id; removed user_id filter in payments service) -3. Force registration + linking through services. +3. Force registration + linking through services. (partial: `People::Register` sur chemins admin/form/web ; rattachement via `AttachUserToPerson` / `AccountLinker` ; auditer le reste des controllers) 4. Replace deletes with anonymization. 5. Clean legacy views. 6. Add/extend tests. diff --git a/docs/migrations/vocabulary_migration.md b/docs/migrations/vocabulary_migration.md index aa932378..f884c224 100644 --- a/docs/migrations/vocabulary_migration.md +++ b/docs/migrations/vocabulary_migration.md @@ -2,7 +2,7 @@ > **Statut** : stable (transitionnel — disparaît à la fin de phase 4) > **Public cible** : contributeur -> **Dernière vérification** : 2026-04-27 +> **Dernière vérification** : 2026-05-01 > **Sources de vérité** : `app/models/`, `app/services/people/`, [`../glossary.md`](../glossary.md). > Plan progressif d'alignement vocabulaire / code / documentation, sans big-bang. Chaque phase est livrable seule, sans casser la précédente. @@ -71,7 +71,7 @@ - ✅ **Tests à mettre à jour** : remplacer les usages existants. ### Phase 1 — Donations propres -- `People::PaymentCreator` : retirer la réécriture `item_type: "Donation" → "Payment"` (L92 de `app/services/people/payment_creator.rb`). +- ✅ `People::PaymentCreator` : la réécriture `item_type: "Donation" → "Payment"` **n’existe plus** sur le chemin simple ; conserver les migrations / specs jusqu’à disparition totale des lignes legacy. - Data migration : `PaymentLine.where(item_type: "Payment").where("description ILIKE '%don%' OR description = 'Donation'").update_all(item_type: "Donation")`. - Backfill : pour chaque `Payment.where("donation > 0")`, créer une `PaymentLine` `item_type: "Donation"` si absente. - Migration DB : `remove_column :payments, :donation`. diff --git a/docs/payments.md b/docs/payments.md index 7c7ba88a..d5318b41 100644 --- a/docs/payments.md +++ b/docs/payments.md @@ -2,7 +2,7 @@ > **Statut** : stable > **Public cible** : contributeur -> **Dernière vérification** : 2026-04-27 +> **Dernière vérification** : 2026-05-01 > **Sources de vérité** : `app/models/payment.rb`, `app/models/payment_line.rb`, `app/services/people/payment_creator.rb`. > Vocabulaire utilisé : voir [glossary.md](glossary.md). @@ -17,7 +17,7 @@ Payment (transaction) ├── PaymentLine ── item_type ──> ContributionFormula (legacy: SubscriptionPlan) ├── PaymentLine ── item_type ──> Contribution (legacy: BookOfEntry, rare) ├── PaymentLine ── item_type ──> MembershipType -└── PaymentLine ── item_type ──> Donation (cible — actuel: "Payment", voir §4) +└── PaymentLine ── item_type ──> Donation (création actuelle via `PaymentCreator` ; données anciennes peuvent encore avoir `Payment`, voir §4) ``` **Invariant fondamental** : `payment.payment_lines.sum(:amount_cents) == payment.total_cents`. @@ -58,7 +58,7 @@ Payment#anonymize! | Renouvellement / catalogue | `"MembershipType"` | `membership_type.id` | rare, surtout pour audit | | Achat de cotisation | `"ContributionFormula"` | `formula.id` | legacy : `"SubscriptionPlan"` | | Cotisation existante (réf.) | `"Contribution"` | `contribution.id` | legacy : `"BookOfEntry"` (rare) | -| Don | `"Donation"` | `payment.id` (provisoire) | actuel : `"Payment"` — voir §4 | +| Don | `"Donation"` | `payment.id` (provisoire) | création : service ci-dessous ; legacy DB : encore `"Payment"` jusqu’à backfill complet — voir §4 | ### 3.2 Création multi-lignes @@ -97,28 +97,20 @@ PaymentLine.new( **Aucune `PaymentLine` ne doit avoir `item_type: "Payment"`**. -### 4.2 État actuel — dette technique +### 4.2 État actuel — code vs données legacy -Le service [`People::PaymentCreator`](../app/services/people/payment_creator.rb) (L87-100) **réécrit** automatiquement les lignes de don : +**Code (`People::PaymentCreator`)** : la ligne simple utilise `item_type` défaut `"Donation"` et **conserve** ce type pour les dons (`donation_line?` → `item_id` = `payment.id`). Il n’y a plus de réécriture systématique vers `"Payment"` dans ce chemin. ```ruby -# app/services/people/payment_creator.rb -def create_single_line(payment) - line_item_type = item_type.presence || "Donation" +# app/services/people/payment_creator.rb (extrait) +payment.payment_lines.create!( + item_type: line_item_type, # ex. "Donation" + item_id: donation_line?(line_item_type) ? payment.id : item_id, ... - resolved_item_type = donation_line?(line_item_type) ? "Payment" : line_item_type - payment.payment_lines.create!( - item_type: resolved_item_type, # ← écrase "Donation" en "Payment" - item_id: donation_line?(line_item_type) ? payment.id : item_id, - ... - ) -end +) ``` -**Conséquences** : -- En base, `payment_lines.item_type = 'Payment'` pour tous les dons existants. -- Les requêtes de filtrage (`Admin::PaymentsService` L67, `Payment` L44) doivent matcher `item_type = 'Donation'` **OU** `description LIKE '%don%'` pour rétro-compat. -- Le commentaire de [`Person#create_payment_for_donation`](../app/models/person.rb) L450 (« utiliser Payment comme item_type pour cohérence avec PaymentCreator ») documente le hack mais ne le justifie pas conceptuellement. +**Données** : des lignes historiques peuvent encore avoir `item_type: "Payment"` jusqu’à application complète des migrations de backfill (`db/migrate/*backfill_donation*`). Les requêtes métier doivent couvrir **Donation** comme canon et **Payment** comme legacy le temps du nettoyage (voir `phase1-donation-fix`). ### 4.3 Plan de migration (phase `phase1-donation-fix`) @@ -128,7 +120,7 @@ end .where("description ILIKE ? OR description = ?", "%don%", "Donation") .update_all(item_type: "Donation") ``` -2. **Suppression du hack** : retirer la réécriture L92 dans `People::PaymentCreator` et le commentaire L450 dans `Person`. +2. **Nettoyage code résiduel** : retirer toute référence encore basée sur `item_type: "Payment"` pour les dons dans les modèles/helpers commentés ; aligner les specs/factories sur `"Donation"` uniquement. 3. **Mise à jour des requêtes** : simplifier `Payment#with_donations` et `Admin::PaymentsService` à `where(payment_lines: { item_type: "Donation" })`. 4. **Specs à ajuster** : `spec/factories/payments.rb` trait `:with_donation` doit créer une `PaymentLine` `item_type: "Donation"`. 5. **Validation modèle** : ajouter `validates :item_type, inclusion: { in: %w[Membership MembershipType ContributionFormula Contribution Donation] }` sur `PaymentLine`. diff --git a/lib/tasks/i18n_keys.rake b/lib/tasks/i18n_keys.rake new file mode 100644 index 00000000..4b852490 --- /dev/null +++ b/lib/tasks/i18n_keys.rake @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "yaml" + +def flatten_keys(hash, prefix = nil) + hash.flat_map do |key, value| + current = [ prefix, key ].compact.join(".") + value.is_a?(Hash) ? flatten_keys(value, current) : [ current ] + end +end + +namespace :i18n do + desc "Print locale key parity between fr and en" + task check_keys: :environment do + fr_data = YAML.load_file(Rails.root.join("config/locales/fr.yml"))["fr"] || {} + en_data = YAML.load_file(Rails.root.join("config/locales/en.yml"))["en"] || {} + + fr_keys = flatten_keys(fr_data).sort + en_keys = flatten_keys(en_data).sort + + missing_in_en = fr_keys - en_keys + missing_in_fr = en_keys - fr_keys + + puts "Missing in en (#{missing_in_en.size})" + missing_in_en.each { |k| puts " - #{k}" } + + puts "\nMissing in fr (#{missing_in_fr.size})" + missing_in_fr.each { |k| puts " - #{k}" } + + if missing_in_en.empty? && missing_in_fr.empty? + puts "\nLocale keys are in sync." + else + puts "\nLocale keys are not in sync." + exit(1) + end + end +end diff --git a/spec/forms/admin/user_creation_form_spec.rb b/spec/forms/admin/user_creation_form_spec.rb index 39b9b0af..c5466a38 100644 --- a/spec/forms/admin/user_creation_form_spec.rb +++ b/spec/forms/admin/user_creation_form_spec.rb @@ -91,7 +91,9 @@ result = form.call expect(result.success?).to be(false) - expect(result.errors.join(', ')).to include('Invalid data') + expect(result.errors.join(', ')).to start_with( + I18n.t('admin.users.create.invalid_data_alert', details: '').rstrip + ) end end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index a96e3f45..59d6e569 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -199,10 +199,10 @@ end end - context 'with user without person' do + context 'with user auto-linked to minimal person' do let(:user_without_person) { create(:user, person: nil) } - it 'returns false (no legacy event_attendees)' do + it 'returns false when no attendance exists for linked person' do expect(event.is_user_registered?(user_without_person)).to be false end end diff --git a/spec/models/person_user_architecture_spec.rb b/spec/models/person_user_architecture_spec.rb index d2ef7ad3..434501e8 100644 --- a/spec/models/person_user_architecture_spec.rb +++ b/spec/models/person_user_architecture_spec.rb @@ -34,13 +34,14 @@ end end - describe 'User without Person' do - it 'handles user without person gracefully' do - user = create(:user, person: nil) - - expect(user.first_name).to be_nil - expect(user.last_name).to be_nil - expect(user.full_name).to be_nil + describe 'User-Person invariant' do + it 'auto-creates a minimal person when missing on create' do + user = create(:user, person: nil, email_address: 'orphan@example.com') + + expect(user.person).to be_present + expect(user.person.first_name).to eq('Web') + expect(user.person.last_name).to eq('User') + expect(user.person.email).to eq('orphan@example.com') end end end diff --git a/spec/requests/admin/users_spec.rb b/spec/requests/admin/users_spec.rb index 0d64408e..f1413459 100644 --- a/spec/requests/admin/users_spec.rb +++ b/spec/requests/admin/users_spec.rb @@ -96,7 +96,9 @@ end.not_to change(Person, :count) expect(response).to have_http_status(:unprocessable_content) - expect(flash[:alert]).to include('Invalid data') + expect(flash[:alert]).to start_with( + I18n.t("admin.users.create.invalid_data_alert", details: "").rstrip + ) end end end diff --git a/spec/services/account_claim_management/account_claim_creator_spec.rb b/spec/services/account_claim_management/account_claim_creator_spec.rb index 8f7d367e..f282fd98 100644 --- a/spec/services/account_claim_management/account_claim_creator_spec.rb +++ b/spec/services/account_claim_management/account_claim_creator_spec.rb @@ -52,7 +52,7 @@ result = creator.call expect(result.success?).to be false - expect(result.message).to include('Invalid data') + expect(result.message).to include(I18n.t('services.validation.invalid_data')) end it 'returns failure when user_id is missing' do diff --git a/spec/services/attendance_list_management/attendance_list_creator_spec.rb b/spec/services/attendance_list_management/attendance_list_creator_spec.rb index 92af1146..4444f424 100644 --- a/spec/services/attendance_list_management/attendance_list_creator_spec.rb +++ b/spec/services/attendance_list_management/attendance_list_creator_spec.rb @@ -42,7 +42,7 @@ result = described_class.new(start_date: Time.current, created_by_id: admin_user.id).call expect(result.success?).to be false - expect(result.message).to include('Invalid data') + expect(result.message).to include(I18n.t('services.validation.invalid_data')) end it 'fails when created_by user does not exist' do diff --git a/spec/services/attendance_list_management/attendance_list_deleter_spec.rb b/spec/services/attendance_list_management/attendance_list_deleter_spec.rb index be737223..e8fb1d26 100644 --- a/spec/services/attendance_list_management/attendance_list_deleter_spec.rb +++ b/spec/services/attendance_list_management/attendance_list_deleter_spec.rb @@ -31,7 +31,7 @@ result = described_class.new(deleted_by_id: admin_user.id).call expect(result.success?).to be false - expect(result.message).to include('Invalid data') + expect(result.message).to include(I18n.t('services.validation.invalid_data')) end it 'fails when attendance list does not exist' do diff --git a/spec/services/attendance_list_management/attendance_list_updater_spec.rb b/spec/services/attendance_list_management/attendance_list_updater_spec.rb index 2d5c25de..cc51bfda 100644 --- a/spec/services/attendance_list_management/attendance_list_updater_spec.rb +++ b/spec/services/attendance_list_management/attendance_list_updater_spec.rb @@ -43,7 +43,7 @@ result = described_class.new(name: 'Test', updated_by_id: admin_user.id).call expect(result.success?).to be false - expect(result.message).to include('Invalid data') + expect(result.message).to include(I18n.t('services.validation.invalid_data')) end it 'fails when attendance list does not exist' do diff --git a/spec/services/attendance_management/attendance_creator_spec.rb b/spec/services/attendance_management/attendance_creator_spec.rb index d729a6f4..7f1702c9 100644 --- a/spec/services/attendance_management/attendance_creator_spec.rb +++ b/spec/services/attendance_management/attendance_creator_spec.rb @@ -54,7 +54,7 @@ result = creator.call expect(result.success?).to be false - expect(result.message).to include('Invalid data') + expect(result.message).to include(I18n.t('services.validation.invalid_data')) end it "returns failure when person doesn't exist" do diff --git a/spec/services/blog_management/blog_creator_spec.rb b/spec/services/blog_management/blog_creator_spec.rb index 4829104f..b20d08d7 100644 --- a/spec/services/blog_management/blog_creator_spec.rb +++ b/spec/services/blog_management/blog_creator_spec.rb @@ -55,7 +55,7 @@ result = creator.call expect(result.success?).to be false - expect(result.message).to include('Invalid data') + expect(result.message).to include(I18n.t('services.validation.invalid_data')) end it 'returns failure when content is missing' do diff --git a/spec/services/people/account_linker_spec.rb b/spec/services/people/account_linker_spec.rb index 6c24dda2..7bac8483 100644 --- a/spec/services/people/account_linker_spec.rb +++ b/spec/services/people/account_linker_spec.rb @@ -54,7 +54,7 @@ result = described_class.new(user: user).call expect(result.success?).to be(false) - expect(result.message).to include('Invalid data') + expect(result.message).to include(I18n.t('services.validation.invalid_data')) end end end diff --git a/spec/services/people/account_merger_spec.rb b/spec/services/people/account_merger_spec.rb index f0aa827e..4da57d8d 100644 --- a/spec/services/people/account_merger_spec.rb +++ b/spec/services/people/account_merger_spec.rb @@ -60,7 +60,7 @@ result = described_class.new(source_person: source_person).call expect(result.success?).to be(false) - expect(result.message).to include('Invalid data') + expect(result.message).to include(I18n.t('services.validation.invalid_data')) end end end diff --git a/spec/services/people/contribution_creator_spec.rb b/spec/services/people/contribution_creator_spec.rb index 2ce1c161..4c9be963 100644 --- a/spec/services/people/contribution_creator_spec.rb +++ b/spec/services/people/contribution_creator_spec.rb @@ -71,7 +71,7 @@ ).call expect(result.success?).to be(false) - expect(result.message).to include('Invalid data') + expect(result.message).to include(I18n.t('services.validation.invalid_data')) end it 'fails when plan missing' do diff --git a/spec/services/people/contribution_upgrader_spec.rb b/spec/services/people/contribution_upgrader_spec.rb index 3eaf96e6..015c6ad8 100644 --- a/spec/services/people/contribution_upgrader_spec.rb +++ b/spec/services/people/contribution_upgrader_spec.rb @@ -60,7 +60,7 @@ ).call expect(result.success?).to be(false) - expect(result.message).to include('Invalid data') + expect(result.message).to include(I18n.t('services.validation.invalid_data')) end it 'fails when recorded_by missing' do diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb deleted file mode 100644 index cee29fd2..00000000 --- a/test/application_system_test_case.rb +++ /dev/null @@ -1,5 +0,0 @@ -require "test_helper" - -class ApplicationSystemTestCase < ActionDispatch::SystemTestCase - driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] -end diff --git a/test/controllers/partners_controller_test.rb b/test/controllers/partners_controller_test.rb deleted file mode 100644 index 5277d305..00000000 --- a/test/controllers/partners_controller_test.rb +++ /dev/null @@ -1,17 +0,0 @@ -require "test_helper" - -class PartnersControllerTest < ActionDispatch::IntegrationTest - test "returns partners partial as turbo stream" do - get partners_url(format: :turbo_stream) - - assert_response :success - assert_equal "text/vnd.turbo-stream.html", response.media_type - assert_includes response.body, "Ville de Toulouse" - end - - test "redirects to about page for html requests" do - get partners_url - - assert_redirected_to page_path("about") - end -end diff --git a/test/system/about_page_test.rb b/test/system/about_page_test.rb deleted file mode 100644 index 48f65875..00000000 --- a/test/system/about_page_test.rb +++ /dev/null @@ -1,10 +0,0 @@ -require "application_system_test_case" - -class AboutPageTest < ApplicationSystemTestCase - test "shows partners on about page" do - visit page_path("about") - - assert_selector "h1", text: "À propos du Circographe" - assert_selector "[data-controller='timeline'] .timeline-step", minimum: 3, visible: :all, wait: 5 - end -end diff --git a/test/system/association_page_test.rb b/test/system/association_page_test.rb deleted file mode 100644 index e5be34c2..00000000 --- a/test/system/association_page_test.rb +++ /dev/null @@ -1,12 +0,0 @@ -require "application_system_test_case" - -class AssociationPageTest < ApplicationSystemTestCase - test "association page exposes anchor sections" do - visit page_path("association") - - assert_selector "h1", text: /Bienvenue au Circographe/ - assert_selector "section#le-cirque", wait: 5, visible: :all - assert_selector "section#les-arts-graphiques", visible: :all - assert_selector "section#fonctionnement", visible: :all - end -end diff --git a/test/system/become_member_page_test.rb b/test/system/become_member_page_test.rb deleted file mode 100644 index cc482af6..00000000 --- a/test/system/become_member_page_test.rb +++ /dev/null @@ -1,11 +0,0 @@ -require "application_system_test_case" - -class BecomeMemberPageTest < ApplicationSystemTestCase - test "adhesion page shows timeline and checklist" do - visit page_path("become_member") - - assert_selector "h1", text: /Adhérer/ - assert_selector "[data-controller='timeline'] .timeline-step", minimum: 3, wait: 5 - assert_selector "[data-controller='checklist'] li", minimum: 1, visible: :all - end -end diff --git a/test/system/contact_page_test.rb b/test/system/contact_page_test.rb deleted file mode 100644 index 1c817883..00000000 --- a/test/system/contact_page_test.rb +++ /dev/null @@ -1,11 +0,0 @@ -require "application_system_test_case" - -class ContactPageTest < ApplicationSystemTestCase - test "contact page renders form turbo frame and FAQ" do - visit page_path("contact_us") - - assert_selector "h1", text: /Contact/ - assert_selector "turbo-frame#contact_form", wait: 5 - assert_selector "[data-controller='accordion']", visible: :all, wait: 5 - end -end diff --git a/test/system/faq_page_test.rb b/test/system/faq_page_test.rb deleted file mode 100644 index a0c67051..00000000 --- a/test/system/faq_page_test.rb +++ /dev/null @@ -1,13 +0,0 @@ -require "application_system_test_case" - -class FaqPageTest < ApplicationSystemTestCase - test "faq page shows sections and anchors" do - visit page_path("faq") - - assert_selector "h1", text: /Questions fréquentes/ - assert_selector "a[href='#faq-adhesion']" - assert_selector "a", text: "Adhésion", wait: 5 - assert_selector "a", text: "Contact" - assert_selector "a", text: "Vie du lieu" - end -end diff --git a/test/system/home_page_test.rb b/test/system/home_page_test.rb deleted file mode 100644 index 9d5d34f8..00000000 --- a/test/system/home_page_test.rb +++ /dev/null @@ -1,11 +0,0 @@ -require "application_system_test_case" - -class HomePageTest < ApplicationSystemTestCase - test "home page shows upcoming events frame" do - visit root_path - - assert_selector "h1", text: /Circographe/i - assert_selector "turbo-frame#events_upcoming" - assert_selector "section", text: /Horaires d'ouverture/ - end -end