diff --git a/.claude/planning/README.md b/.claude/planning/README.md index 0bd8996e..09ce6481 100644 --- a/.claude/planning/README.md +++ b/.claude/planning/README.md @@ -13,7 +13,9 @@ Centralna lokacija za svu plansku dokumentaciju projekta. ├── IMPLEMENTATION.md # 17 faza implementacije ├── DEVELOPER_ONBOARDING.md ├── TAILWIND_GUIDE.md +├── rfcs/ # Request for Comments ├── adr/ # Architecture Decision Records +├── decisions/ # Product i tehničke odluke ├── architecture/ # Arhitekturni dokumenti ├── testing/ # Test planovi i scenariji └── archive/ # Stari dokumenti @@ -63,6 +65,19 @@ Architecture Decision Records - dokumentovane ključne tehničke odluke. --- +## RFCs (rfcs/) + +Request for Comments - veće promjene koje zahtijevaju diskusiju. + +| Broj | Naslov | Status | Datum | +|------|--------|--------|-------| +| RFC-0001 | Curator Dashboard v2: Per-Resource Suggestions, Reviews, Audio Tours | Proposed | 2026-02-05 | + +**Location:** `.claude/planning/rfcs/` +**Kreiranje:** Koristi `/rfc` komandu. + +--- + ## Decisions (decisions/) Product i tehničke odluke koje utiču na arhitekturu. @@ -71,6 +86,11 @@ Product i tehničke odluke koje utiču na arhitekturu. |-------|--------|--------| | 2026-02-03 | Remove Platform Database | Accepted | | 2026-02-04 | AI Services DSL Migration | Proposed | +| 2026-02-05 | Per-Resource Suggestion Models (ADR-0003) | Proposed | +| 2026-02-05 | Reviews Management System (ADR-0004) | Proposed | +| 2026-02-05 | Audio Tour Generation Integration (ADR-0005) | Proposed | +| 2026-02-05 | Multiple Video URLs + Cover Photo Support (ADR-0006) | Proposed | +| 2026-02-05 | Human vs AI Suggestion Origin Tracking (ADR-0007) | Proposed | **Location:** `.claude/planning/decisions/` @@ -150,4 +170,4 @@ Stari dokumenti za referencu. Ne koristi za aktivni development. --- -*Zadnje ažuriranje: 2026-02-04* +*Zadnje ažuriranje: 2026-02-05* diff --git a/.claude/planning/decisions/2026-02-05-ai-vs-human-suggestion-origin.md b/.claude/planning/decisions/2026-02-05-ai-vs-human-suggestion-origin.md new file mode 100644 index 00000000..8a8698ea --- /dev/null +++ b/.claude/planning/decisions/2026-02-05-ai-vs-human-suggestion-origin.md @@ -0,0 +1,382 @@ +# ADR-0007: Human vs AI Suggestion Origin Tracking + +## Status +Proposed + +## Datum +2026-02-05 + +## Autori +Product Manager, Tech Lead + +## Context + +### Problem: AI piše direktno u bazu + +Trenutno svi AI servisi direktno mijenjaju resurse: + +| Servis | Šta radi | Kako piše | +|--------|----------|-----------| +| `Ai::LocationEnricher` | Generise opise, prijevode, tagove | `location.save!`, `location.set_translation()` | +| `Ai::AudioTourGenerator` | Generise script + TTS audio | `audio_tour.save!`, `location.update_column(:audio_tour_metadata)` | +| `Ai::ExperienceLocationSyncer` | Sync lokacija iz opisa | `experience.add_location()`, `location.update(ai_generated: true)` | +| `Ai::ExperienceTypeClassifier` | Klasifikuje lokacije po tipu | `location.add_experience_type()` | +| Platform DSL Executor | CLI content operacije | `record.save`, `record.update!()` | + +**Rezultat:** AI-generisan sadržaj je odmah vidljiv korisnicima. Nema pregleda, nema QA, nema odobrenja. Ako AI generiše loš opis, pogrešnu klasifikaciju, ili netačnu historijsku informaciju — to je odmah live. + +### Zašto je ovo problem? + +1. **Kvalitet** — AI može generisati netačne informacije (halucinacije), osobito za historijski kontekst BiH +2. **Ton** — AI opisi mogu biti generički, bez lokalnog štiha koji Usput.ba želi +3. **Konzistentnost** — Različiti AI pozivi mogu generisati konfliktne informacije za isti resurs +4. **Kontrola** — Admin nema pregled šta je AI promijenio i kad +5. **Rollback** — Ako AI napravi grešku, nema jednostavnog načina da se vrati na prethodno stanje + +### Šta želimo + +- AI-generisan sadržaj prolazi kroz **isti suggestion workflow** kao i kuratorski prijedlozi +- Admin i kuratori mogu **pregledati AI prijedloge** prije nego postanu vidljivi +- Jasno razlikovanje **ko je predložio** — čovjek ili AI (i koji AI servis) +- Kuratori mogu **recenzirati** AI prijedloge +- AI prijedlozi i ljudski prijedlozi vidljivi **odvojeno** u dashboardu + +## Decision + +### 1. Origin polje na Suggestable concern + +Dodati `origin` enum na sve suggestion modele kroz `Suggestable` concern: + +```ruby +# app/models/concerns/suggestable.rb +module Suggestable + included do + # ... existing code ... + + enum :origin, { + human: 0, # Kurator ili admin predložio + ai_generated: 1 # AI servis generisao + }, prefix: :origin + + # Koji AI servis je kreirao suggestion (null za human) + # Npr: "location_enricher", "audio_tour_generator", "experience_syncer" + attribute :ai_service, :string + + scope :human_suggestions, -> { where(origin: :human) } + scope :ai_suggestions, -> { where(origin: :ai_generated) } + end +end +``` + +```ruby +# Migration — dodati na sve suggestion tabele +add_column :location_suggestions, :origin, :integer, default: 0, null: false +add_column :location_suggestions, :ai_service, :string +# Isti za experience_suggestions, plan_suggestions +``` + +### 2. AI servisi kreiraju suggestion-e umjesto direktnog pisanja + +**Prije (direktno):** +```ruby +# Ai::LocationEnricher +def enrich(location) + description = generate_description(location) + location.description = description + location.save! # Odmah live +end +``` + +**Poslije (kroz suggestion):** +```ruby +# Ai::LocationEnricher +def enrich(location) + description = generate_description(location) + historical = generate_historical_context(location) + tags = generate_tags(location) + translations = generate_translations(location) + + suggestion = LocationSuggestion.find_or_create_pending!( + location, + user: system_user, # Dedicated AI system user + origin: :ai_generated, + ai_service: "location_enricher" + ) + + suggestion.update!( + proposed_description: description, + proposed_historical_context: historical, + proposed_tags: tags, + change_type: :update_resource + ) + + # Prijevodi idu kao zasebni prijedlozi ili metadata na suggestion + # (vidi sekciju 5 za detalje) + + suggestion +end +``` + +### 3. System User za AI operacije + +```ruby +# Dedicated user account za AI operacije +# Kreiran kroz seed ili migration +system_user = User.find_or_create_by!( + email: "ai@usput.ba", + username: "usput-ai", + user_type: :admin # Admin da može kreirati suggestion-e +) +``` + +Svi AI servisi koriste ovaj account kao `user` na suggestion-u. Dashboard prikazuje "Usput AI" umjesto korisničkog imena. + +### 4. Dashboard prikaz — odvojeni tabovi + +``` +┌─────────────────────────────────────────┐ +│ Pending Suggestions │ +├──────────┬──────────┬───────────────────┤ +│ [Human] │ [AI] │ [All] │ +├──────────┴──────────┴───────────────────┤ +│ │ +│ 📝 LocationSuggestion #12 │ +│ Origin: AI (location_enricher) │ +│ Predloženo: description, tags, context │ +│ Datum: 05.02.2026 │ +│ [Pregledaj] [Odobri] [Odbij] │ +│ │ +│ 📝 LocationSuggestion #11 │ +│ Origin: Human (kurator: @jasmin) │ +│ Predloženo: name, city, photos (3) │ +│ Datum: 05.02.2026 │ +│ [Pregledaj] [Odobri] [Odbij] │ +│ │ +└─────────────────────────────────────────┘ +``` + +Kurator vidi oba taba. Admin vidi oba taba + approve/reject akcije. + +### 5. AI servis adaptacije — po servisu + +#### LocationEnricher → LocationSuggestion + +```ruby +class Ai::LocationEnricher + def enrich(location) + # Generiši sve podatke + data = generate_all_data(location) + + # Kreiraj suggestion umjesto direktnog save + LocationSuggestion.find_or_create_pending!(location, user: system_user).tap do |s| + s.update!( + origin: :ai_generated, + ai_service: "location_enricher", + change_type: :update_resource, + proposed_description: data[:description], + proposed_historical_context: data[:historical_context], + proposed_tags: data[:tags], + proposed_experience_type_ids: data[:experience_type_ids] + ) + end + end +end +``` + +#### AudioTourGenerator → Direktno (izuzetak) + +Audio tura generisanje ostaje **admin-only akcija** (ADR-0005). Kad admin klikne "Generiši", to je eksplicitno odobrenje — nema smisla kreirati suggestion pa ga odobriti. AudioTour se kreira direktno ali samo na admin zahtjev. + +#### ExperienceLocationSyncer → ExperienceSuggestion + +```ruby +class Ai::ExperienceLocationSyncer + def sync_locations(experience) + detected_locations = detect_locations_from_description(experience) + + ExperienceSuggestion.find_or_create_pending!(experience, user: system_user).tap do |s| + s.update!( + origin: :ai_generated, + ai_service: "experience_location_syncer", + change_type: :update_resource, + proposed_location_uuids: detected_locations.map(&:uuid) + ) + end + end +end +``` + +#### ExperienceTypeClassifier → LocationSuggestion + +```ruby +class Ai::ExperienceTypeClassifier + def classify(location) + types = classify_experience_types(location) + + LocationSuggestion.find_or_create_pending!(location, user: system_user).tap do |s| + s.update!( + origin: :ai_generated, + ai_service: "experience_type_classifier", + change_type: :update_resource, + proposed_experience_type_ids: types.map(&:id) + ) + end + end +end +``` + +### 6. Prijevodi — poseban slučaj + +Prijevodi (`set_translation`) koriste Mobility gem i nisu direktni atributi na modelu. Opcije: + +**Opcija A: JSONB metadata polje na suggestion** +```ruby +# Na LocationSuggestion +t.jsonb :proposed_translations, default: {} +# Struktura: { "en" => { "description" => "...", "name" => "..." }, "de" => { ... } } +``` + +**Opcija B: Odvojeni translation workflow** +Prijevodi se generišu i primjenjuju tek kad je originalni tekst odobren. Approval callback pokreće translation generation. + +**Preporučeno: Opcija B** — Prijevodi zavise od finalnog teksta. Nema smisla prevoditi prijedlog koji može biti odbijen. Kad admin odobri suggestion koji mijenja description, sistem automatski trigeruje prijevod novog teksta. + +### 7. Auto-approve za pouzdane AI operacije + +Neke AI operacije su dovoljno pouzdane da mogu biti auto-approved: + +```ruby +# config/initializers/ai_auto_approve.rb +AI_AUTO_APPROVE_SERVICES = %w[ + # experience_type_classifier # Možda u budućnosti +].freeze + +# U Suggestable concern +after_create :auto_approve_if_eligible + +def auto_approve_if_eligible + return unless origin_ai_generated? + return unless AI_AUTO_APPROVE_SERVICES.include?(ai_service) + + approve!(system_user, notes: "Auto-approved: trusted AI service") +end +``` + +Za sada: **nijedan servis nije auto-approved**. Svi AI prijedlozi čekaju ljudski pregled. Auto-approve se može postepeno omogućavati kad se uspostavi povjerenje u kvalitet. + +### 8. Batch AI generation → Batch suggestions + +Kad se pokrene batch operacija (npr. "enrich all locations without description"), rezultat je **lista pending suggestion-a** umjesto direktnih promjena: + +```ruby +# Prije +locations.each { |l| Ai::LocationEnricher.new(l).enrich } +# → Sve lokacije odmah ažurirane + +# Poslije +locations.each { |l| Ai::LocationEnricher.new(l).enrich } +# → N pending LocationSuggestion zapisa +# → Admin vidi "15 new AI suggestions" na dashboardu +# → Admin može bulk approve ili pregledati jedan po jedan +``` + +## Consequences + +### Positive + +- **Kvalitet kontrola** — Nijedan AI sadržaj nije live bez ljudskog odobrenja +- **Transparentnost** — Jasno vidljivo šta je AI generisao vs šta je čovjek napisao +- **Unified workflow** — Isti suggestion sistem za sve izvore sadržaja +- **Rollback** — Suggestion se može odbiti. Originalni resurs ostaje nepromijenjen. +- **Audit trail** — `ai_service` polje prati koji servis je generisao šta +- **Postepeno povjerenje** — Auto-approve se može uključiti po servisu kad se dokaže kvalitet +- **Kurator recenzija** — Kuratori mogu pregledati i komentarisati AI prijedloge + +### Negative + +- **Sporiji AI pipeline** — Sadržaj koji je ranije bio odmah live sad čeka odobrenje. Za batch operacije to znači admin bottleneck. +- **System user** — Potreban je dedicated AI user account. Treba paziti da se ne koristi za manual operacije. +- **Prijevodi postaju dvostepeni** — Originalni tekst treba odobriti, pa tek onda prevoditi. Više čekanja. +- **Refaktorizacija AI servisa** — Svi servisi moraju biti adaptirani da kreiraju suggestion-e umjesto direktnog pisanja. Nije trivijalno — 4+ servisa sa različitim logikama. +- **Conflict sa existing pending** — Ako kurator i AI oboje predlože promjenu iste lokacije, unique constraint dozvoljava samo jedan pending. Rješenje: AI contribution se dodaje na postojeći human suggestion ili obrnuto. + +### Neutral + +- **Audio tour generisanje** ostaje direktno — admin eksplicitno traži, to je de facto odobrenje +- **`needs_ai_regeneration` flag** se mijenja u "trigger za kreiranje AI suggestion-a" umjesto "trigger za direktnu promjenu" +- **Platform DSL** treba adaptaciju — `bin/platform exec 'content | generate_description'` sad kreira suggestion umjesto direktnog update-a + +## Alternatives Considered + +### Opcija 1: Samo flag na resursu (ai_generated: true/false) + +Označiti resurse koje je AI kreirao/mijenjao ali bez suggestion workflow-a. + +- **Prednosti:** Minimalna promjena. AI i dalje piše direktno. +- **Mane:** Nema pregleda prije objavljivanja. Flag je informativni, ne sprečava ništa. +- **Zašto odbačeno:** Ne rješava core problem — AI sadržaj je odmah live. + +### Opcija 2: Odvojen AI Review Queue (ne kroz suggestion model) + +Poseban `AiContentReview` model samo za AI-generirane promjene. + +- **Prednosti:** Ne komplicira suggestion model. +- **Mane:** Dva odvojena sistema za review. Admin mora gledati dva inbox-a. Dupla logika za approve/reject. +- **Zašto odbačeno:** Unified suggestion model sa `origin` poljem je jednostavniji i konzistentniji. + +### Opcija 3: AI piše direktno ali sa "draft" statusom na resursu + +Dodati `publication_status` na svaki resurs (draft/published). AI piše u draft, admin publish-a. + +- **Prednosti:** Nema suggestion modela za AI. AI piše direktno ali u draft. +- **Mane:** Zahtijeva status kolonu na svakom modelu. Public query-ji moraju filtrirati draft. Promjena na 4+ modela. Miješanje AI draft-a i manual draft-a. +- **Zašto odbačeno:** Suggestion model već postoji i radi za human prijedloge. Jednostavnije je dodati `origin` nego novi status sistem na svim modelima. + +## Implementation Notes + +### Redoslijed implementacije + +1. **Faza 2 (ADR-0003)** — Kreiraj suggestion modele sa `origin` i `ai_service` poljem od početka +2. **Faza 2.5 (novi)** — Adaptiraj AI servise da kreiraju suggestion-e + - LocationEnricher → LocationSuggestion + - ExperienceTypeClassifier → LocationSuggestion + - ExperienceLocationSyncer → ExperienceSuggestion +3. **Faza 4 (ADR-0005)** — Audio tour generisanje (ostaje direktno, admin-only) +4. **Faza 5 (cleanup)** — Ukloni stare direktne AI pipeline-e + +### Conflict resolution: Human + AI pending za isti resurs + +Unique constraint dozvoljava samo jedan pending suggestion per resurs. Kad AI i human oboje žele predložiti promjenu: + +```ruby +# AI servis koristi find_or_create_pending! — isto kao kurator +# Ako već postoji human pending → AI dodaje contribution +# Ako već postoji AI pending → novi AI run ažurira polja + +suggestion = LocationSuggestion.find_or_create_pending!(location, user: system_user) +if suggestion.origin_human? && suggestion.persisted? + # Postoji human suggestion — AI dodaje kao contribution + suggestion.add_contribution( + user: system_user, + notes: "AI enrichment via #{service_name}", + **ai_proposed_fields + ) +else + # AI suggestion — ažuriraj direktno + suggestion.update!( + origin: :ai_generated, + ai_service: service_name, + **ai_proposed_fields + ) +end +``` + +## References + +- RFC-0001: Curator Dashboard v2 (`.claude/planning/rfcs/0001-curator-dashboard-v2.md`) +- ADR-0003: Per-Resource Suggestion Models (`.claude/planning/decisions/2026-02-05-per-resource-suggestion-models.md`) +- ADR-0005: Audio Tour Generation (`.claude/planning/decisions/2026-02-05-audio-tour-generation-integration.md`) +- `Ai::LocationEnricher` (`app/services/ai/location_enricher.rb`) +- `Ai::AudioTourGenerator` (`app/services/ai/audio_tour_generator.rb`) +- `Ai::ExperienceLocationSyncer` (`app/services/ai/experience_location_syncer.rb`) +- `Ai::ExperienceTypeClassifier` (`app/services/ai/experience_type_classifier.rb`) diff --git a/.claude/planning/decisions/2026-02-05-audio-tour-generation-integration.md b/.claude/planning/decisions/2026-02-05-audio-tour-generation-integration.md new file mode 100644 index 00000000..59a62e29 --- /dev/null +++ b/.claude/planning/decisions/2026-02-05-audio-tour-generation-integration.md @@ -0,0 +1,283 @@ +# ADR-0005: Audio Tour Generation Integration in Curator Dashboard + +## Status +Proposed + +## Datum +2026-02-05 + +## Autori +Tech Lead, Product Manager + +## Context + +`Ai::AudioTourGenerator` servis već postoji (705 linija) sa punom funkcionalnošću: +- Generisanje AI skripte za lokaciju +- Text-to-speech preko ElevenLabs (26 glasova), OpenAI, ili Google Cloud +- Multilingvalna podrška (bs, en, de, fr, itd.) +- Batch generisanje za više lokacija +- Number-to-words konverzija za prirodan govor + +Međutim, **nema UI za pokretanje generisanja**. Trenutno se audio ture generišu samo: +- Direktno iz Rails konzole +- Kroz Platform CLI (`bin/platform`) +- Nema način da admin ili kurator pokrene generisanje iz dashboarda + +### Trenutno stanje u curator dashboard-u + +- Audio Tours imaju CRUD u curator dashboardu (iza feature flaga) +- Location show stranica nema nikakav audio tour status ili player +- `AudioTour` model postoji sa: `location_id`, `locale`, `script`, `word_count`, `duration` +- Lokacije imaju `audio_file` Active Storage attachment + +### Troškovi + +ElevenLabs API: +- ~$0.30 po minuti generisanog audio-a (Creator plan) +- Prosječna tura: 3-5 minuta = ~$1-1.50 po turi +- Sa 26 glasova dostupnih, voice selection je besplatan + +OpenAI TTS: +- $15 per 1M characters (HD quality) +- Prosječna tura ~2000 characters = ~$0.03 po turi +- Značajno jeftinije ali manja kvaliteta za bosanski + +## Decision + +Integrisati audio tour generisanje u curator dashboard kao **admin-only akciju** na location show stranici, sa background job izvršavanjem i real-time status prikazom. + +### 1. Background Job + +```ruby +# app/jobs/audio_tour_generate_job.rb +class AudioTourGenerateJob < ApplicationJob + queue_as :default + + def perform(location_id:, locale:, requested_by_id:, voice_id: nil) + location = Location.find(location_id) + user = User.find(requested_by_id) + + generator = Ai::AudioTourGenerator.new(location) + + # Provjeri da li audio već postoji za ovaj locale + if generator.audio_exists?(locale) && !force + Rails.logger.info "Audio already exists for #{location.name} (#{locale})" + return + end + + result = generator.generate(locale, force: false) + + # Zabilježi aktivnost + CuratorActivity.record( + user: user, + action: "audio_tour_generated", + recordable: location, + metadata: { + locale: locale, + voice_id: voice_id, + duration: result&.dig(:duration) + } + ) + rescue => e + # Zabilježi grešku + CuratorActivity.record( + user: user, + action: "audio_tour_generation_failed", + recordable: location, + metadata: { + locale: locale, + error: e.message + } + ) + raise # Re-raise za Solid Queue retry + end +end +``` + +### 2. Controller akcija + +```ruby +# app/controllers/curator/locations_controller.rb +# Dodati u existing controller + +def generate_audio_tour + @location = Location.find(params[:id]) + + unless current_user.admin? + redirect_to curator_location_path(@location), + alert: "Samo admin može generisati audio ture." + return + end + + locale = params[:locale] || "bs" + + # Provjeri da li već postoji pending job + # (Solid Queue nema built-in dedup, ali možemo provjeriti + # CuratorActivity za recent generation request) + recent_request = CuratorActivity + .where(action: "audio_tour_generation_requested") + .where(recordable: @location) + .where("created_at > ?", 10.minutes.ago) + .exists? + + if recent_request + redirect_to curator_location_path(@location), + alert: "Generisanje je već pokrenuto. Sačekajte da se završi." + return + end + + AudioTourGenerateJob.perform_later( + location_id: @location.id, + locale: locale, + requested_by_id: current_user.id + ) + + record_activity("audio_tour_generation_requested", + recordable: @location, + metadata: { locale: locale } + ) + + redirect_to curator_location_path(@location), + notice: "Audio tura za #{locale.upcase} se generise u pozadini." +end +``` + +### 3. Ruta + +```ruby +namespace :curator do + resources :locations do + member do + post :generate_audio_tour + end + # ... existing routes + end +end +``` + +### 4. UI na Location Show stranici + +Dodati sekciju na `curator/locations/show.html.erb`: + +```erb + +
+
+

Audio Tura

+ + <% audio_tour = @location.audio_tours.first %> + <% if audio_tour&.audio_file&.attached? %> + +
+ +

+ <%= audio_tour.locale.upcase %> · + <%= audio_tour.duration || "N/A" %> · + <%= audio_tour.word_count || "N/A" %> riječi +

+
+ <% else %> +

+ Nema audio ture za ovu lokaciju. +

+ <% end %> + + <% if current_user.admin? %> + +
+ <%= form_with url: generate_audio_tour_curator_location_path(@location), + method: :post, class: "flex items-center gap-2" do |f| %> + <%= f.select :locale, + [["Bosanski", "bs"], ["English", "en"], ["Deutsch", "de"]], + {}, class: "rounded-md border-gray-300 text-sm" %> + <%= f.submit "Generiši audio turu", + class: "inline-flex items-center rounded-md bg-blue-600 px-3 py-2 + text-sm font-semibold text-white shadow-sm hover:bg-blue-500", + data: { confirm: "Generisanje audio ture košta ~$1-1.50. Nastaviti?" } %> + <% end %> +
+ <% end %> +
+
+``` + +### 5. CuratorActivity proširenje + +Dodati nove action types: + +```ruby +# U CuratorActivity modelu +ACTIONS = [ + # ... existing actions ... + "audio_tour_generation_requested", + "audio_tour_generated", + "audio_tour_generation_failed" +] +``` + +### 6. Zaštite + +| Zaštita | Implementacija | +|---------|----------------| +| **Admin-only** | `current_user.admin?` check u controlleru | +| **Rate limiting** | Provjera CuratorActivity za duplicate request u zadnjih 10 min | +| **Cost awareness** | `data-confirm` dialog sa informacijom o cijeni | +| **Error handling** | Job bilježi grešku u CuratorActivity, re-raise za retry | +| **Audit trail** | Svaki request i rezultat zabilježen kao CuratorActivity | + +## Consequences + +### Positive + +- **Samoposlužno generisanje** — Admin može generisati audio ture bez Rails konzole ili CLI +- **Audit trail** — Svako generisanje je zabilježeno sa korisnik, locale, trajanje +- **Background processing** — Ne blokira UI. Solid Queue radi retry na failure. +- **Cost visibility** — Confirmation dialog upozorava na cijenu +- **Duplicate prevention** — Rate limiting sprečava slučajno dvostruko pokretanje +- **Status prikaz** — Admin i kurator odmah vide da li lokacija ima audio turu + +### Negative + +- **Nema real-time progress** — Korisnik ne vidi progress generisanja. Mora refreshati stranicu. (Turbo Stream upgrade je moguć u budućnosti) +- **Nema voice selection u UI** — Trenutno koristi default/random voice. Voice picker bi bio nice-to-have. +- **Jedan locale po request** — Admin mora pokrenuti generisanje za svaki jezik posebno. Batch multilingvalno generisanje je moguće kao poboljšanje. + +### Neutral + +- **Postojeći AudioTour CRUD ostaje** — Admin može i dalje ručno editovati script, metadata. Generisanje je dopuna, ne zamjena. +- **OpenAI vs ElevenLabs izbor** — Ostaje na konfiguraciji servisa, ne na UI odluci. + +## Alternatives Considered + +### Opcija 1: Audio Tour CRUD je dovoljan + +Admin edituje AudioTour script ručno, upload-a audio fajl. + +- **Prednosti:** Nema API troškova. Potpuna kontrola. +- **Mane:** Ručno pisanje skripte je sporo. Ručni TTS je workflow izvan platforme. +- **Zašto odbačeno:** Generator već postoji i radi. Ne iskoristiti ga je gubitak. + +### Opcija 2: Batch generisanje sa dashboard stranice + +Posebna stranica "Generiši audio ture" sa checkbox listom lokacija. + +- **Prednosti:** Efikasno za bulk generisanje. +- **Mane:** Ogroman API trošak ako se greškom pokrene za 100 lokacija. Teže za kontrolu. +- **Zašto odbačeno:** Per-location generisanje je sigurnije za početak. Batch se može dodati kad se uspostavi workflow. + +### Opcija 3: Kurator može pokrenuti generisanje + +Dozvoli i kuratorima da pokreću audio generisanje. + +- **Prednosti:** Manje bottleneck-a na adminu. +- **Mane:** Kurator može nepotrebno trošiti API budget. Nema cost accountability. +- **Zašto odbačeno:** Troškovi su realni (~$1.50 po turi). Samo admin treba imati tu odgovornost. Može se proširiti na kuratore kad se uspostavi budget monitoring. + +## References + +- RFC-0001: Curator Dashboard v2 (`.claude/planning/rfcs/0001-curator-dashboard-v2.md`) +- `Ai::AudioTourGenerator` servis (`app/services/ai/audio_tour_generator.rb`) +- `AudioTour` model (`app/models/audio_tour.rb`) +- ElevenLabs API pricing: https://elevenlabs.io/pricing diff --git a/.claude/planning/decisions/2026-02-05-per-resource-suggestion-models.md b/.claude/planning/decisions/2026-02-05-per-resource-suggestion-models.md new file mode 100644 index 00000000..339c207b --- /dev/null +++ b/.claude/planning/decisions/2026-02-05-per-resource-suggestion-models.md @@ -0,0 +1,426 @@ +# ADR-0003: Replace ContentChange with Per-Resource Suggestion Models + +## Status +Proposed + +## Datum +2026-02-05 + +## Autori +Tech Lead, Product Manager + +## Context + +Curator dashboard koristi polimorfni `ContentChange` model za sve content prijedloge (Location, Experience, Plan, AudioTour). Ovaj model: + +1. **Ne radi na produkciji** — Feature flag `curator_edit_delete` je disabled. Jedini funkcionalni suggestion sistem je `PhotoSuggestion` koji je per-resource model. + +2. **JSONB proposed_data gubi type safety** — Form parametri dolaze kao stringovi (`"1"`, `"true"`). Kad se sprema u JSONB, tipovi se gube. Pri `approve!()`, `location.update!(proposed_data)` šalje `location_category_ids: ["1", "2"]` umjesto `[1, 2]`. Rails asocijacije ne rade ispravno. + +3. **File attachments su isključeni** — Komentar u kodu: `# Note: File attachments (photos, audio) are not included in proposals`. Kurator popuni formu SA slikama, ali proposal ih tiho odbaci. Kad admin odobri, resurs nema slika. + +4. **Merge contributions je destruktivan** — `merge_contributions!` radi shallow `Hash#merge!` po redu kreacije. Kad kurator B edituje isti ključ kao kurator A, podatci kuratora A nestanu bez upozorenja. + +5. **safe_attributes_for je krhka** — Svaki model ima hardcoded whitelist u ContentChange modelu. Kad se doda novo polje na Location, mora se ručno dodati i u ContentChange. Lako se zaboravi. + +6. **Dual identity problem** — Model ima i `changeable_type` (polimorfna asocijacija) i `changeable_class` (string kolona za create kad changeable_id je nil). Kontroleri moraju koristiti `.or()` query na oba. + +7. **PhotoSuggestion dokazuje da per-resource radi** — Jednostavan model, typed kolone, Active Storage za slike, `approve!`/`reject!` metode. Na produkciji je i funkcioniše. + +### Zahvaćeni fajlovi + +| Fajl | Linije | Opis | +|------|--------|------| +| `app/models/content_change.rb` | 295 | Polimorfni model | +| `app/models/curator_review.rb` | 27 | Peer review na ContentChange | +| `app/models/content_change_contribution.rb` | ~30 | Multi-curator contributions | +| `app/controllers/curator/proposals_controller.rb` | ~60 | Curator proposal pregled | +| `app/controllers/curator/admin/content_changes_controller.rb` | ~80 | Admin approve/reject | +| `app/views/curator/proposals/` | ~200 | Proposal views | +| `test/models/content_change_test.rb` | 480 | Testovi koji prolaze ali testiraju broken flow | +| `test/controllers/curator/*/` | 240+ | Controller testovi | + +## Decision + +**Zamijeniti `ContentChange` sa per-resource suggestion modelima:** + +- `LocationSuggestion` — za predlaganje promjena na lokacijama (uključujući slike — zamjenjuje i `PhotoSuggestion`) +- `ExperienceSuggestion` — za predlaganje promjena na iskustvima +- `PlanSuggestion` — za predlaganje promjena na planove + +**Ukinuti `PhotoSuggestion`** — funkcionalnost slika se apsorbira u `LocationSuggestion` koji podržava Active Storage. Nema razloga za odvojen model kad LocationSuggestion može držati i tekst i slike. + +### Ključni principi dizajna + +#### 1. Jedan pending suggestion per resurs + multi-contributor + +```ruby +# Unique constraint: samo JEDAN pending suggestion po resursu +add_index :location_suggestions, :location_id, + unique: true, + where: "status = 0 AND location_id IS NOT NULL", + name: "idx_one_pending_per_location" + +# Više kuratora doprinosi ISTOM suggestion-u +class LocationSuggestionContribution < ApplicationRecord + belongs_to :location_suggestion + belongs_to :user + + # Iste typed kolone kao LocationSuggestion + # Samo popunjena polja = polja koja ovaj kurator mijenja + t.string :proposed_name # nil ako ne mijenja name + t.text :proposed_description # nil ako ne mijenja opis + t.text :notes # Komentar kuratora + + validates :user_id, uniqueness: { + scope: :location_suggestion_id, + message: "already contributed" + } +end +``` + +**Kako radi merge:** Suggestion drži "finalno stanje" prijedloga. Kad kurator B doprinese, njegovi non-nil values se upisuju u suggestion polja, a stari values (od kuratora A) se čuvaju u contribution-u kuratora A. Admin vidi finalno stanje + historiju ko je šta mijenjao. + +```ruby +# Primjer toka: +# 1. Kurator A predlaže: name="Stari Most", description="..." +suggestion = LocationSuggestion.find_or_create_pending!(location, user: kurator_a) +# → suggestion.proposed_name = "Stari Most" +# → suggestion.proposed_description = "..." + +# 2. Kurator B doprinosi: description="bolja verzija", city="Mostar" +suggestion.add_contribution(user: kurator_b, + proposed_description: "bolja verzija", + proposed_city: "Mostar") +# → Kreira contribution za kuratora A sa starim description +# → Ažurira suggestion: description="bolja verzija", city="Mostar" +# → Kreira contribution za kuratora B sa novim vrijednostima + +# 3. Admin vidi finalno stanje + diff + ko je šta mijenjao +``` + +**Zašto ne odvojeni suggestion-i po kuratoru:** Korisnik je rekao da želi jedan suggestion per resurs sa višestrukim kontributorima. Ovo sprečava konflikte (dva kuratora rade na istom resursu nezavisno, admin mora da bira). Umjesto toga, kuratori kolaboriraju na jednom prijedlogu. + +#### 2. Typed kolone umjesto JSONB + +```ruby +# LOŠE (ContentChange) +t.jsonb :proposed_data # {"name": "Stari Most", "category_ids": ["1","2"]} + +# DOBRO (LocationSuggestion) +t.string :proposed_name +t.string :proposed_city +t.text :proposed_description +t.json :proposed_category_ids # Typed kao Array +``` + +**Zašto:** Rails zna tip svake kolone. Nema type mismatch pri approve. Validacije rade normalno. + +**Ključna razlika od ContentChange:** Contribution model ima **iste typed kolone** kao suggestion model. Nema JSONB. Merge je per-kolona i type-safe. + +#### 3. Active Storage za fajlove (zamjenjuje PhotoSuggestion) + +```ruby +class LocationSuggestion < ApplicationRecord + has_many_attached :proposed_photos + + validate :acceptable_photos + + def acceptable_photos + return unless proposed_photos.attached? + proposed_photos.each do |photo| + errors.add(:proposed_photos, "max 10MB") if photo.blob.byte_size > 10.megabytes + unless %w[image/jpeg image/png image/gif image/webp].include?(photo.blob.content_type) + errors.add(:proposed_photos, "must be JPEG, PNG, GIF, or WebP") + end + end + errors.add(:proposed_photos, "max 10") if proposed_photos.size > 10 + end +end + +class ExperienceSuggestion < ApplicationRecord + has_one_attached :proposed_cover_photo +end +``` + +**Zašto apsorbirati PhotoSuggestion:** Kurator predlaže promjene na lokaciji — ime, opis, koordinate, I slike. Razdvajanje teksta i slika u odvojene modele znači dva proposal workflow-a za isti resurs. Sa LocationSuggestion, jedan prijedlog pokriva sve. + +**Migracija sa PhotoSuggestion:** Pending photo suggestion-e migrirati u LocationSuggestion zapise (samo sa proposed_photos). Odobrene/odbijene ostaviti kao historiju. + +#### 4. Suggestable concern za zajedničku logiku + +```ruby +# app/models/concerns/suggestable.rb +module Suggestable + extend ActiveSupport::Concern + + included do + belongs_to :user # Kurator/admin ili system_user za AI + belongs_to :reviewed_by, class_name: "User", optional: true + + enum :status, { pending: 0, approved: 1, rejected: 2 } + enum :change_type, { create_resource: 0, update_resource: 1, delete_resource: 2 } + enum :origin, { human: 0, ai_generated: 1 }, prefix: :origin # ADR-0007 + + validates :user, presence: true + + scope :pending_review, -> { where(status: :pending) } + scope :recent, -> { order(created_at: :desc) } + scope :human_suggestions, -> { where(origin: :human) } + scope :ai_suggestions, -> { where(origin: :ai_generated) } + end + + def approve!(admin, notes: nil) + transaction do + apply_changes! + update!( + status: :approved, + reviewed_by: admin, + reviewed_at: Time.current, + admin_notes: notes + ) + end + end + + def reject!(admin, notes: nil) + update!( + status: :rejected, + reviewed_by: admin, + reviewed_at: Time.current, + admin_notes: notes + ) + end + + # Dodaj contribution od drugog kuratora + def add_contribution(user:, notes: nil, **proposed_fields) + non_nil_fields = proposed_fields.compact + + transaction do + # Sačuvaj contribution za audit trail + contribution = contributions.create!( + user: user, + notes: notes, + **non_nil_fields + ) + + # Ažuriraj suggestion sa novim vrijednostima + non_nil_fields.each do |field, value| + self[field] = value if respond_to?("#{field}=") + end + save! + end + end + + # Svaki model implementira + def apply_changes! + raise NotImplementedError + end + + # Polja koja su popunjena (non-nil proposed_* kolone) + def proposed_changes + attributes.select { |k, v| k.start_with?("proposed_") && v.present? } + end +end +``` + +#### 5. Admin = direktan CRUD, Kurator = suggestion + +```ruby +# app/controllers/curator/locations_controller.rb +def update + if current_user.admin? + # Direktno ažuriranje + LocationUpdater.new(@location, location_params).call + redirect_to curator_location_path(@location) + else + # Find or create pending suggestion za ovaj resurs + suggestion = LocationSuggestion.find_or_create_pending!( + @location, user: current_user + ) + suggestion.add_contribution( + user: current_user, + **suggestion_params + ) + redirect_to curator_location_path(@location), + notice: "Promjene predložene za pregled." + end +end +``` + +### Migracije + +```ruby +# Primjer: create_location_suggestions +class CreateLocationSuggestions < ActiveRecord::Migration[8.0] + def change + create_table :location_suggestions do |t| + t.references :location, foreign_key: true # nil za create_resource + t.references :user, null: false, foreign_key: true + t.references :reviewed_by, foreign_key: { to_table: :users } + + t.integer :status, default: 0, null: false + t.integer :change_type, default: 0, null: false + t.integer :origin, default: 0, null: false # 0=human, 1=ai_generated (ADR-0007) + t.string :ai_service # Koji AI servis (ADR-0007) + t.datetime :reviewed_at + t.text :admin_notes + + # Typed polja — ista kao Location atributi + t.string :proposed_name + t.string :proposed_city + t.text :proposed_description + t.text :proposed_historical_context + t.decimal :proposed_lat, precision: 10, scale: 7 + t.decimal :proposed_lng, precision: 10, scale: 7 + t.integer :proposed_budget + t.string :proposed_phone + t.string :proposed_email + t.string :proposed_website + t.jsonb :proposed_video_urls, default: [] # Array URL-ova (ADR-0006) + t.jsonb :proposed_social_links, default: {} + t.jsonb :proposed_tags, default: [] + t.jsonb :proposed_category_ids, default: [] + t.jsonb :proposed_experience_type_ids, default: [] + # Slike idu kroz Active Storage (has_many_attached :proposed_photos) + + t.timestamps + end + + # Jedan pending suggestion per lokacija + add_index :location_suggestions, :location_id, + unique: true, + where: "status = 0 AND location_id IS NOT NULL", + name: "idx_one_pending_per_location" + end +end + +# Contribution tabela — ISTE typed kolone za audit trail +class CreateLocationSuggestionContributions < ActiveRecord::Migration[8.0] + def change + create_table :location_suggestion_contributions do |t| + t.references :location_suggestion, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.text :notes + + # Typed polja — kopija suggestion kolona + # Samo non-nil polja = polja koja ovaj kurator mijenja + t.string :proposed_name + t.string :proposed_city + t.text :proposed_description + t.text :proposed_historical_context + t.decimal :proposed_lat, precision: 10, scale: 7 + t.decimal :proposed_lng, precision: 10, scale: 7 + t.integer :proposed_budget + t.string :proposed_phone + t.string :proposed_email + t.string :proposed_website + t.jsonb :proposed_video_urls # Array URL-ova (ADR-0006) + t.jsonb :proposed_social_links + t.jsonb :proposed_tags + t.jsonb :proposed_category_ids + t.jsonb :proposed_experience_type_ids + + t.timestamps + end + + # Jedan contribution per kurator per suggestion + add_index :location_suggestion_contributions, + [:location_suggestion_id, :user_id], + unique: true, + name: "idx_loc_suggestion_contrib_unique_user" + end +end +``` + +**ExperienceSuggestion** — typed kolone: +- `proposed_title`, `proposed_description`, `proposed_category_id`, `proposed_duration` +- `proposed_seasons` (jsonb), `proposed_location_uuids` (jsonb) +- `proposed_contact_name`, `proposed_contact_email`, `proposed_contact_phone`, `proposed_contact_website` +- `proposed_video_urls` (jsonb, ADR-0006) +- `has_one_attached :proposed_cover_photo` + +**PlanSuggestion** — typed kolone: +- `proposed_title`, `proposed_city_name`, `proposed_notes`, `proposed_visibility` +- `proposed_start_date`, `proposed_end_date` +- `proposed_preferences` (jsonb), `proposed_experience_days` (jsonb) +- `has_one_attached :proposed_cover_photo` (ADR-0006) + +Svaki sa odgovarajućom contribution tabelom (iste typed kolone). + +### Cleanup plan + +Kad novi suggestion modeli budu na produkciji i funkcionišu: + +1. Migrirati pending PhotoSuggestion zapise u LocationSuggestion +2. Obrisati tabele: `content_changes`, `content_change_contributions`, `photo_suggestions` +3. Ukloniti modele: `ContentChange`, `ContentChangeContribution`, `CuratorReview`, `PhotoSuggestion` +4. Ukloniti kontrolere: `Curator::ProposalsController`, `Curator::Admin::ContentChangesController`, `Curator::PhotoSuggestionsController`, `Curator::Admin::PhotoSuggestionsController` +5. Ukloniti views: `curator/proposals/`, `curator/photo_suggestions/`, `curator/admin/photo_suggestions/` +6. Ukloniti feature flag: `curator_edit_delete` +7. Ažurirati `CuratorActivity` action types + +## Consequences + +### Positive + +- **Radi na produkciji** — Typed kolone eliminišu type mismatch. Active Storage drži fajlove uz suggestion. +- **Testabilnost** — Svaki model se testira nezavisno sa pravim tipovima, ne JSONB mockovima. +- **Jasna odgovornost** — `LocationSuggestion` zna sve o lokacijama. Ne treba `safe_attributes_for` whitelist. +- **Unified prijedlozi** — Tekst + slike u jednom suggestion-u. Nema odvojenog PhotoSuggestion. Jedan workflow za sve. +- **Type-safe merge** — Contribution model ima iste typed kolone. Merge je per-kolona, ne JSONB. Admin vidi ko je šta mijenjao. +- **Kolaboracija** — Više kuratora radi na istom prijedlogu. Unique constraint sprečava konflikte. +- **Lakše dodavanje polja** — Novo polje = nova kolona na suggestion + contribution modelu. Migracija dokumentuje promjenu. +- **Admin efikasnost** — Direktan CRUD bez proposal overhead-a. + +### Negative + +- **Više tabela** — 3 suggestion tabele + 3 contribution tabele = 6 novih tabela. Značajno više od jednog ContentChange. +- **Duplicirane kolone** — Suggestion i Contribution imaju iste kolone. Kad se doda polje, treba dodati na oba mjesta. +- **Kompleksniji merge** — Iako je type-safe, logika "ažuriraj suggestion polja + sačuvaj contribution" je više koda nego jednostavan `Hash#merge!`. +- **Migracija PhotoSuggestion** — Pending photo suggestion-e treba migrirati. Approved/rejected su historija. +- **Migracija ContentChange** — Existing zapisi trebaju cleanup. + +### Neutral + +- **Neto broj modela** — Od 4 (ContentChange + Contribution + CuratorReview + PhotoSuggestion) na 6 (3 suggestion + 3 contribution) + concern. Više fajlova ali svaki je jednostavniji. +- **Testovi se moraju prepisati** — Ali testovi per model su fokusiraniji i lakši za održavanje. + +## Alternatives Considered + +### Opcija 1: Popraviti ContentChange + +Dodati type casting u `approve!()`, Active Storage polje, fix merge logic. + +- **Prednosti:** Manje promjena. Jedan model. +- **Mane:** JSONB serialization ostaje fragilan. Svaki novi resurs/polje zahtijeva ručno rukovanje. Root cause se ne rješava. +- **Zašto odbačeno:** Fundamentalni problem je arhitekturni — jedan generički model ne može ispravno pokriti specifičnosti različitih resursa. Popravke bi bile zakrpe. + +### Opcija 2: Odvojeni suggestion-i per kurator (bez multi-contributor) + +Svaki kurator kreira svoj suggestion. Admin bira koji prihvata. + +- **Prednosti:** Nema merge logike. Jednostavnije. +- **Mane:** Dva kuratora rade na istoj lokaciji nezavisno. Dupli posao. Admin mora uporediti i birati. Ne skalira. +- **Zašto odbačeno:** Korisnik eksplicitno želi kolaboraciju na jednom prijedlogu per resurs. + +### Opcija 3: Field-level suggestions + +Kurator predlaže promjenu jednog polja (ne cijelog resursa). + +- **Prednosti:** Minimalna forma. Lako za review. +- **Mane:** Ne pokriva create workflow. Admin mora odobriti svako polje pojedinačno. +- **Zašto odbačeno:** Može se dodati kao poboljšanje u budućnosti, ali per-resource suggestion je potreban za create i bulk update. + +### Opcija 4: Zadržati PhotoSuggestion odvojeno + +Ostaviti PhotoSuggestion kakav jeste, novi suggestion modeli samo za tekst. + +- **Prednosti:** PhotoSuggestion radi, ne dirati. +- **Mane:** Dva odvojena workflow-a za isti resurs (tekst vs slike). Kurator mora koristiti dva različita formulara. Admin mora pregledati dva tipa prijedloga za istu lokaciju. +- **Zašto odbačeno:** Unified prijedlog (tekst + slike) je bolji UX i za kuratora i za admina. + +## References + +- RFC-0001: Curator Dashboard v2 (`.claude/planning/rfcs/0001-curator-dashboard-v2.md`) +- Existing `PhotoSuggestion` model (`app/models/photo_suggestion.rb`) — referentna implementacija +- `ContentChange` model (`app/models/content_change.rb`) — model koji se zamjenjuje diff --git a/.claude/planning/decisions/2026-02-05-reviews-management-system.md b/.claude/planning/decisions/2026-02-05-reviews-management-system.md new file mode 100644 index 00000000..ae8d4b9c --- /dev/null +++ b/.claude/planning/decisions/2026-02-05-reviews-management-system.md @@ -0,0 +1,315 @@ +# ADR-0004: Reviews Management System for Curator Dashboard + +## Status +Proposed + +## Datum +2026-02-05 + +## Autori +Product Manager, Tech Lead + +## Context + +`Review` model već postoji u sistemu — polimorfni model (reviewable: Location, Experience, Plan) sa rating (1-5), comment, author_name. Korisnici mogu ostavljati recenzije na javnim stranicama. + +Međutim, **nema nikakvog moderacijskog workflow-a**: + +1. **Reviews se ne moderiraju** — Svaki review je odmah vidljiv. Nema zaštite od spama, neprikladnog sadržaja, ili lažnih recenzija. +2. **Kuratori ne vide reviews** — `Curator::ReviewsController` postoji sa `index`, `show`, `destroy` ali nema moderation status, filtriranje, ili flag sistem. +3. **Nema pregleda po resursu** — Admin/kurator ne može vidjeti "sve reviews za ovu lokaciju" u dashboard kontekstu. +4. **Nema bulk akcija** — Svaki review se mora pojedinačno pregledati. + +### Trenutno stanje + +```ruby +# app/models/review.rb +class Review < ApplicationRecord + include Identifiable + belongs_to :reviewable, polymorphic: true + belongs_to :user, optional: true + validates :rating, presence: true, inclusion: { in: 1..5 } + validates :comment, length: { maximum: 1000 } + # Nema moderation_status + # Nema flag system +end +``` + +```ruby +# db/schema.rb - reviews tabela +create_table "reviews" do |t| + t.string "author_name" + t.text "comment" + t.integer "rating", null: false + t.bigint "reviewable_id", null: false + t.string "reviewable_type", null: false + t.bigint "user_id" + t.string "uuid", limit: 36, null: false + t.timestamps +end +``` + +### Rute +```ruby +# Trenutno u curator namespace +resources :reviews, only: [ :index, :show, :destroy ] +``` + +## Decision + +Implementirati **dvoslojni reviews management sistem**: + +### 1. Review Moderation Status + +Dodati `moderation_status` na Review model: + +```ruby +# Migration +add_column :reviews, :moderation_status, :integer, default: 0, null: false +add_column :reviews, :moderated_by_id, :bigint +add_column :reviews, :moderated_at, :datetime +add_column :reviews, :moderation_notes, :text +add_foreign_key :reviews, :users, column: :moderated_by_id +add_index :reviews, :moderation_status + +# Model +class Review < ApplicationRecord + enum :moderation_status, { + unreviewed: 0, # Default za nove reviews + approved: 1, # Pregledano i odobreno + flagged: 2, # Označeno za pregled + removed: 3 # Uklonjeno (soft delete) + } + + belongs_to :moderated_by, class_name: "User", optional: true + + scope :needs_moderation, -> { where(moderation_status: [:unreviewed, :flagged]) } + scope :visible, -> { where(moderation_status: [:unreviewed, :approved]) } + scope :removed, -> { where(moderation_status: :removed) } +end +``` + +**Odluka o default ponašanju:** Novi reviews su `unreviewed` i **vidljivi na javnim stranicama** (zajedno sa `approved`). Samo `removed` reviews su sakriveni. Ovo izbjegava bottleneck gdje admin mora odobriti svaki review prije nego postane vidljiv. + +### 2. Review Flags (kurator flagging) + +```ruby +# app/models/review_flag.rb +class ReviewFlag < ApplicationRecord + belongs_to :review + belongs_to :user # Kurator koji flaga + + enum :reason, { + spam: 0, + inappropriate: 1, + inaccurate: 2, + duplicate: 3, + other: 4 + } + + validates :reason, presence: true + validates :review_id, uniqueness: { scope: :user_id, + message: "already flagged by this user" } + + after_create :auto_flag_review + + private + + def auto_flag_review + # Automatski promijeni review status na flagged + # kad dobije prvi flag + review.flagged! if review.unreviewed? + end +end +``` + +```ruby +# Migration +create_table :review_flags do |t| + t.references :review, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.integer :reason, null: false, default: 0 + t.text :notes + t.timestamps +end + +add_index :review_flags, [:review_id, :user_id], unique: true +``` + +### 3. Controller struktura + +```ruby +# Kurator: pregled + flag +# app/controllers/curator/reviews_controller.rb +class Curator::ReviewsController < Curator::BaseController + def index + @reviews = Review.includes(:reviewable, :user) + @reviews = @reviews.where(reviewable_type: params[:type]) if params[:type] + @reviews = @reviews.where(moderation_status: params[:status]) if params[:status] + @reviews = @reviews.where("rating <= ?", params[:max_rating]) if params[:max_rating] + @reviews = @reviews.order(created_at: :desc).page(params[:page]) + end + + def show + @review = Review.find(params[:id]) + @flags = @review.review_flags.includes(:user) + end + + def flag + @review = Review.find(params[:id]) + flag = @review.review_flags.build( + user: current_user, + reason: params[:reason], + notes: params[:notes] + ) + + if flag.save + record_activity("review_flagged", recordable: @review) + redirect_to curator_review_path(@review), notice: "Review flagged." + else + redirect_to curator_review_path(@review), alert: flag.errors.full_messages.join(", ") + end + end +end + +# Admin: moderacija +# app/controllers/curator/admin/reviews_controller.rb +class Curator::Admin::ReviewsController < Curator::Admin::BaseController + def index + @reviews = Review.needs_moderation + .includes(:reviewable, :user, :review_flags) + .order(created_at: :desc) + .page(params[:page]) + end + + def approve + @review = Review.find(params[:id]) + @review.update!( + moderation_status: :approved, + moderated_by: current_user, + moderated_at: Time.current, + moderation_notes: params[:notes] + ) + record_activity("review_approved", recordable: @review) + redirect_to curator_admin_reviews_path, notice: "Review approved." + end + + def remove + @review = Review.find(params[:id]) + @review.update!( + moderation_status: :removed, + moderated_by: current_user, + moderated_at: Time.current, + moderation_notes: params[:notes] + ) + record_activity("review_removed", recordable: @review) + redirect_to curator_admin_reviews_path, notice: "Review removed." + end + + def bulk_action + review_ids = params[:review_ids] + action = params[:bulk_action] # "approve" or "remove" + + Review.where(id: review_ids).find_each do |review| + review.update!( + moderation_status: action.to_sym, + moderated_by: current_user, + moderated_at: Time.current + ) + end + + redirect_to curator_admin_reviews_path, + notice: "#{review_ids.size} reviews #{action}d." + end +end +``` + +### 4. Rute + +```ruby +namespace :curator do + resources :reviews, only: [:index, :show] do + member do + post :flag + end + end + + namespace :admin do + resources :reviews, only: [:index, :show] do + member do + post :approve + post :remove + end + collection do + post :bulk_action + end + end + end +end +``` + +### 5. Public scope promjena + +Javne stranice trebaju koristiti `.visible` scope umjesto default: + +```ruby +# Prije +@reviews = @location.reviews.recent + +# Poslije +@reviews = @location.reviews.visible.recent +``` + +## Consequences + +### Positive + +- **Zaštita od spama** — Reviews se mogu flaggati i ukloniti +- **Kurator učestvuje** — Kurator može flaggati problematične reviews bez admin pristupa +- **Auto-escalation** — Flag automatski stavlja review u `flagged` status za admin pregled +- **Bulk moderacija** — Admin može brzo obraditi više reviews +- **Soft delete** — `removed` umjesto fizičkog brisanja. Moguć recovery. +- **Audit trail** — `moderated_by`, `moderated_at`, `moderation_notes` za praćenje + +### Negative + +- **Migracija na postojećoj tabeli** — Sve postojeće reviews dobijaju `unreviewed` status. Ako ih je puno, mogao bi biti backlog za pregled. +- **Public visibility promjena** — Treba ažurirati sve public views da koriste `.visible` scope. Propušteni query može prikazati `removed` review. +- **Još jedna tabela** — `review_flags` je nova tabela. + +### Neutral + +- **Postojeći destroy ostaje** — `Curator::ReviewsController#destroy` se može zadržati za admin-only hard delete ili zamijeniti sa `remove` (soft delete). Preporučujem zamjenu. +- **Unreviewed su vidljivi** — Namjerna odluka. Alternativa (invisible dok admin ne odobri) bi blokirala UGC tok. + +## Alternatives Considered + +### Opcija 1: Samo admin delete (bez flag/moderation) + +Admin može samo obrisati reviews. Nema statusa, nema flagova. + +- **Prednosti:** Minimalna promjena. Radi sad. +- **Mane:** Kurator ne može učestvovati. Admin mora sam pronaći problematične reviews. Nema audit trail. +- **Zašto odbačeno:** Ne skalira. Kad reviews porastu, admin ne može sve pregledati. + +### Opcija 2: Pre-moderation (reviews nevidljivi dok admin ne odobri) + +Svaki review čeka admin approval prije javnog prikaza. + +- **Prednosti:** Potpuna kontrola kvaliteta. +- **Mane:** UGC tok se zaustavlja. Korisnik ostavlja review i ne vidi ga. Loše iskustvo. Admin bottleneck. +- **Zašto odbačeno:** Za turističku platformu sa malim admin timom, pre-moderation bi ugušio UGC. + +### Opcija 3: AI auto-moderation + +Koristiti AI za automatsku klasifikaciju reviews (spam, sentiment, quality). + +- **Prednosti:** Skalira bez admin effort-a. +- **Mane:** Košta. Lažni positivi. Kompleksna implementacija. Overkill za trenutni volumen. +- **Zašto odbačeno:** Može se dodati u budućnosti kao enhancement. Trenutno je flag + admin review dovoljno. + +## References + +- RFC-0001: Curator Dashboard v2 (`.claude/planning/rfcs/0001-curator-dashboard-v2.md`) +- Existing `Review` model (`app/models/review.rb`) +- Existing `Curator::ReviewsController` (`app/controllers/curator/reviews_controller.rb`) diff --git a/.claude/planning/decisions/2026-02-05-video-urls-and-cover-photos.md b/.claude/planning/decisions/2026-02-05-video-urls-and-cover-photos.md new file mode 100644 index 00000000..112274d4 --- /dev/null +++ b/.claude/planning/decisions/2026-02-05-video-urls-and-cover-photos.md @@ -0,0 +1,249 @@ +# ADR-0006: Multiple Video URLs + Cover Photo Support + +## Status +Proposed + +## Datum +2026-02-05 + +## Autori +Product Manager, Tech Lead + +## Context + +### Video URL — trenutno stanje + +`Location` ima jednu `video_url` string kolonu. Ovo je ograničavajuće: +- Lokacija može imati više videa (YouTube tura, drone snimak, Instagram reel, TikTok) +- Jedan URL ne pokriva različite platforme i formate +- `Experience` i `Plan` nemaju nikakvu video podršku + +### Cover Photo — trenutno stanje + +| Model | Cover Photo | Kako | +|-------|-------------|------| +| Location | `has_many_attached :photos` | Active Storage, više slika, thumb/medium/large varijante | +| Experience | `has_one_attached :cover_photo` | Active Storage, jedna slika, nema varijanti | +| Plan | Nema | Derivira iz experience-a kroz `display_cover_photos` | + +Experience već ima `cover_photo` ali Plan nema direktan upload — oslanja se na fotografije iz asociranih iskustava. Problem: plan bez iskustava nema cover photo, a admin ponekad želi specifičnu sliku za plan. + +## Decision + +### 1. Multiple video URLs na Location i Experience + +Zamijeniti `video_url` (string kolona) sa `video_urls` (JSONB array) na Location. Dodati isto na Experience. + +```ruby +# Migration za Location +class ChangeVideoUrlToVideoUrlsOnLocations < ActiveRecord::Migration[8.0] + def up + add_column :locations, :video_urls, :jsonb, default: [] + + # Migracija postojećih podataka + execute <<-SQL + UPDATE locations + SET video_urls = jsonb_build_array(video_url) + WHERE video_url IS NOT NULL AND video_url != '' + SQL + + remove_column :locations, :video_url + end + + def down + add_column :locations, :video_url, :string + + execute <<-SQL + UPDATE locations + SET video_url = video_urls->>0 + WHERE jsonb_array_length(video_urls) > 0 + SQL + + remove_column :locations, :video_urls + end +end + +# Migration za Experience +class AddVideoUrlsToExperiences < ActiveRecord::Migration[8.0] + def change + add_column :experiences, :video_urls, :jsonb, default: [] + end +end +``` + +```ruby +# app/models/location.rb +class Location < ApplicationRecord + # Validacija video URL-ova + validate :valid_video_urls + + private + + def valid_video_urls + return if video_urls.blank? + + video_urls.each_with_index do |url, i| + unless url.match?(URI::DEFAULT_PARSER.make_regexp(%w[http https])) + errors.add(:video_urls, "URL ##{i + 1} is not valid") + end + end + end +end + +# app/models/experience.rb — ista validacija +``` + +**UI — Video URLs forma:** +```erb +<%# Dinamički fieldset — dodaj/ukloni URL-ove %> +
+ <% (resource.video_urls.presence || [""]).each_with_index do |url, i| %> +
+ <%= f.url_field "video_urls[]", value: url, placeholder: "https://youtube.com/..." %> + +
+ <% end %> + +
+``` + +### 2. Cover Photo na Plan modelu + +```ruby +# Migration +class AddCoverPhotoToPlans < ActiveRecord::Migration[8.0] + # Active Storage ne zahtijeva migraciju — koristi existing active_storage_attachments + # Samo treba dodati has_one_attached u model +end + +# app/models/plan.rb +class Plan < ApplicationRecord + has_one_attached :cover_photo + + def display_cover_photo + # Prioritet: direktan upload > experience cover > location photo + return cover_photo if cover_photo.attached? + + # Postojeći fallback + experiences_with_photos = experiences.select { |e| e.cover_photo.attached? } + return experiences_with_photos.first.cover_photo if experiences_with_photos.any? + + # Dalje fallback na lokacije + experiences.each do |exp| + exp.locations.each do |loc| + return loc.photos.first if loc.photos.attached? + end + end + + nil + end +end +``` + +**UI — Plan Cover Photo forma:** +```erb +<%# Isti pattern kao Experience forma %> +<% if plan.persisted? && plan.cover_photo.attached? %> +
+ <%= image_tag rails_blob_path(plan.cover_photo, disposition: "inline"), + class: "h-32 w-48 object-cover rounded-lg" %> + +
+<% end %> +<%= f.file_field :cover_photo, accept: "image/*" %> +

Preporučena veličina: 1200x800px

+``` + +### 3. Varijante za Experience cover_photo + +Experience ima `has_one_attached :cover_photo` ali nema definirane varijante. Dodati za konzistentnost sa Location: + +```ruby +# app/models/experience.rb +has_one_attached :cover_photo do |attachable| + attachable.variant :thumb, resize_to_limit: [200, 200] + attachable.variant :medium, resize_to_limit: [400, 400] + attachable.variant :large, resize_to_limit: [800, 800] +end + +# app/models/plan.rb — isti varijante +has_one_attached :cover_photo do |attachable| + attachable.variant :thumb, resize_to_limit: [200, 200] + attachable.variant :medium, resize_to_limit: [400, 400] + attachable.variant :large, resize_to_limit: [800, 800] +end +``` + +### 4. Uticaj na Suggestion modele + +Ove promjene zahtijevaju ažuriranje per-resource suggestion modela (ADR-0003): + +```ruby +# LocationSuggestion — dodati: +t.jsonb :proposed_video_urls, default: [] +# (proposed_photos već postoji kroz Active Storage) + +# ExperienceSuggestion — dodati: +t.jsonb :proposed_video_urls, default: [] +# (proposed_cover_photo već postoji kroz Active Storage) + +# PlanSuggestion — dodati: +# (proposed_cover_photo — novi, kroz Active Storage) +has_one_attached :proposed_cover_photo +``` + +Iste kolone treba dodati i na odgovarajuće contribution modele. + +## Consequences + +### Positive + +- **Više videa po lokaciji** — YouTube, drone, TikTok, Instagram — svi na jednom mjestu +- **Video na iskustvima** — Experience može imati promo video ili tutorial +- **Direktan cover za planove** — Admin ne zavisi od experience-a za vizual plana +- **Konzistentne varijante** — Svi modeli imaju thumb/medium/large +- **Backward compatible** — Migracija čuva postojeće video_url podatke + +### Negative + +- **JSONB za URL-ove** — Nema foreign key constraint. URL validacija mora biti u modelu. +- **Stimulus controller** — Potreban `dynamic-fields` Stimulus controller za add/remove UI. Nije kompleksno ali je novi JS. +- **Plan cover photo može biti zbunjujuć** — Ako plan ima i direktnu sliku i experience slike, šta se prikazuje? (Riješeno prioritetom u `display_cover_photo`) + +### Neutral + +- **Active Storage ne zahtijeva migraciju** — Polimorfna tabela se koristi automatski kad se doda `has_one_attached`. +- **Video URL ne zahtijeva embed** — Za sada čuvamo samo URL. Embed/player se može dodati kasnije. + +## Alternatives Considered + +### Opcija 1: Odvojena video_urls tabela (has_many) + +```ruby +class LocationVideo < ApplicationRecord + belongs_to :location + validates :url, presence: true + validates :platform, inclusion: { in: %w[youtube tiktok instagram other] } +end +``` + +- **Prednosti:** Čistiji model. Može se dodati platform, title, thumbnail. +- **Mane:** Previše overhead za listu URL-ova. Svaki video je novi DB record. CRUD za nested resources. +- **Zašto odbačeno:** JSONB array je dovoljno za URL-ove. Ako zatrebaju metapodaci, može se migrirati u budućnosti. + +### Opcija 2: Zadržati video_url (singular) i dodati video_url na Experience + +- **Prednosti:** Minimalna promjena. +- **Mane:** Lokacija sa više videa i dalje ne može čuvati sve. Problem se samo širi. +- **Zašto odbačeno:** Korisnik eksplicitno želi više video URL-ova. + +## References + +- ADR-0003: Per-Resource Suggestion Models (`.claude/planning/decisions/2026-02-05-per-resource-suggestion-models.md`) +- RFC-0001: Curator Dashboard v2 (`.claude/planning/rfcs/0001-curator-dashboard-v2.md`) +- Location model (`app/models/location.rb`) +- Experience model (`app/models/experience.rb`) +- Plan model (`app/models/plan.rb`) diff --git a/.claude/planning/rfcs/0001-curator-dashboard-v2.md b/.claude/planning/rfcs/0001-curator-dashboard-v2.md new file mode 100644 index 00000000..19889f5b --- /dev/null +++ b/.claude/planning/rfcs/0001-curator-dashboard-v2.md @@ -0,0 +1,388 @@ +# RFC-0001: Curator Dashboard v2 — Per-Resource Suggestions, Reviews, Audio Tours + +## Summary + +Kompletna refaktorizacija curator dashboard sistema. Zamjena nefunkcionalnog polimorfnog `ContentChange` modela sa **per-resource suggestion modelima**, dodavanje **reviews upravljanja**, i integracija **audio tour generisanja** direktno iz curator dashboarda. Admin korisnici dobijaju direktan CRUD, kuratori predlažu promjene. + +## Motivation + +### Problem + +Trenutni `ContentChange` model ne radi na produkciji. Samo `PhotoSuggestion` funkcioniše. Ključni problemi: + +1. **Jedan model za sve resurse** — Polimorfni JSONB pristup ne može pokriti specifičnosti svakog resursa (M2M asocijacije za lokacije, ordered lokacije za iskustva, dnevni raspored za planove) +2. **Fotografije isključene iz prijedloga** — Kod eksplicitno kaže `File attachments are not included in proposals`. Kurator popuni formu sa slikama, ali prijedlog ih ignoriše +3. **Type mismatch pri approve** — Form parametri su stringovi, JSONB deserijalizacija ne konvertuje tipove nazad +4. **Merge contributions gubi podatke** — Shallow `Hash#merge!` znači da zadnji kurator prepiše prethodnog +5. **Zbunjujući UX za create** — Kurator "kreira" lokaciju koja ne postoji dok admin ne odobri +6. **PhotoSuggestion je odvojen** — Dokazuje da per-resource pristup radi, ali lekcija nije primijenjena na ostale resurse + +### Ko je affected? + +- **Kuratori** — Ne mogu predlagati promjene osim slika +- **Admini** — Ne mogu direktno upravljati sadržajem (sve je iza disabled feature flaga) +- **Platforma** — Sadržaj stagnira jer nema efikasnog workflow-a za promjene + +### Zašto sada? + +Feature flag `curator_edit_delete` je disabled na produkciji. Jedina funkcionalna akcija za kuratore je predlaganje slika. Platforma treba funkcionalan content management odmah. + +## Detailed Design + +### Overview + +``` +┌─────────────────────────────────────────────────────────┐ +│ Curator Dashboard │ +├──────────────┬──────────────────────────────────────────┤ +│ Admin │ Direktan CRUD na svim resursima │ +│ (user_type │ + Pregled i odobravanje suggestion-a │ +│ = admin) │ + Upravljanje reviews │ +│ │ + Pokretanje audio tour generisanja │ +├──────────────┼──────────────────────────────────────────┤ +│ Curator │ Read-only pregled resursa │ +│ (user_type │ + LocationSuggestion (tekst + slike) │ +│ = curator)│ + ExperienceSuggestion (novo) │ +│ │ + PlanSuggestion (novo) │ +│ │ + Pregled reviews + flagging │ +└──────────────┴──────────────────────────────────────────┘ +``` + +**Ključne razlike od prethodnog sistema:** +- **Jedan pending suggestion per resurs** — unique constraint, više kuratora doprinosi istom +- **PhotoSuggestion se ukida** — slike idu u LocationSuggestion +- **Multi-contributor** — svaki kurator doprinosi istom suggestion-u, typed kolone za audit trail +- **Human vs AI origin** — svaki suggestion ima `origin` (human/ai_generated) i `ai_service` polje. AI servisi kreiraju suggestion-e umjesto direktnog pisanja u bazu. Kuratori vide human i AI prijedloge odvojeno (ADR-0007) + +### Princip dizajna: Dva režima rada + +**Admin režim:** Direktan CRUD. Forme rade kao standardne Rails forme — `Location.create!`, `Location.update!`, `Location.destroy!`. Nema proposal workflow-a. Admin je autoritet. + +**Curator režim:** Suggestion-based. Kurator vidi resurs, klikne "Predloži promjenu", popuni formu specifičnu za taj resurs. Suggestion se kreira sa statusom `pending`. Admin odobri ili odbije. + +### Data Model + +#### A. Per-Resource Suggestion modeli + +**Ključni principi:** +- **Jedan pending suggestion per resurs** (unique constraint u bazi) +- **Multi-contributor** — više kuratora doprinosi istom suggestion-u +- **Typed kolone** umjesto JSONB — i na suggestion i na contribution modelima +- **Active Storage za fajlove** — slike idu uz suggestion (zamjenjuje PhotoSuggestion) + +Detaljan dizajn: vidi **ADR-0003** (`.claude/planning/decisions/2026-02-05-per-resource-suggestion-models.md`) + +```ruby +# Suggestable concern — zajednicka logika +module Suggestable + included do + belongs_to :user + belongs_to :reviewed_by, class_name: "User", optional: true + has_many :contributions # Per-resource contribution model + enum :status, { pending: 0, approved: 1, rejected: 2 } + enum :change_type, { create_resource: 0, update_resource: 1, delete_resource: 2 } + end + + enum :origin, { human: 0, ai_generated: 1 }, prefix: :origin + attribute :ai_service, :string # "location_enricher", "experience_syncer", ... + scope :human_suggestions, -> { where(origin: :human) } + scope :ai_suggestions, -> { where(origin: :ai_generated) } + end + + def approve!(admin, notes: nil) ... end + def reject!(admin, notes: nil) ... end + def add_contribution(user:, **proposed_fields) ... end +end +``` + +```ruby +# LocationSuggestion — zamjenjuje i ContentChange i PhotoSuggestion za lokacije +class LocationSuggestion < ApplicationRecord + include Suggestable + belongs_to :location, optional: true + has_many :contributions, class_name: "LocationSuggestionContribution" + has_many_attached :proposed_photos # Zamjenjuje PhotoSuggestion + # Typed kolone: proposed_name, proposed_city, proposed_description, ... +end + +# LocationSuggestionContribution — audit trail, iste typed kolone +class LocationSuggestionContribution < ApplicationRecord + belongs_to :location_suggestion + belongs_to :user + # Iste typed kolone — samo popunjena = polja koja ovaj kurator mijenja +end +``` + +```ruby +# ExperienceSuggestion +class ExperienceSuggestion < ApplicationRecord + include Suggestable + belongs_to :experience, optional: true + has_many :contributions, class_name: "ExperienceSuggestionContribution" + has_one_attached :proposed_cover_photo + # Typed kolone: proposed_title, proposed_description, proposed_seasons, + # proposed_video_urls (jsonb), proposed_contact_*, ... +end + +# PlanSuggestion +class PlanSuggestion < ApplicationRecord + include Suggestable + belongs_to :plan, optional: true + has_many :contributions, class_name: "PlanSuggestionContribution" + has_one_attached :proposed_cover_photo # ADR-0006: direktan cover photo + # Typed kolone: proposed_title, proposed_city_name, proposed_experience_days, ... +end +``` + +#### B. PhotoSuggestion — UKIDA SE + +`PhotoSuggestion` se apsorbira u `LocationSuggestion`. Funkcionalnost slika postaje dio unified suggestion workflow-a. Pending photo suggestion-e se migriraju u LocationSuggestion zapise. + +#### C. Reviews upravljanje + +`Review` model već postoji (polimorfni: Location, Experience, Plan). Curator dashboard treba: + +```ruby +# Rute — reviews su read + moderate (ne create) +# Kurator: pregled reviews, flagging +# Admin: pregled + odobravanje/brisanje/odgovaranje + +# app/controllers/curator/reviews_controller.rb +# Već postoji sa: index, show, destroy +# Proširiti sa: +# - Filtriranje po statusu, resursu, ratingu +# - Bulk akcije za admin (approve/reject multiple) +# - Flag system za kuratore + +# app/models/review.rb — dodati: +# - enum :moderation_status (unreviewed, approved, flagged, removed) +# - scope :needs_moderation +# - scope :flagged +``` + +**Nova tabela: review_flags** +```ruby +create_table :review_flags do |t| + t.references :review, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true # Kurator koji flaga + t.string :reason, null: false # spam, inappropriate, inaccurate, other + t.text :notes + t.timestamps + + t.index [:review_id, :user_id], unique: true # Jedan flag per kurator +end +``` + +#### D. Audio Tour generisanje + +Integracija postojećeg `Ai::AudioTourGenerator` servisa u curator dashboard: + +```ruby +# Admin-only akcija na location show stranici +# app/controllers/curator/locations_controller.rb +def generate_audio_tour + require_admin # Samo admin može pokrenuti + @location = Location.find(params[:id]) + + Ai::AudioTourGenerateJob.perform_later( + location_id: @location.id, + locale: params[:locale] || "bs", + requested_by: current_user.id + ) + + redirect_to curator_location_path(@location), + notice: "Audio tura se generise u pozadini..." +end +``` + +UI na location show stranici: +- Status postojeće audio ture (ima/nema, za koje jezike) +- Dugme "Generiši audio turu" (samo za admin) +- Player za postojeću audio turu +- Dropdown za izbor jezika + +### Routes + +```ruby +namespace :curator do + resources :locations do + # Suggestion-i (kurator predlaže promjenu ili dodaje contribution) + resources :location_suggestions, only: [:new, :create, :edit, :update] + member do + post :generate_audio_tour # Admin only + end + collection do + get :needs_photos + end + end + + resources :experiences do + resources :experience_suggestions, only: [:new, :create, :edit, :update] + end + + resources :plans do + resources :plan_suggestions, only: [:new, :create, :edit, :update] + end + + resources :reviews, only: [:index, :show] do + member do + post :flag # Kurator flaga review + end + end + + # Admin sekcija + namespace :admin do + # Unified suggestion pregled — svi tipovi na jednom mjestu + resources :suggestions, only: [:index] # Dashboard sa svim pending + resources :location_suggestions, only: [:show] do + member { post :approve; post :reject } + end + resources :experience_suggestions, only: [:show] do + member { post :approve; post :reject } + end + resources :plan_suggestions, only: [:show] do + member { post :approve; post :reject } + end + resources :reviews, only: [:index, :show] do + member { post :approve; post :remove } + collection { post :bulk_action } + end + # Postojeći + resources :users, only: [:index, :show, :edit, :update] + resources :curator_applications, only: [:index, :show] + end +end +``` + +### UI Changes + +#### Location Show (Kurator) +- Pregled svih podataka (read-only) +- Dugme "Predloži promjenu" → otvara LocationSuggestion formu (tekst + slike zajedno) +- Ako postoji pending suggestion, dugme "Doprinesi" → otvara isti formular sa current suggestion podacima +- Sekcija "Reviews" sa listom i flag opcijom +- Audio player ako tura postoji + +#### Location Show (Admin) +- Sve isto kao kurator PLUS: +- Dugme "Uredi" → direktan CRUD (standardna Rails forma) +- Dugme "Obriši" +- Dugme "Generiši audio turu" sa jezičkim opcijama +- Badge sa brojem pending suggestion-a +- Link na admin panel za odobravanje + +#### Admin Suggestion Panel +- Unified inbox svih suggestion-a (sa filterima po tipu resursa) +- Diff prikaz: lijevo original, desno predloženo (side-by-side) +- Approve/Reject sa notes poljem +- Fotografije prikazane inline + +#### Reviews Management +- Lista reviews sa filterima (rating, resurs, status) +- Flagged reviews istaknuti +- Admin: bulk approve/remove +- Kurator: flag dugme + +## Drawbacks + +1. **Više modela/tabela** — 3 suggestion modela + 3 contribution modela + review_flags = 7 novih tabela. Više migracija, više kontrolera. +2. **Duplicirane kolone** — Suggestion i Contribution imaju iste typed kolone. Kad se doda polje na resurs, treba dodati na oba. +3. **Migracija podataka** — Pending ContentChange + pending PhotoSuggestion zapise treba migrirati ili odbaciti. +4. **Kompleksniji merge** — Typed kolone rješavaju type safety, ali logika "ažuriraj suggestion + sačuvaj contribution" je više koda. + +## Alternatives + +### A. Popraviti ContentChange + +Umjesto novih modela, popraviti postojeći: +- Dodati type casting pri approve +- Dodati Active Storage polje +- Popraviti merge logic + +**Odbačeno jer:** Fundamentalni problem je JSONB pristup za typed podatke. Svaki novi resurs ili polje zahtijeva ručno rukovanje serialization/deserialization. Krhko i error-prone. + +### B. Inline field-level suggestions + +Kurator klikne na jedno polje i predloži promjenu samo tog polja. + +**Odbačeno kao standalone jer:** Previše granularno za create. Može se dodati kao poboljšanje u budućnosti (fase 3+), ali per-resource forme su potrebne za create workflow. + +### C. Git-style branching + +Svaki kurator radi na "branchu" resursa, admin "merge-a". + +**Odbačeno jer:** Overkill za turističku platformu sa malim brojem kuratora. Kompleksna implementacija bez proporcionalnog benefita. + +## Unresolved Questions + +1. **Migracija podataka** — Koliko ima pending ContentChange i PhotoSuggestion zapisa na produkciji? Migrirati ih ili odbaciti? +2. **Contribution conflict resolution** — Kad kurator B prepiše polje kuratora A, da li kurator A dobije notifikaciju? Ili je "last write wins" dovoljno uz audit trail? +3. **Notifikacije** — Da li kuratori trebaju email/in-app notifikaciju kad admin odobri/odbije suggestion ili kad neko doprinese? +4. **Audio tour cost control** — Generisanje audio tura košta (ElevenLabs API). Treba li limit po lokaciji/danu? +5. **Review moderation default** — Da li novi reviews trebaju biti `approved` po defaultu ili `unreviewed`? + +## Implementation Plan + +### Faza 1: Admin Direct CRUD (prioritet: VISOK) +Omogućiti `curator_edit_delete` flag za admin korisnike putem Flipper actors. Admini odmah mogu direktno upravljati sadržajem kroz postojeće forme. + +**Deliverables:** +- [ ] Flipper: enable `curator_edit_delete` per-actor za admine +- [ ] Razdvojiti controller logiku: admin = direktan CRUD, curator = suggestion +- [ ] Testovi za oba režima + +### Faza 2: Per-Resource Suggestion modeli (prioritet: VISOK) +Zamijeniti ContentChange + PhotoSuggestion sa LocationSuggestion, ExperienceSuggestion, PlanSuggestion. + +**Deliverables:** +- [ ] `Suggestable` concern +- [ ] `LocationSuggestion` model + `LocationSuggestionContribution` + migracija + kontroler + forme + testovi +- [ ] `ExperienceSuggestion` model + `ExperienceSuggestionContribution` + migracija + kontroler + forme + testovi +- [ ] `PlanSuggestion` model + `PlanSuggestionContribution` + migracija + kontroler + forme + testovi +- [ ] Admin unified suggestion inbox + per-type approval panel +- [ ] Migracija pending PhotoSuggestion → LocationSuggestion +- [ ] Migracija ili cleanup starih ContentChange zapisa + +### Faza 2.5: AI Pipeline Adaptation (prioritet: VISOK) +Adaptirati AI servise da kreiraju suggestion-e umjesto direktnog pisanja u bazu (ADR-0007). + +**Deliverables:** +- [ ] System user (`ai@usput.ba`) seed +- [ ] `Ai::LocationEnricher` → kreira `LocationSuggestion` (origin: ai_generated) +- [ ] `Ai::ExperienceTypeClassifier` → kreira `LocationSuggestion` za experience_type_ids +- [ ] `Ai::ExperienceLocationSyncer` → kreira `ExperienceSuggestion` za location_uuids +- [ ] Dashboard: odvojeni tabovi [Human] / [AI] / [All] za pending suggestions +- [ ] `needs_ai_regeneration` flag → trigeruje kreiranje AI suggestion-a umjesto direktne promjene +- [ ] Testovi za AI → suggestion tok + +**Izuzetak:** `Ai::AudioTourGenerator` ostaje direktan (admin-only akcija = eksplicitno odobrenje). + +### Faza 3: Reviews Management (prioritet: SREDNJI) +Dodati moderation workflow za korisničke recenzije. + +**Deliverables:** +- [ ] `moderation_status` kolona na reviews tabeli +- [ ] `ReviewFlag` model + migracija +- [ ] Curator reviews controller (index, show, flag) +- [ ] Admin reviews controller (approve, remove, bulk_action) +- [ ] UI za pregled i filtriranje reviews +- [ ] Testovi + +### Faza 4: Audio Tour Integration (prioritet: SREDNJI) +Integrisati audio tour generisanje u curator dashboard. + +**Deliverables:** +- [ ] `generate_audio_tour` akcija na locations controlleru +- [ ] Background job za generisanje +- [ ] UI: status, player, generate dugme na location show +- [ ] Testovi + +### Faza 5: Cleanup (prioritet: NIZAK) +- [ ] Ukloniti `ContentChange` model, migracije, kontrolere, views +- [ ] Ukloniti `CuratorReview` model +- [ ] Ukloniti `ContentChangeContribution` model +- [ ] Ukloniti `PhotoSuggestion` model + kontroleri + views +- [ ] Ukloniti `Proposals` kontroler i views +- [ ] Ukloniti `curator_edit_delete` feature flag (više nije potreban) +- [ ] Ažurirati CuratorActivity action types +- [ ] Drop tabele: `content_changes`, `content_change_contributions`, `photo_suggestions` diff --git a/app/controllers/curator/admin/experience_suggestions_controller.rb b/app/controllers/curator/admin/experience_suggestions_controller.rb new file mode 100644 index 00000000..72f6e931 --- /dev/null +++ b/app/controllers/curator/admin/experience_suggestions_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Curator + module Admin + # Experience suggestions approval controller for admin users. + # Allows admins to approve or reject experience suggestions from curators. + class ExperienceSuggestionsController < BaseController + before_action :set_suggestion, only: [ :show, :approve, :reject ] + + def show + end + + def approve + if @suggestion.pending? + if @suggestion.approve!(current_user, notes: params[:admin_notes]) + record_activity(:approve_suggestion, recordable: @suggestion, metadata: { type: "ExperienceSuggestion" }) + redirect_to curator_admin_suggestions_path, + notice: "Prijedlog odobren." + else + redirect_to curator_admin_experience_suggestion_path(@suggestion), + alert: "Greška pri odobravanju prijedloga." + end + else + redirect_to curator_admin_suggestions_path, + alert: "Prijedlog je već pregledan." + end + rescue => e + redirect_to curator_admin_experience_suggestion_path(@suggestion), + alert: "Greška: #{e.message}" + end + + def reject + if @suggestion.pending? + if @suggestion.reject!(current_user, notes: params[:admin_notes]) + record_activity(:reject_suggestion, recordable: @suggestion, metadata: { type: "ExperienceSuggestion" }) + redirect_to curator_admin_suggestions_path, + notice: "Prijedlog odbijen." + else + redirect_to curator_admin_experience_suggestion_path(@suggestion), + alert: "Greška pri odbijanju prijedloga." + end + else + redirect_to curator_admin_suggestions_path, + alert: "Prijedlog je već pregledan." + end + end + + private + + def set_suggestion + @suggestion = ExperienceSuggestion.find(params[:id]) + end + end + end +end diff --git a/app/controllers/curator/admin/location_suggestions_controller.rb b/app/controllers/curator/admin/location_suggestions_controller.rb new file mode 100644 index 00000000..a2686509 --- /dev/null +++ b/app/controllers/curator/admin/location_suggestions_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Curator + module Admin + # Location suggestions approval controller for admin users. + # Allows admins to approve or reject location suggestions from curators. + class LocationSuggestionsController < BaseController + before_action :set_suggestion, only: [ :show, :approve, :reject ] + + def show + end + + def approve + if @suggestion.pending? + if @suggestion.approve!(current_user, notes: params[:admin_notes]) + record_activity(:approve_suggestion, recordable: @suggestion, metadata: { type: "LocationSuggestion" }) + redirect_to curator_admin_suggestions_path, + notice: "Prijedlog odobren." + else + redirect_to curator_admin_location_suggestion_path(@suggestion), + alert: "Greška pri odobravanju prijedloga." + end + else + redirect_to curator_admin_suggestions_path, + alert: "Prijedlog je već pregledan." + end + rescue => e + redirect_to curator_admin_location_suggestion_path(@suggestion), + alert: "Greška: #{e.message}" + end + + def reject + if @suggestion.pending? + if @suggestion.reject!(current_user, notes: params[:admin_notes]) + record_activity(:reject_suggestion, recordable: @suggestion, metadata: { type: "LocationSuggestion" }) + redirect_to curator_admin_suggestions_path, + notice: "Prijedlog odbijen." + else + redirect_to curator_admin_location_suggestion_path(@suggestion), + alert: "Greška pri odbijanju prijedloga." + end + else + redirect_to curator_admin_suggestions_path, + alert: "Prijedlog je već pregledan." + end + end + + private + + def set_suggestion + @suggestion = LocationSuggestion.find(params[:id]) + end + end + end +end diff --git a/app/controllers/curator/admin/plan_suggestions_controller.rb b/app/controllers/curator/admin/plan_suggestions_controller.rb new file mode 100644 index 00000000..26439d25 --- /dev/null +++ b/app/controllers/curator/admin/plan_suggestions_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Curator + module Admin + # Plan suggestions approval controller for admin users. + # Allows admins to approve or reject plan suggestions from curators. + class PlanSuggestionsController < BaseController + before_action :set_suggestion, only: [ :show, :approve, :reject ] + + def show + end + + def approve + if @suggestion.pending? + if @suggestion.approve!(current_user, notes: params[:admin_notes]) + record_activity(:approve_suggestion, recordable: @suggestion, metadata: { type: "PlanSuggestion" }) + redirect_to curator_admin_suggestions_path, + notice: "Prijedlog odobren." + else + redirect_to curator_admin_plan_suggestion_path(@suggestion), + alert: "Greška pri odobravanju prijedloga." + end + else + redirect_to curator_admin_suggestions_path, + alert: "Prijedlog je već pregledan." + end + rescue => e + redirect_to curator_admin_plan_suggestion_path(@suggestion), + alert: "Greška: #{e.message}" + end + + def reject + if @suggestion.pending? + if @suggestion.reject!(current_user, notes: params[:admin_notes]) + record_activity(:reject_suggestion, recordable: @suggestion, metadata: { type: "PlanSuggestion" }) + redirect_to curator_admin_suggestions_path, + notice: "Prijedlog odbijen." + else + redirect_to curator_admin_plan_suggestion_path(@suggestion), + alert: "Greška pri odbijanju prijedloga." + end + else + redirect_to curator_admin_suggestions_path, + alert: "Prijedlog je već pregledan." + end + end + + private + + def set_suggestion + @suggestion = PlanSuggestion.find(params[:id]) + end + end + end +end diff --git a/app/controllers/curator/admin/reviews_controller.rb b/app/controllers/curator/admin/reviews_controller.rb new file mode 100644 index 00000000..097bd40f --- /dev/null +++ b/app/controllers/curator/admin/reviews_controller.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Curator + module Admin + # Reviews moderation controller for admin users. + # Allows admins to approve or remove reviews based on flags and moderation status. + class ReviewsController < BaseController + before_action :set_review, only: [ :show, :approve, :remove ] + + def index + @reviews = Review.includes(:reviewable, :user, :review_flags).order(created_at: :desc) + @reviews = @reviews.where(moderation_status: params[:moderation_status]) if params[:moderation_status].present? + @reviews = @reviews.where(reviewable_type: params[:type]) if params[:type].present? + @reviews = @reviews.by_rating(params[:rating]) if params[:rating].present? + + if params[:search].present? + @reviews = @reviews.where("comment ILIKE ? OR author_name ILIKE ?", "%#{params[:search]}%", "%#{params[:search]}%") + end + + @reviews = @reviews.page(params[:page]).per(20) + + @stats = { + total: Review.count, + unreviewed: Review.unreviewed.count, + flagged: Review.flagged.count, + approved: Review.where(moderation_status: :approved).count, + removed: Review.removed.count + } + end + + def show + end + + def approve + if @review.update(moderation_status: :approved) + record_activity("approve_review", recordable: @review) + redirect_to curator_admin_reviews_path, notice: "Recenzija odobrena." + else + redirect_to curator_admin_review_path(@review), alert: "Greška pri odobravanju." + end + end + + def remove + if @review.update(moderation_status: :removed) + record_activity("remove_review", recordable: @review) + redirect_to curator_admin_reviews_path, notice: "Recenzija uklonjena." + else + redirect_to curator_admin_review_path(@review), alert: "Greška pri uklanjanju." + end + end + + private + + def set_review + @review = Review.find_by!(uuid: params[:id]) + end + end + end +end diff --git a/app/controllers/curator/admin/suggestions_controller.rb b/app/controllers/curator/admin/suggestions_controller.rb new file mode 100644 index 00000000..afc2b038 --- /dev/null +++ b/app/controllers/curator/admin/suggestions_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Curator + module Admin + # Unified suggestions inbox for admins. + # Shows all pending suggestions across all resource types in one view. + class SuggestionsController < BaseController + def index + @location_suggestions = LocationSuggestion.includes(:user, :location).pending_review.recent + @experience_suggestions = ExperienceSuggestion.includes(:user, :experience).pending_review.recent + @plan_suggestions = PlanSuggestion.includes(:user, :plan).pending_review.recent + + @stats = { + location_pending: LocationSuggestion.pending_review.count, + experience_pending: ExperienceSuggestion.pending_review.count, + plan_pending: PlanSuggestion.pending_review.count, + total_pending: LocationSuggestion.pending_review.count + ExperienceSuggestion.pending_review.count + PlanSuggestion.pending_review.count + } + end + end + end +end diff --git a/app/controllers/curator/base_controller.rb b/app/controllers/curator/base_controller.rb index fa2a5439..8e4ec1f7 100644 --- a/app/controllers/curator/base_controller.rb +++ b/app/controllers/curator/base_controller.rb @@ -38,6 +38,12 @@ def record_activity(action, recordable: nil, metadata: {}) current_user.check_spam_activity! end + # Admin direct CRUD: admin users can edit/delete directly without proposals + def admin_direct_crud? + current_user.admin? && Flipper.enabled?(:curator_edit_delete, current_user) + end + helper_method :admin_direct_crud? + # Find pending proposal for a resource (for display on show/edit pages) def pending_proposal_for(resource) return nil unless resource.present? diff --git a/app/controllers/curator/experience_suggestions_controller.rb b/app/controllers/curator/experience_suggestions_controller.rb new file mode 100644 index 00000000..a7fa1a8a --- /dev/null +++ b/app/controllers/curator/experience_suggestions_controller.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Curator + class ExperienceSuggestionsController < BaseController + before_action :set_experience + + def new + @suggestion = ExperienceSuggestion.find_or_create_pending!( + @experience, + user: current_user, + change_type: @experience ? :update_resource : :create_resource, + origin: :human + ) + redirect_to edit_curator_experience_experience_suggestion_path(@experience, @suggestion) + end + + def create + @suggestion = ExperienceSuggestion.find_or_create_pending!( + @experience, + user: current_user, + change_type: @experience ? :update_resource : :create_resource, + origin: :human + ) + + if @suggestion.user == current_user + # Original creator, update directly + if @suggestion.update(suggestion_params) + attach_cover_photo if params[:experience_suggestion]&.dig(:proposed_cover_photo).present? + record_activity("suggestion_created", recordable: @suggestion) + redirect_to curator_experience_path(@experience), notice: "Prijedlog uspješno kreiran." + else + render :edit, status: :unprocessable_entity + end + else + # Different curator, add contribution + contribution_params = suggestion_params.except(:contribution_notes).to_h.symbolize_keys + @suggestion.add_contribution( + user: current_user, + notes: params[:experience_suggestion][:contribution_notes], + **contribution_params + ) + record_activity("suggestion_contributed", recordable: @suggestion) + redirect_to curator_experience_path(@experience), notice: "Doprinos prijedlogu uspješno dodan." + end + end + + def edit + @suggestion = ExperienceSuggestion.find(params[:id]) + end + + def update + @suggestion = ExperienceSuggestion.find(params[:id]) + + if @suggestion.user == current_user + if @suggestion.update(suggestion_params) + attach_cover_photo if params[:experience_suggestion]&.dig(:proposed_cover_photo).present? + record_activity("suggestion_created", recordable: @suggestion) + redirect_to curator_experience_path(@experience), notice: "Prijedlog ažuriran.", status: :see_other + else + render :edit, status: :unprocessable_entity + end + else + contribution_params = suggestion_params.except(:contribution_notes).to_h.symbolize_keys + @suggestion.add_contribution( + user: current_user, + notes: params[:experience_suggestion][:contribution_notes], + **contribution_params + ) + record_activity("suggestion_contributed", recordable: @suggestion) + redirect_to curator_experience_path(@experience), notice: "Doprinos prijedlogu uspješno dodan.", status: :see_other + end + end + + private + + def set_experience + @experience = Experience.find_by_public_id!(params[:experience_id]) + end + + def suggestion_params + params.require(:experience_suggestion).permit( + :proposed_title, :proposed_description, :proposed_contact_name, + :proposed_contact_email, :proposed_contact_phone, + :proposed_contact_website, :contribution_notes, + proposed_seasons: [], proposed_location_uuids: [], + proposed_video_urls: [] + ) + end + + def attach_cover_photo + @suggestion.proposed_cover_photo.attach(params[:experience_suggestion][:proposed_cover_photo]) + end + end +end diff --git a/app/controllers/curator/experiences_controller.rb b/app/controllers/curator/experiences_controller.rb index ab65ddef..e0817f68 100644 --- a/app/controllers/curator/experiences_controller.rb +++ b/app/controllers/curator/experiences_controller.rb @@ -38,7 +38,78 @@ def new end def create - # Instead of creating directly, create a proposal for admin review + if admin_direct_crud? + create_directly + else + create_proposal + end + end + + def edit + @pending_proposal = pending_proposal_for(@experience) + end + + def update + if admin_direct_crud? + update_directly + else + update_proposal + end + end + + def destroy + if admin_direct_crud? + destroy_directly + else + destroy_proposal + end + end + + private + + # === Admin direct CRUD === + + def create_directly + @experience = Experience.new(experience_params) + + if @experience.save + update_experience_locations(@experience) + record_activity("resource_created", recordable: @experience, metadata: { type: "Experience", title: @experience.title }) + redirect_to curator_experience_path(@experience), notice: t("curator.experiences.created", default: "Iskustvo kreirano."), status: :see_other + else + flash.now[:alert] = @experience.errors.full_messages.join(", ") + render :new, status: :unprocessable_entity + end + end + + def update_directly + if @experience.update(experience_params) + update_experience_locations(@experience) + record_activity("resource_updated", recordable: @experience, metadata: { type: "Experience", title: @experience.title }) + redirect_to curator_experience_path(@experience), notice: t("curator.experiences.updated", default: "Iskustvo ažurirano."), status: :see_other + else + flash.now[:alert] = @experience.errors.full_messages.join(", ") + render :edit, status: :unprocessable_entity + end + end + + def destroy_directly + title = @experience.title + @experience.destroy! + record_activity("resource_deleted", recordable: nil, metadata: { type: "Experience", title: title }) + redirect_to curator_experiences_path, notice: t("curator.experiences.deleted", default: "Iskustvo obrisano."), status: :see_other + end + + def update_experience_locations(experience) + if params[:experience][:location_uuids].present? + uuids = params[:experience][:location_uuids].reject(&:blank?) + experience.location_uuids = uuids + end + end + + # === Curator proposal workflow === + + def create_proposal proposal = current_user.content_changes.build( change_type: :create_content, changeable_class: "Experience", @@ -55,12 +126,7 @@ def create end end - def edit - @pending_proposal = pending_proposal_for(@experience) - end - - def update - # Use find_or_create to ensure only one pending proposal per resource + def update_proposal proposal = ContentChange.find_or_create_for_update( changeable: @experience, user: current_user, @@ -78,8 +144,7 @@ def update end end - def destroy - # Use find_or_create to ensure only one pending proposal per resource + def destroy_proposal proposal = ContentChange.find_or_create_for_delete( changeable: @experience, user: current_user, @@ -94,8 +159,6 @@ def destroy end end - private - def set_experience @experience = Experience.find_by_public_id!(params[:id]) end diff --git a/app/controllers/curator/location_suggestions_controller.rb b/app/controllers/curator/location_suggestions_controller.rb new file mode 100644 index 00000000..faeae720 --- /dev/null +++ b/app/controllers/curator/location_suggestions_controller.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Curator + class LocationSuggestionsController < BaseController + before_action :set_location + + def new + @suggestion = LocationSuggestion.find_or_create_pending!( + @location, + user: current_user, + change_type: @location ? :update_resource : :create_resource, + origin: :human + ) + redirect_to edit_curator_location_location_suggestion_path(@location, @suggestion) + end + + def create + @suggestion = LocationSuggestion.find_or_create_pending!( + @location, + user: current_user, + change_type: @location ? :update_resource : :create_resource, + origin: :human + ) + + if @suggestion.user == current_user + # Original creator, update directly + if @suggestion.update(suggestion_params) + attach_photos if params[:location_suggestion]&.dig(:proposed_photos).present? + record_activity("suggestion_created", recordable: @suggestion) + redirect_to curator_location_path(@location), notice: "Prijedlog uspješno kreiran." + else + render :edit, status: :unprocessable_entity + end + else + # Different curator, add contribution + contribution_params = suggestion_params.except(:contribution_notes).to_h.symbolize_keys + @suggestion.add_contribution( + user: current_user, + notes: params[:location_suggestion][:contribution_notes], + **contribution_params + ) + record_activity("suggestion_contributed", recordable: @suggestion) + redirect_to curator_location_path(@location), notice: "Doprinos prijedlogu uspješno dodan." + end + end + + def edit + @suggestion = LocationSuggestion.find(params[:id]) + end + + def update + @suggestion = LocationSuggestion.find(params[:id]) + + if @suggestion.user == current_user + if @suggestion.update(suggestion_params) + attach_photos if params[:location_suggestion]&.dig(:proposed_photos).present? + record_activity("suggestion_created", recordable: @suggestion) + redirect_to curator_location_path(@location), notice: "Prijedlog ažuriran.", status: :see_other + else + render :edit, status: :unprocessable_entity + end + else + contribution_params = suggestion_params.except(:contribution_notes).to_h.symbolize_keys + @suggestion.add_contribution( + user: current_user, + notes: params[:location_suggestion][:contribution_notes], + **contribution_params + ) + record_activity("suggestion_contributed", recordable: @suggestion) + redirect_to curator_location_path(@location), notice: "Doprinos prijedlogu uspješno dodan.", status: :see_other + end + end + + private + + def set_location + @location = Location.find_by_public_id!(params[:location_id]) + end + + def suggestion_params + params.require(:location_suggestion).permit( + :proposed_name, :proposed_city, :proposed_description, + :proposed_historical_context, :proposed_lat, :proposed_lng, + :proposed_budget, :proposed_phone, :proposed_email, + :proposed_website, :contribution_notes, + proposed_tags: [], proposed_social_links: {}, + proposed_experience_type_ids: [] + ) + end + + def attach_photos + @suggestion.proposed_photos.attach(params[:location_suggestion][:proposed_photos]) + end + end +end diff --git a/app/controllers/curator/locations_controller.rb b/app/controllers/curator/locations_controller.rb index f70911c9..f0a9ddab 100644 --- a/app/controllers/curator/locations_controller.rb +++ b/app/controllers/curator/locations_controller.rb @@ -2,7 +2,7 @@ module Curator class LocationsController < BaseController - before_action :set_location, only: [ :show, :edit, :update, :destroy ] + before_action :set_location, only: [ :show, :edit, :update, :destroy, :generate_audio_tour ] before_action :load_form_options, only: [ :new, :create, :edit, :update ] def index @@ -65,7 +65,107 @@ def new end def create - # Instead of creating directly, create a proposal for admin review + if admin_direct_crud? + create_directly + else + create_proposal + end + end + + def edit + @pending_proposal = pending_proposal_for(@location) + end + + def update + if admin_direct_crud? + update_directly + else + update_proposal + end + end + + def destroy + if admin_direct_crud? + destroy_directly + else + destroy_proposal + end + end + + def generate_audio_tour + unless current_user.admin? + redirect_to curator_location_path(@location), alert: "Samo admin može generisati audio ture." + return + end + + locale = params[:locale] || "bs" + + # Check for recent generation request to prevent duplicate jobs + recent_request = CuratorActivity + .where(action: "audio_tour_generation_requested") + .where(recordable: @location) + .where("created_at > ?", 10.minutes.ago) + .exists? + + if recent_request + redirect_to curator_location_path(@location), alert: "Generisanje je već pokrenuto. Sačekajte da se završi." + return + end + + AudioTourGenerateJob.perform_later( + location_id: @location.id, + locale: locale, + requested_by_id: current_user.id + ) + + record_activity("audio_tour_generation_requested", + recordable: @location, + metadata: { locale: locale } + ) + + redirect_to curator_location_path(@location), + notice: "Audio tura za #{locale.upcase} se generise u pozadini." + end + + private + + # === Admin direct CRUD === + + def create_directly + result = LocationCreator.new(location_params.to_h).call + + if result.success? + record_activity("resource_created", recordable: result.location, metadata: { type: "Location", name: result.location.name }) + redirect_to curator_location_path(result.location), notice: t("curator.locations.created", default: "Lokacija kreirana."), status: :see_other + else + @location = Location.new(location_params) + flash.now[:alert] = result.errors.join(", ") + render :new, status: :unprocessable_entity + end + end + + def update_directly + result = LocationUpdater.new(@location, location_params.to_h).call + + if result.success? + record_activity("resource_updated", recordable: @location, metadata: { type: "Location", name: @location.name }) + redirect_to curator_location_path(@location), notice: t("curator.locations.updated", default: "Lokacija ažurirana."), status: :see_other + else + flash.now[:alert] = result.errors.join(", ") + render :edit, status: :unprocessable_entity + end + end + + def destroy_directly + name = @location.name + @location.destroy! + record_activity("resource_deleted", recordable: nil, metadata: { type: "Location", name: name }) + redirect_to curator_locations_path, notice: t("curator.locations.deleted", default: "Lokacija obrisana."), status: :see_other + end + + # === Curator proposal workflow === + + def create_proposal proposal = current_user.content_changes.build( change_type: :create_content, changeable_class: "Location", @@ -82,17 +182,7 @@ def create end end - def edit - @pending_proposal = pending_proposal_for(@location) - end - - def update - Rails.logger.info "[Curator::Locations] Update called for location #{@location.id} by user #{current_user.id}" - Rails.logger.info "[Curator::Locations] Params: #{params.inspect}" - Rails.logger.info "[Curator::Locations] Proposal data: #{proposal_data_from_params.inspect}" - - # Use find_or_create to ensure only one pending proposal per resource - # This allows multiple curators to contribute to the same proposal + def update_proposal proposal = ContentChange.find_or_create_for_update( changeable: @location, user: current_user, @@ -100,8 +190,6 @@ def update proposed_data: proposal_data_from_params ) - Rails.logger.info "[Curator::Locations] Proposal created: persisted=#{proposal.persisted?}, errors=#{proposal.errors.full_messages}" - if proposal.persisted? action = proposal.contributions.exists?(user: current_user) ? "proposal_contributed" : "proposal_updated" record_activity(action, recordable: @location, metadata: { type: "Location", name: @location.name }) @@ -116,8 +204,7 @@ def update render :edit, status: :unprocessable_entity end - def destroy - # Use find_or_create to ensure only one pending proposal per resource + def destroy_proposal proposal = ContentChange.find_or_create_for_delete( changeable: @location, user: current_user, @@ -132,8 +219,6 @@ def destroy end end - private - def set_location @location = Location.find_by_public_id!(params[:id]) end diff --git a/app/controllers/curator/plan_suggestions_controller.rb b/app/controllers/curator/plan_suggestions_controller.rb new file mode 100644 index 00000000..93e3bd1e --- /dev/null +++ b/app/controllers/curator/plan_suggestions_controller.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Curator + class PlanSuggestionsController < BaseController + before_action :set_plan + + def new + @suggestion = PlanSuggestion.find_or_create_pending!( + @plan, + user: current_user, + change_type: @plan ? :update_resource : :create_resource, + origin: :human + ) + redirect_to edit_curator_plan_plan_suggestion_path(@plan, @suggestion) + end + + def create + @suggestion = PlanSuggestion.find_or_create_pending!( + @plan, + user: current_user, + change_type: @plan ? :update_resource : :create_resource, + origin: :human + ) + + if @suggestion.user == current_user + # Original creator, update directly + if @suggestion.update(suggestion_params) + attach_cover_photo if params[:plan_suggestion]&.dig(:proposed_cover_photo).present? + record_activity("suggestion_created", recordable: @suggestion) + redirect_to curator_plan_path(@plan), notice: "Prijedlog uspješno kreiran." + else + render :edit, status: :unprocessable_entity + end + else + # Different curator, add contribution + contribution_params = suggestion_params.except(:contribution_notes).to_h.symbolize_keys + @suggestion.add_contribution( + user: current_user, + notes: params[:plan_suggestion][:contribution_notes], + **contribution_params + ) + record_activity("suggestion_contributed", recordable: @suggestion) + redirect_to curator_plan_path(@plan), notice: "Doprinos prijedlogu uspješno dodan." + end + end + + def edit + @suggestion = PlanSuggestion.find(params[:id]) + end + + def update + @suggestion = PlanSuggestion.find(params[:id]) + + if @suggestion.user == current_user + if @suggestion.update(suggestion_params) + attach_cover_photo if params[:plan_suggestion]&.dig(:proposed_cover_photo).present? + record_activity("suggestion_created", recordable: @suggestion) + redirect_to curator_plan_path(@plan), notice: "Prijedlog ažuriran.", status: :see_other + else + render :edit, status: :unprocessable_entity + end + else + contribution_params = suggestion_params.except(:contribution_notes).to_h.symbolize_keys + @suggestion.add_contribution( + user: current_user, + notes: params[:plan_suggestion][:contribution_notes], + **contribution_params + ) + record_activity("suggestion_contributed", recordable: @suggestion) + redirect_to curator_plan_path(@plan), notice: "Doprinos prijedlogu uspješno dodan.", status: :see_other + end + end + + private + + def set_plan + @plan = Plan.find_by_public_id!(params[:plan_id]) + end + + def suggestion_params + params.require(:plan_suggestion).permit( + :proposed_title, :proposed_notes, :proposed_city_name, + :proposed_visibility, :contribution_notes, + proposed_experience_days: {}, proposed_preferences: {} + ) + end + + def attach_cover_photo + @suggestion.proposed_cover_photo.attach(params[:plan_suggestion][:proposed_cover_photo]) + end + end +end diff --git a/app/controllers/curator/plans_controller.rb b/app/controllers/curator/plans_controller.rb index 19ff2215..e697e0d0 100644 --- a/app/controllers/curator/plans_controller.rb +++ b/app/controllers/curator/plans_controller.rb @@ -46,7 +46,82 @@ def new end def create - # Instead of creating directly, create a proposal for admin review + if admin_direct_crud? + create_directly + else + create_proposal + end + end + + def edit + @pending_proposal = pending_proposal_for(@plan) + end + + def update + if admin_direct_crud? + update_directly + else + update_proposal + end + end + + def destroy + if admin_direct_crud? + destroy_directly + else + destroy_proposal + end + end + + private + + # === Admin direct CRUD === + + def create_directly + @plan = Plan.new(plan_params) + @plan.preferences = build_preferences_from_params + @plan.user = current_user + + if @plan.save + update_plan_experiences(@plan) + record_activity("resource_created", recordable: @plan, metadata: { type: "Plan", title: @plan.title }) + redirect_to curator_plan_path(@plan), notice: t("curator.plans.created", default: "Plan kreiran."), status: :see_other + else + flash.now[:alert] = @plan.errors.full_messages.join(", ") + render :new, status: :unprocessable_entity + end + end + + def update_directly + @plan.assign_attributes(plan_params) + @plan.preferences = build_preferences_from_params + + if @plan.save + update_plan_experiences(@plan) + record_activity("resource_updated", recordable: @plan, metadata: { type: "Plan", title: @plan.title }) + redirect_to curator_plan_path(@plan), notice: t("curator.plans.updated", default: "Plan ažuriran."), status: :see_other + else + flash.now[:alert] = @plan.errors.full_messages.join(", ") + render :edit, status: :unprocessable_entity + end + end + + def destroy_directly + title = @plan.title + @plan.destroy! + record_activity("resource_deleted", recordable: nil, metadata: { type: "Plan", title: title }) + redirect_to curator_plans_path, notice: t("curator.plans.deleted", default: "Plan obrisan."), status: :see_other + end + + def update_plan_experiences(plan) + if params[:plan][:experience_days].present? + plan.experience_days = params[:plan][:experience_days].to_unsafe_h + end + end + + # === Curator proposal workflow === + + def create_proposal proposal = current_user.content_changes.build( change_type: :create_content, changeable_class: "Plan", @@ -63,12 +138,7 @@ def create end end - def edit - @pending_proposal = pending_proposal_for(@plan) - end - - def update - # Use find_or_create to ensure only one pending proposal per resource + def update_proposal proposal = ContentChange.find_or_create_for_update( changeable: @plan, user: current_user, @@ -86,8 +156,7 @@ def update end end - def destroy - # Use find_or_create to ensure only one pending proposal per resource + def destroy_proposal proposal = ContentChange.find_or_create_for_delete( changeable: @plan, user: current_user, @@ -102,8 +171,6 @@ def destroy end end - private - def set_plan @plan = Plan.find_by_public_id!(params[:id]) end diff --git a/app/controllers/curator/reviews_controller.rb b/app/controllers/curator/reviews_controller.rb index 0e9c1332..1c630a73 100644 --- a/app/controllers/curator/reviews_controller.rb +++ b/app/controllers/curator/reviews_controller.rb @@ -1,12 +1,13 @@ module Curator class ReviewsController < BaseController - before_action :set_review, only: [ :show, :destroy ] + before_action :set_review, only: [ :show, :destroy, :flag ] rescue_from ActiveRecord::RecordNotFound, with: :review_not_found def index @reviews = Review.includes(:reviewable, :user).order(created_at: :desc) @reviews = @reviews.by_rating(params[:rating]) if params[:rating].present? @reviews = @reviews.where(reviewable_type: params[:type]) if params[:type].present? + @reviews = @reviews.where(moderation_status: params[:moderation_status]) if params[:moderation_status].present? if params[:search].present? @reviews = @reviews.where("comment ILIKE ? OR author_name ILIKE ?", "%#{params[:search]}%", "%#{params[:search]}%") @@ -47,10 +48,30 @@ def destroy end end + def flag + if @review.flagged_by?(current_user) + redirect_to curator_reviews_path, alert: "Već ste prijavili ovu recenziju." + return + end + + flag = @review.review_flags.build( + user: current_user, + reason: params[:reason], + notes: params[:notes] + ) + + if flag.save + record_activity("review_flagged", recordable: @review) + redirect_to curator_reviews_path, notice: "Recenzija prijavljena." + else + redirect_to curator_review_path(@review), alert: flag.errors.full_messages.join(", ") + end + end + private def set_review - @review = Review.find(params[:id]) + @review = Review.find_by!(uuid: params[:id]) end def review_not_found diff --git a/app/jobs/audio_tour_generate_job.rb b/app/jobs/audio_tour_generate_job.rb new file mode 100644 index 00000000..9ea7deda --- /dev/null +++ b/app/jobs/audio_tour_generate_job.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class AudioTourGenerateJob < ApplicationJob + queue_as :default + + def perform(location_id:, locale:, requested_by_id:) + location = Location.find(location_id) + user = User.find(requested_by_id) + + generator = Ai::AudioTourGenerator.new(location) + result = generator.generate(locale: locale, force: false) + + CuratorActivity.record( + user: user, + action: "audio_tour_generated", + recordable: location, + metadata: { + locale: locale, + status: result[:status].to_s, + duration: result.dig(:audio_info, :duration) + } + ) + rescue => e + CuratorActivity.record( + user: User.find_by(id: requested_by_id), + action: "audio_tour_generation_failed", + recordable: Location.find_by(id: location_id), + metadata: { + locale: locale, + error: e.message + } + ) + raise # Re-raise for Solid Queue retry + end +end diff --git a/app/models/concerns/suggestable.rb b/app/models/concerns/suggestable.rb new file mode 100644 index 00000000..082ef21d --- /dev/null +++ b/app/models/concerns/suggestable.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +# Suggestable concern for suggestion models (LocationSuggestion, ExperienceSuggestion, etc.) +# +# Provides common functionality for content suggestions: +# - Status tracking (pending/approved/rejected) +# - Change types (create/update/delete) +# - Origin tracking (human vs AI-generated) +# - Approve/reject workflows +# - Multi-contributor support +# +# Usage: +# class LocationSuggestion < ApplicationRecord +# include Suggestable +# +# belongs_to :location, optional: true +# has_many :contributions, class_name: "LocationSuggestionContribution" +# +# def apply_changes! +# # Implement resource-specific logic +# end +# end +# +module Suggestable + extend ActiveSupport::Concern + + included do + belongs_to :user + belongs_to :reviewed_by, class_name: "User", optional: true + + enum :status, { pending: 0, approved: 1, rejected: 2 } + enum :change_type, { create_resource: 0, update_resource: 1, delete_resource: 2 } + enum :origin, { human: 0, ai_generated: 1 }, prefix: :origin + + validates :user, presence: true + + scope :pending_review, -> { where(status: :pending) } + scope :recent, -> { order(created_at: :desc) } + scope :human_suggestions, -> { where(origin: :human) } + scope :ai_suggestions, -> { where(origin: :ai_generated) } + end + + # Approve the suggestion and apply changes to the resource + # @param admin [User] The admin approving the suggestion + # @param notes [String, nil] Optional admin notes + def approve!(admin, notes: nil) + transaction do + apply_changes! + update!( + status: :approved, + reviewed_by: admin, + reviewed_at: Time.current, + admin_notes: notes + ) + end + end + + # Reject the suggestion without applying changes + # @param admin [User] The admin rejecting the suggestion + # @param notes [String, nil] Optional admin notes explaining rejection + def reject!(admin, notes: nil) + update!( + status: :rejected, + reviewed_by: admin, + reviewed_at: Time.current, + admin_notes: notes + ) + end + + # Add contribution from another curator to this suggestion + # Updates the suggestion fields with non-nil values and creates a contribution record + # @param user [User] The user contributing + # @param notes [String, nil] Optional notes about the contribution + # @param proposed_fields [Hash] The fields being proposed (non-nil values only) + def add_contribution(user:, notes: nil, **proposed_fields) + non_nil_fields = proposed_fields.compact + + transaction do + # Save contribution for audit trail + contribution = contributions.create!( + user: user, + notes: notes, + **non_nil_fields + ) + + # Update suggestion with new values + non_nil_fields.each do |field, value| + self[field] = value if respond_to?("#{field}=") + end + save! + end + end + + # Apply the proposed changes to the resource + # Must be implemented by including model + def apply_changes! + raise NotImplementedError, "#{self.class.name} must implement #apply_changes!" + end + + # Get all proposed changes (non-nil proposed_* fields) + # @return [Hash] Hash of proposed field names and values + def proposed_changes + attributes.select { |k, v| k.start_with?("proposed_") && v.present? } + end + + module ClassMethods + # Find existing pending suggestion or create new one for this resource + # @param resource [ActiveRecord::Base, nil] The resource being suggested (nil for create_resource) + # @param user [User] The user creating the suggestion + # @param attrs [Hash] Additional attributes for the suggestion + # @return [Suggestable] The found or created suggestion + def find_or_create_pending!(resource, user:, **attrs) + resource_column = resource_association_column + existing = pending.find_by(resource_column => resource&.id) + + if existing + existing + else + create!( + resource_column => resource&.id, + user: user, + status: :pending, + **attrs + ) + end + end + + # Returns the resource association column name (e.g., :location_id) + # Must be implemented by including model + def resource_association_column + raise NotImplementedError, "#{name} must implement .resource_association_column" + end + end +end diff --git a/app/models/curator_activity.rb b/app/models/curator_activity.rb index 49d75fd0..bc1c391a 100644 --- a/app/models/curator_activity.rb +++ b/app/models/curator_activity.rb @@ -13,17 +13,30 @@ class CuratorActivity < ApplicationRecord proposal_contributed proposal_deleted review_added + review_flagged photo_suggested resource_viewed + resource_created + resource_updated + resource_deleted login approve_photo_suggestion reject_photo_suggestion + suggestion_created + suggestion_contributed + approve_suggestion + reject_suggestion + approve_review + remove_review update_user unblock_user approve_curator_application reject_curator_application approve_content_change reject_content_change + audio_tour_generation_requested + audio_tour_generated + audio_tour_generation_failed ].freeze validates :action, presence: true, inclusion: { in: ACTIONS } @@ -67,14 +80,52 @@ def description "Submitted deletion request" when "review_added" "Added review to a proposal" + when "review_flagged" + "Flagged a review" when "photo_suggested" target = recordable_description "Suggested photo for #{target}" + when "suggestion_created" + target = recordable_description + "Created suggestion for #{target}" + when "suggestion_contributed" + target = recordable_description + "Contributed to suggestion for #{target}" + when "approve_suggestion" + type = metadata["type"]&.gsub("Suggestion", "") || "Resource" + "Approved #{type}Suggestion" + when "reject_suggestion" + type = metadata["type"]&.gsub("Suggestion", "") || "Resource" + "Rejected #{type}Suggestion" + when "approve_review" + "Approved a review" + when "remove_review" + "Removed a review" when "resource_viewed" target = recordable_description "Viewed #{target}" + when "resource_created" + type = metadata["type"] || "Resource" + name = metadata["name"] || metadata["title"] + "Created #{type}: #{name}" + when "resource_updated" + target = recordable_description + "Updated #{target}" + when "resource_deleted" + type = metadata["type"] || "Resource" + name = metadata["name"] || metadata["title"] + "Deleted #{type}: #{name}" when "login" "Logged in" + when "audio_tour_generation_requested" + target = recordable_description + "Requested audio tour generation for #{target}" + when "audio_tour_generated" + target = recordable_description + "Generated audio tour for #{target}" + when "audio_tour_generation_failed" + target = recordable_description + "Audio tour generation failed for #{target}" else action.humanize end @@ -87,16 +138,40 @@ def icon_class "text-blue-500" when "proposal_updated", "proposal_contributed" "text-amber-500" - when "proposal_deleted" + when "proposal_deleted", "resource_deleted" "text-red-500" + when "resource_created" + "text-emerald-600" + when "resource_updated" + "text-sky-500" when "review_added" "text-purple-500" + when "review_flagged" + "text-orange-500" when "photo_suggested" "text-green-500" + when "suggestion_created" + "text-blue-500" + when "suggestion_contributed" + "text-amber-500" + when "approve_suggestion" + "text-green-600" + when "reject_suggestion" + "text-red-600" + when "approve_review" + "text-green-600" + when "remove_review" + "text-red-600" when "resource_viewed" "text-gray-400" when "login" "text-emerald-500" + when "audio_tour_generation_requested" + "text-blue-500" + when "audio_tour_generated" + "text-green-600" + when "audio_tour_generation_failed" + "text-red-600" else "text-gray-500" end @@ -121,6 +196,12 @@ def recordable_description recordable.description when PhotoSuggestion "Photo for #{recordable.location&.name}" + when LocationSuggestion + "Location suggestion for #{recordable.location&.name || 'new location'}" + when ExperienceSuggestion + "Experience suggestion for #{recordable.experience&.title || 'new experience'}" + when PlanSuggestion + "Plan suggestion for #{recordable.plan&.title || 'new plan'}" else recordable.class.name end diff --git a/app/models/experience_suggestion.rb b/app/models/experience_suggestion.rb new file mode 100644 index 00000000..fa894787 --- /dev/null +++ b/app/models/experience_suggestion.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +# ExperienceSuggestion - Per-resource suggestion model for experiences +# +# Replaces ContentChange for experience-specific suggestions with typed columns +# and Active Storage support. Supports multi-curator contributions through +# ExperienceSuggestionContribution model. +# +# One pending suggestion per experience enforced by unique constraint. +# Multiple curators can contribute to the same suggestion. +# +# Usage: +# # Create or find pending suggestion +# suggestion = ExperienceSuggestion.find_or_create_pending!( +# experience, +# user: current_user, +# origin: :human, +# change_type: :update_resource +# ) +# +# # Update with proposed changes +# suggestion.update!( +# proposed_title: "Rafting na Uni", +# proposed_description: "...", +# proposed_estimated_duration: 180, +# proposed_location_uuids: ["uuid1", "uuid2"] +# ) +# +# # Add cover photo +# suggestion.proposed_cover_photo.attach(params[:cover_photo]) +# +# # Approve (applies changes to experience) +# suggestion.approve!(admin_user) +# +class ExperienceSuggestion < ApplicationRecord + include Suggestable + + belongs_to :experience, optional: true # nil for create_resource + has_many :contributions, class_name: "ExperienceSuggestionContribution", dependent: :destroy + has_one_attached :proposed_cover_photo + + validate :acceptable_cover_photo + + # Returns the resource association column name for Suggestable concern + def self.resource_association_column + :experience_id + end + + # Apply proposed changes to the experience + # Creates new experience for create_resource, updates for update_resource, deletes for delete_resource + def apply_changes! + case change_type + when "create_resource" + attrs = proposed_attributes + new_experience = Experience.create!(attrs) + update_experience_locations!(new_experience) if proposed_location_uuids.present? + attach_cover_photo!(new_experience) if proposed_cover_photo.attached? + update!(experience: new_experience) + when "update_resource" + attrs = proposed_attributes + experience.update!(attrs) + update_experience_locations!(experience) if proposed_location_uuids.present? + attach_cover_photo!(experience) if proposed_cover_photo.attached? + when "delete_resource" + experience.destroy! + end + end + + private + + # Build hash of proposed attributes from typed columns + def proposed_attributes + attrs = {} + attrs[:title] = proposed_title if proposed_title.present? + attrs[:description] = proposed_description if proposed_description.present? + attrs[:experience_category_id] = proposed_experience_category_id if proposed_experience_category_id.present? + attrs[:estimated_duration] = proposed_estimated_duration if proposed_estimated_duration.present? + attrs[:contact_name] = proposed_contact_name if proposed_contact_name.present? + attrs[:contact_email] = proposed_contact_email if proposed_contact_email.present? + attrs[:contact_phone] = proposed_contact_phone if proposed_contact_phone.present? + attrs[:contact_website] = proposed_contact_website if proposed_contact_website.present? + attrs[:seasons] = proposed_seasons if proposed_seasons.present? + attrs + end + + # Update experience locations from proposed UUIDs + def update_experience_locations!(exp) + exp.location_uuids = proposed_location_uuids + end + + # Attach proposed cover photo to the experience + def attach_cover_photo!(exp) + exp.cover_photo.attach(proposed_cover_photo.blob) + end + + # Validate cover photo attachment + def acceptable_cover_photo + return unless proposed_cover_photo.attached? + + photo = proposed_cover_photo + errors.add(:proposed_cover_photo, "max 10MB") if photo.blob.byte_size > 10.megabytes + unless %w[image/jpeg image/png image/gif image/webp].include?(photo.blob.content_type) + errors.add(:proposed_cover_photo, "must be JPEG, PNG, GIF, or WebP") + end + end +end diff --git a/app/models/experience_suggestion_contribution.rb b/app/models/experience_suggestion_contribution.rb new file mode 100644 index 00000000..208fa761 --- /dev/null +++ b/app/models/experience_suggestion_contribution.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# ExperienceSuggestionContribution - Multi-curator contribution tracking +# +# When multiple curators work on the same experience suggestion, +# each contribution is tracked separately for audit trail. +# +# Has same typed columns as ExperienceSuggestion - only non-nil fields +# represent what this specific curator changed. +# +# Usage: +# suggestion.add_contribution( +# user: curator_b, +# notes: "Added better description", +# proposed_description: "New description...", +# proposed_title: "Updated title" +# ) +# +class ExperienceSuggestionContribution < ApplicationRecord + belongs_to :experience_suggestion + belongs_to :user + + validates :user_id, uniqueness: { + scope: :experience_suggestion_id, + message: "already contributed to this suggestion" + } +end diff --git a/app/models/location_suggestion.rb b/app/models/location_suggestion.rb new file mode 100644 index 00000000..bd813171 --- /dev/null +++ b/app/models/location_suggestion.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +# LocationSuggestion - Per-resource suggestion model for locations +# +# Replaces ContentChange for location-specific suggestions with typed columns +# and Active Storage support. Supports multi-curator contributions through +# LocationSuggestionContribution model. +# +# One pending suggestion per location enforced by unique constraint. +# Multiple curators can contribute to the same suggestion. +# +# Usage: +# # Create or find pending suggestion +# suggestion = LocationSuggestion.find_or_create_pending!( +# location, +# user: current_user, +# origin: :human, +# change_type: :update_resource +# ) +# +# # Update with proposed changes +# suggestion.update!( +# proposed_name: "Stari Most", +# proposed_city: "Mostar", +# proposed_description: "...", +# proposed_lat: 43.337, +# proposed_lng: 17.815 +# ) +# +# # Add photos +# suggestion.proposed_photos.attach(params[:photos]) +# +# # Approve (applies changes to location) +# suggestion.approve!(admin_user) +# +class LocationSuggestion < ApplicationRecord + include Suggestable + + belongs_to :location, optional: true # nil for create_resource + has_many :contributions, class_name: "LocationSuggestionContribution", dependent: :destroy + has_many_attached :proposed_photos + + validate :acceptable_photos + + # Returns the resource association column name for Suggestable concern + def self.resource_association_column + :location_id + end + + # Apply proposed changes to the location + # Creates new location for create_resource, updates for update_resource, deletes for delete_resource + def apply_changes! + case change_type + when "create_resource" + attrs = proposed_attributes + result = LocationCreator.new(attrs).call + raise ActiveRecord::RecordInvalid.new(result.location) unless result.success? + update!(location: result.location) + when "update_resource" + attrs = proposed_attributes + result = LocationUpdater.new(location, attrs).call + raise ActiveRecord::Rollback unless result.success? + # Attach photos if any + attach_proposed_photos_to_location! if proposed_photos.attached? + when "delete_resource" + location.destroy! + end + end + + private + + # Build hash of proposed attributes from typed columns + def proposed_attributes + attrs = {} + attrs[:name] = proposed_name if proposed_name.present? + attrs[:city] = proposed_city if proposed_city.present? + attrs[:description] = proposed_description if proposed_description.present? + attrs[:historical_context] = proposed_historical_context if proposed_historical_context.present? + attrs[:lat] = proposed_lat if proposed_lat.present? + attrs[:lng] = proposed_lng if proposed_lng.present? + attrs[:budget] = proposed_budget if proposed_budget.present? + attrs[:phone] = proposed_phone if proposed_phone.present? + attrs[:email] = proposed_email if proposed_email.present? + attrs[:website] = proposed_website if proposed_website.present? + attrs[:video_url] = proposed_video_urls&.first if proposed_video_urls.present? # backwards compat + attrs[:tags] = proposed_tags if proposed_tags.present? + attrs[:social_links] = proposed_social_links if proposed_social_links.present? + attrs[:suitable_experiences] = proposed_experience_type_ids if proposed_experience_type_ids.present? + attrs + end + + # Attach proposed photos to the location + def attach_proposed_photos_to_location! + proposed_photos.each do |photo| + location.photos.attach(photo.blob) + end + end + + # Validate photo attachments + def acceptable_photos + return unless proposed_photos.attached? + + proposed_photos.each do |photo| + errors.add(:proposed_photos, "max 10MB per photo") if photo.blob.byte_size > 10.megabytes + unless %w[image/jpeg image/png image/gif image/webp].include?(photo.blob.content_type) + errors.add(:proposed_photos, "must be JPEG, PNG, GIF, or WebP") + end + end + errors.add(:proposed_photos, "max 10 photos") if proposed_photos.size > 10 + end +end diff --git a/app/models/location_suggestion_contribution.rb b/app/models/location_suggestion_contribution.rb new file mode 100644 index 00000000..8b40c3c6 --- /dev/null +++ b/app/models/location_suggestion_contribution.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# LocationSuggestionContribution - Multi-curator contribution tracking +# +# When multiple curators work on the same location suggestion, +# each contribution is tracked separately for audit trail. +# +# Has same typed columns as LocationSuggestion - only non-nil fields +# represent what this specific curator changed. +# +# Usage: +# suggestion.add_contribution( +# user: curator_b, +# notes: "Added better description", +# proposed_description: "New description...", +# proposed_city: "Mostar" +# ) +# +class LocationSuggestionContribution < ApplicationRecord + belongs_to :location_suggestion + belongs_to :user + + validates :user_id, uniqueness: { + scope: :location_suggestion_id, + message: "already contributed to this suggestion" + } +end diff --git a/app/models/plan_suggestion.rb b/app/models/plan_suggestion.rb new file mode 100644 index 00000000..5dd1a12e --- /dev/null +++ b/app/models/plan_suggestion.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +# PlanSuggestion - Per-resource suggestion model for plans +# +# Replaces ContentChange for plan-specific suggestions with typed columns +# and Active Storage support. Supports multi-curator contributions through +# PlanSuggestionContribution model. +# +# One pending suggestion per plan enforced by unique constraint. +# Multiple curators can contribute to the same suggestion. +# +# Usage: +# # Create or find pending suggestion +# suggestion = PlanSuggestion.find_or_create_pending!( +# plan, +# user: current_user, +# origin: :human, +# change_type: :update_resource +# ) +# +# # Update with proposed changes +# suggestion.update!( +# proposed_title: "3 Days in Sarajevo", +# proposed_city_name: "Sarajevo", +# proposed_experience_days: { "1" => ["uuid1", "uuid2"], "2" => ["uuid3"] } +# ) +# +# # Add cover photo +# suggestion.proposed_cover_photo.attach(params[:cover_photo]) +# +# # Approve (applies changes to plan) +# suggestion.approve!(admin_user) +# +class PlanSuggestion < ApplicationRecord + include Suggestable + + belongs_to :plan, optional: true # nil for create_resource + has_many :contributions, class_name: "PlanSuggestionContribution", dependent: :destroy + has_one_attached :proposed_cover_photo + + # Returns the resource association column name for Suggestable concern + def self.resource_association_column + :plan_id + end + + # Apply proposed changes to the plan + # Creates new plan for create_resource, updates for update_resource, deletes for delete_resource + def apply_changes! + case change_type + when "create_resource" + attrs = proposed_attributes + new_plan = Plan.create!(attrs) + update_plan_experiences!(new_plan) if proposed_experience_days.present? + attach_cover_photo!(new_plan) if proposed_cover_photo.attached? + update!(plan: new_plan) + when "update_resource" + attrs = proposed_attributes + self.plan.update!(attrs) + update_plan_experiences!(self.plan) if proposed_experience_days.present? + attach_cover_photo!(self.plan) if proposed_cover_photo.attached? + when "delete_resource" + self.plan.destroy! + end + end + + private + + # Build hash of proposed attributes from typed columns + def proposed_attributes + attrs = {} + attrs[:title] = proposed_title if proposed_title.present? + attrs[:notes] = proposed_notes if proposed_notes.present? + attrs[:city_name] = proposed_city_name if proposed_city_name.present? + attrs[:start_date] = proposed_start_date if proposed_start_date.present? + attrs[:end_date] = proposed_end_date if proposed_end_date.present? + attrs[:visibility] = proposed_visibility if proposed_visibility.present? + attrs[:preferences] = proposed_preferences if proposed_preferences.present? + attrs + end + + # Update plan experiences using the experience_days setter + def update_plan_experiences!(plan) + plan.experience_days = proposed_experience_days + end + + # Attach proposed cover photo to the plan (if plan supports it) + def attach_cover_photo!(plan) + plan.cover_photo.attach(proposed_cover_photo.blob) if plan.respond_to?(:cover_photo) + end +end diff --git a/app/models/plan_suggestion_contribution.rb b/app/models/plan_suggestion_contribution.rb new file mode 100644 index 00000000..eb2dd8bc --- /dev/null +++ b/app/models/plan_suggestion_contribution.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# PlanSuggestionContribution - Multi-curator contribution tracking +# +# When multiple curators work on the same plan suggestion, +# each contribution is tracked separately for audit trail. +# +# Has same typed columns as PlanSuggestion - only non-nil fields +# represent what this specific curator changed. +# +# Usage: +# suggestion.add_contribution( +# user: curator_b, +# notes: "Added better title and experiences", +# proposed_title: "New title...", +# proposed_experience_days: { "1" => ["uuid1", "uuid2"] } +# ) +# +class PlanSuggestionContribution < ApplicationRecord + belongs_to :plan_suggestion + belongs_to :user + + validates :user_id, uniqueness: { + scope: :plan_suggestion_id, + message: "already contributed to this suggestion" + } +end diff --git a/app/models/review.rb b/app/models/review.rb index 934c82e9..6938222b 100644 --- a/app/models/review.rb +++ b/app/models/review.rb @@ -3,6 +3,9 @@ class Review < ApplicationRecord belongs_to :reviewable, polymorphic: true, counter_cache: :reviews_count belongs_to :user, optional: true + has_many :review_flags, dependent: :destroy + + enum :moderation_status, { unreviewed: 0, approved: 1, flagged: 2, removed: 3 } validates :rating, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 5 } @@ -15,6 +18,21 @@ class Review < ApplicationRecord scope :recent, -> { order(created_at: :desc) } scope :by_rating, ->(rating) { where(rating: rating) } scope :with_comments, -> { where.not(comment: [ nil, "" ]) } + scope :needs_moderation, -> { where(moderation_status: [ :unreviewed, :flagged ]) } + scope :moderated, -> { where(moderation_status: [ :approved, :removed ]) } + + def flagged_by?(user) + return false unless user + review_flags.exists?(user: user) + end + + def flag_count + review_flags.count + end + + def to_param + uuid + end private diff --git a/app/models/review_flag.rb b/app/models/review_flag.rb new file mode 100644 index 00000000..e23d19d5 --- /dev/null +++ b/app/models/review_flag.rb @@ -0,0 +1,17 @@ +class ReviewFlag < ApplicationRecord + belongs_to :review + belongs_to :user + + REASONS = %w[spam inappropriate inaccurate other].freeze + + validates :reason, presence: true, inclusion: { in: REASONS } + validates :user_id, uniqueness: { scope: :review_id, message: "has already flagged this review" } + + after_create :update_review_moderation_status + + private + + def update_review_moderation_status + review.update!(moderation_status: :flagged) if review.unreviewed? + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 6b7ec25e..a37b4f99 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -38,6 +38,11 @@ class User < ApplicationRecord # Normalize username to lowercase before_save { self.username = username.downcase } + # Flipper actor support (for per-user feature flags) + def flipper_id + "User;#{id}" + end + # Permission helpers def can_curate? curator? || admin? diff --git a/app/views/curator/admin/experience_suggestions/show.html.erb b/app/views/curator/admin/experience_suggestions/show.html.erb new file mode 100644 index 00000000..2ef70bb5 --- /dev/null +++ b/app/views/curator/admin/experience_suggestions/show.html.erb @@ -0,0 +1,351 @@ +
+
+

+ Prijedlog za iskustvo: <%= @suggestion.experience&.title || "Novo iskustvo" %> +

+
+ <%= link_to "Nazad na pregled", curator_admin_suggestions_path, + class: "inline-flex items-center rounded-md bg-white dark:bg-gray-800 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700" %> +
+
+ + +
+
+
+
+
+ Status: + <% case @suggestion.status %> + <% when "pending" %> + + Na čekanju + + <% when "approved" %> + + Odobreno + + <% when "rejected" %> + + Odbijeno + + <% end %> +
+
+ Tip izmjene: + + <% case @suggestion.change_type %> + <% when "create_resource" %> + Kreiranje + <% when "update_resource" %> + Izmjena + <% when "delete_resource" %> + Brisanje + <% end %> + +
+
+ Porijeklo: + + <% if @suggestion.origin_human? %> + Kurator + <% else %> + AI generisano + <% end %> + +
+
+ + <% if @suggestion.pending? %> +
+ <%= form_with url: approve_curator_admin_experience_suggestion_path(@suggestion), method: :post, class: "flex flex-col gap-2" do |f| %> + <%= f.text_field :admin_notes, + placeholder: "Bilješke admina (opcionalno)", + class: "rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white text-sm w-full" %> + <%= f.submit "Odobri prijedlog", + class: "inline-flex items-center justify-center rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500 cursor-pointer w-full", + data: { turbo_confirm: "Jeste li sigurni da želite odobriti ovaj prijedlog?" } %> + <% end %> + + <%= form_with url: reject_curator_admin_experience_suggestion_path(@suggestion), method: :post, class: "flex flex-col gap-2" do |f| %> + <%= f.text_field :admin_notes, + placeholder: "Razlog odbijanja", + class: "rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white text-sm w-full" %> + <%= f.submit "Odbij prijedlog", + class: "inline-flex items-center justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 cursor-pointer w-full", + data: { turbo_confirm: "Jeste li sigurni da želite odbiti ovaj prijedlog?" } %> + <% end %> +
+ <% end %> +
+ + <% if @suggestion.reviewed_by.present? %> +
+ Pregledao: <%= @suggestion.reviewed_by.username %> - <%= @suggestion.reviewed_at.strftime("%d.%m.%Y %H:%M") %> +
+ <% end %> +
+
+ + + <% if @suggestion.proposed_cover_photo.attached? %> +
+
+
+ <%= image_tag @suggestion.proposed_cover_photo, class: "max-h-96 object-contain rounded-lg" %> +
+

+ Naslovna fotografija +

+
+
+ <% end %> + + +
+
+

Predložene izmjene

+
+ <% if @suggestion.proposed_title.present? %> +
+
Naslov
+ <% if @suggestion.update_resource? && @suggestion.experience.present? %> +
+
+ <%= @suggestion.experience.title %> +
+
+ <%= @suggestion.proposed_title %> +
+
+ <% else %> +
+ <%= @suggestion.proposed_title %> +
+ <% end %> +
+ <% end %> + + <% if @suggestion.proposed_description.present? %> +
+
Opis
+ <% if @suggestion.update_resource? && @suggestion.experience.present? && @suggestion.experience.description.present? %> +
+
+ <%= @suggestion.experience.description %> +
+
+ <%= @suggestion.proposed_description %> +
+
+ <% else %> +
+ <%= @suggestion.proposed_description %> +
+ <% end %> +
+ <% end %> + + <% if @suggestion.proposed_seasons.present? %> +
+
Sezone
+
+
+ <% @suggestion.proposed_seasons.each do |season| %> + + <%= season %> + + <% end %> +
+
+
+ <% end %> + + <% if @suggestion.proposed_contact_name.present? %> +
+
Kontakt ime
+ <% if @suggestion.update_resource? && @suggestion.experience.present? %> +
+
+ <%= @suggestion.experience.contact_name %> +
+
+ <%= @suggestion.proposed_contact_name %> +
+
+ <% else %> +
+ <%= @suggestion.proposed_contact_name %> +
+ <% end %> +
+ <% end %> + + <% if @suggestion.proposed_contact_email.present? %> +
+
Kontakt email
+ <% if @suggestion.update_resource? && @suggestion.experience.present? %> +
+
+ <%= @suggestion.experience.contact_email %> +
+
+ <%= @suggestion.proposed_contact_email %> +
+
+ <% else %> +
+ <%= @suggestion.proposed_contact_email %> +
+ <% end %> +
+ <% end %> + + <% if @suggestion.proposed_contact_phone.present? %> +
+
Kontakt telefon
+ <% if @suggestion.update_resource? && @suggestion.experience.present? %> +
+
+ <%= @suggestion.experience.contact_phone %> +
+
+ <%= @suggestion.proposed_contact_phone %> +
+
+ <% else %> +
+ <%= @suggestion.proposed_contact_phone %> +
+ <% end %> +
+ <% end %> + + <% if @suggestion.proposed_contact_website.present? %> +
+
Kontakt website
+ <% if @suggestion.update_resource? && @suggestion.experience.present? %> +
+
+ <%= @suggestion.experience.contact_website %> +
+
+ <%= link_to @suggestion.proposed_contact_website, @suggestion.proposed_contact_website, target: "_blank", class: "text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300" %> +
+
+ <% else %> +
+ <%= link_to @suggestion.proposed_contact_website, @suggestion.proposed_contact_website, target: "_blank", class: "text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300" %> +
+ <% end %> +
+ <% end %> + + <% if @suggestion.proposed_location_uuids.present? %> +
+
Povezane lokacije
+
+ <% locations = Location.where(uuid: @suggestion.proposed_location_uuids) %> + <% if locations.any? %> +
    + <% locations.each do |location| %> +
  • + <%= link_to location.name, location_path(location), class: "text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300" %> +
  • + <% end %> +
+ <% else %> + + <%= @suggestion.proposed_location_uuids.count %> lokacija + + <% end %> +
+
+ <% end %> + + <% if @suggestion.proposed_video_urls.present? %> +
+
Video URL-ovi
+
+
    + <% @suggestion.proposed_video_urls.each do |url| %> +
  • + <%= link_to url, url, target: "_blank", class: "text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300 break-all" %> +
  • + <% end %> +
+
+
+ <% end %> +
+
+
+ + + <% if @suggestion.contributions.any? %> +
+
+

Doprinosi (<%= @suggestion.contributions.count %>)

+
+ <% @suggestion.contributions.order(created_at: :asc).each do |contribution| %> +
+
+ + <%= contribution.user.username %> + + + <%= contribution.created_at.strftime("%d.%m.%Y %H:%M") %> + +
+ <% if contribution.notes.present? %> +

<%= contribution.notes %>

+ <% end %> +
+ Izmijenjeno polja: <%= contribution.attributes.keys.select { |k| k.start_with?("proposed_") && contribution[k].present? }.map { |k| k.gsub("proposed_", "") }.join(", ") %> +
+
+ <% end %> +
+
+
+ <% end %> + + +
+
+

Informacije

+
+
+
Predložio
+
<%= @suggestion.user.username %>
+
+ +
+
Datum predlaganja
+
<%= @suggestion.created_at.strftime("%d.%m.%Y %H:%M") %>
+
+ + <% if @suggestion.experience.present? %> +
+
Postojeće iskustvo
+
+ <%= link_to @suggestion.experience.title, experience_path(@suggestion.experience), class: "text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300" %> +
+
+ <% end %> +
+
+
+ + + <% if @suggestion.admin_notes.present? %> +
+
+

Bilješke admina

+
+ <%= @suggestion.admin_notes %> +
+
+
+ <% end %> +
diff --git a/app/views/curator/admin/location_suggestions/show.html.erb b/app/views/curator/admin/location_suggestions/show.html.erb new file mode 100644 index 00000000..65a420f4 --- /dev/null +++ b/app/views/curator/admin/location_suggestions/show.html.erb @@ -0,0 +1,394 @@ +
+
+

+ Prijedlog za lokaciju: <%= @suggestion.location&.name || "Nova lokacija" %> +

+
+ <%= link_to "Nazad na pregled", curator_admin_suggestions_path, + class: "inline-flex items-center rounded-md bg-white dark:bg-gray-800 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700" %> +
+
+ + +
+
+
+
+
+ Status: + <% case @suggestion.status %> + <% when "pending" %> + + Na čekanju + + <% when "approved" %> + + Odobreno + + <% when "rejected" %> + + Odbijeno + + <% end %> +
+
+ Tip izmjene: + + <% case @suggestion.change_type %> + <% when "create_resource" %> + Kreiranje + <% when "update_resource" %> + Izmjena + <% when "delete_resource" %> + Brisanje + <% end %> + +
+
+ Porijeklo: + + <% if @suggestion.origin_human? %> + Kurator + <% else %> + AI generisano + <% end %> + +
+
+ + <% if @suggestion.pending? %> +
+ <%= form_with url: approve_curator_admin_location_suggestion_path(@suggestion), method: :post, class: "flex flex-col gap-2" do |f| %> + <%= f.text_field :admin_notes, + placeholder: "Bilješke admina (opcionalno)", + class: "rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white text-sm w-full" %> + <%= f.submit "Odobri prijedlog", + class: "inline-flex items-center justify-center rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500 cursor-pointer w-full", + data: { turbo_confirm: "Jeste li sigurni da želite odobriti ovaj prijedlog?" } %> + <% end %> + + <%= form_with url: reject_curator_admin_location_suggestion_path(@suggestion), method: :post, class: "flex flex-col gap-2" do |f| %> + <%= f.text_field :admin_notes, + placeholder: "Razlog odbijanja", + class: "rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white text-sm w-full" %> + <%= f.submit "Odbij prijedlog", + class: "inline-flex items-center justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 cursor-pointer w-full", + data: { turbo_confirm: "Jeste li sigurni da želite odbiti ovaj prijedlog?" } %> + <% end %> +
+ <% end %> +
+ + <% if @suggestion.reviewed_by.present? %> +
+ Pregledao: <%= @suggestion.reviewed_by.username %> - <%= @suggestion.reviewed_at.strftime("%d.%m.%Y %H:%M") %> +
+ <% end %> +
+
+ + +
+
+

Predložene izmjene

+
+ <% if @suggestion.proposed_name.present? %> +
+
Ime
+ <% if @suggestion.update_resource? && @suggestion.location.present? %> +
+
+ <%= @suggestion.location.name %> +
+
+ <%= @suggestion.proposed_name %> +
+
+ <% else %> +
+ <%= @suggestion.proposed_name %> +
+ <% end %> +
+ <% end %> + + <% if @suggestion.proposed_city.present? %> +
+
Grad
+ <% if @suggestion.update_resource? && @suggestion.location.present? %> +
+
+ <%= @suggestion.location.city %> +
+
+ <%= @suggestion.proposed_city %> +
+
+ <% else %> +
+ <%= @suggestion.proposed_city %> +
+ <% end %> +
+ <% end %> + + <% if @suggestion.proposed_description.present? %> +
+
Opis
+ <% if @suggestion.update_resource? && @suggestion.location.present? && @suggestion.location.description.present? %> +
+
+ <%= @suggestion.location.description %> +
+
+ <%= @suggestion.proposed_description %> +
+
+ <% else %> +
+ <%= @suggestion.proposed_description %> +
+ <% end %> +
+ <% end %> + + <% if @suggestion.proposed_historical_context.present? %> +
+
Historijski kontekst
+ <% if @suggestion.update_resource? && @suggestion.location.present? && @suggestion.location.historical_context.present? %> +
+
+ <%= @suggestion.location.historical_context %> +
+
+ <%= @suggestion.proposed_historical_context %> +
+
+ <% else %> +
+ <%= @suggestion.proposed_historical_context %> +
+ <% end %> +
+ <% end %> + + <% if @suggestion.proposed_lat.present? && @suggestion.proposed_lng.present? %> +
+
Lokacija (lat/lng)
+ <% if @suggestion.update_resource? && @suggestion.location.present? %> +
+
+ <%= @suggestion.location.lat %>, <%= @suggestion.location.lng %> +
+
+ <%= @suggestion.proposed_lat %>, <%= @suggestion.proposed_lng %> +
+
+ <% else %> +
+ <%= @suggestion.proposed_lat %>, <%= @suggestion.proposed_lng %> +
+ <% end %> +
+ <% end %> + + <% if @suggestion.proposed_budget.present? %> +
+
Budžet
+ <% if @suggestion.update_resource? && @suggestion.location.present? %> +
+
+ <%= @suggestion.location.budget %> +
+
+ <%= @suggestion.proposed_budget %> +
+
+ <% else %> +
+ <%= @suggestion.proposed_budget %> +
+ <% end %> +
+ <% end %> + + <% if @suggestion.proposed_phone.present? %> +
+
Telefon
+ <% if @suggestion.update_resource? && @suggestion.location.present? %> +
+
+ <%= @suggestion.location.phone %> +
+
+ <%= @suggestion.proposed_phone %> +
+
+ <% else %> +
+ <%= @suggestion.proposed_phone %> +
+ <% end %> +
+ <% end %> + + <% if @suggestion.proposed_email.present? %> +
+
Email
+ <% if @suggestion.update_resource? && @suggestion.location.present? %> +
+
+ <%= @suggestion.location.email %> +
+
+ <%= @suggestion.proposed_email %> +
+
+ <% else %> +
+ <%= @suggestion.proposed_email %> +
+ <% end %> +
+ <% end %> + + <% if @suggestion.proposed_website.present? %> +
+
Website
+ <% if @suggestion.update_resource? && @suggestion.location.present? %> +
+
+ <%= @suggestion.location.website %> +
+
+ <%= link_to @suggestion.proposed_website, @suggestion.proposed_website, target: "_blank", class: "text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300" %> +
+
+ <% else %> +
+ <%= link_to @suggestion.proposed_website, @suggestion.proposed_website, target: "_blank", class: "text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300" %> +
+ <% end %> +
+ <% end %> + + <% if @suggestion.proposed_tags.present? %> +
+
Tagovi
+
+
+ <% @suggestion.proposed_tags.each do |tag| %> + + <%= tag %> + + <% end %> +
+
+
+ <% end %> + + <% if @suggestion.proposed_video_urls.present? %> +
+
Video URL-ovi
+
+
    + <% @suggestion.proposed_video_urls.each do |url| %> +
  • + <%= link_to url, url, target: "_blank", class: "text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300 break-all" %> +
  • + <% end %> +
+
+
+ <% end %> +
+
+
+ + + <% if @suggestion.proposed_photos.attached? %> +
+
+

Fotografije (<%= @suggestion.proposed_photos.count %>)

+
+ <% @suggestion.proposed_photos.each_with_index do |photo, index| %> +
+ <%= image_tag photo, class: "w-full h-full object-cover rounded-lg" %> +
+ <%= index + 1 %> +
+
+ <% end %> +
+
+
+ <% end %> + + + <% if @suggestion.contributions.any? %> +
+
+

Doprinosi (<%= @suggestion.contributions.count %>)

+
+ <% @suggestion.contributions.order(created_at: :asc).each do |contribution| %> +
+
+ + <%= contribution.user.username %> + + + <%= contribution.created_at.strftime("%d.%m.%Y %H:%M") %> + +
+ <% if contribution.notes.present? %> +

<%= contribution.notes %>

+ <% end %> +
+ Izmijenjeno polja: <%= contribution.attributes.keys.select { |k| k.start_with?("proposed_") && contribution[k].present? }.map { |k| k.gsub("proposed_", "") }.join(", ") %> +
+
+ <% end %> +
+
+
+ <% end %> + + +
+
+

Informacije

+
+
+
Predložio
+
<%= @suggestion.user.username %>
+
+ +
+
Datum predlaganja
+
<%= @suggestion.created_at.strftime("%d.%m.%Y %H:%M") %>
+
+ + <% if @suggestion.location.present? %> +
+
Postojeća lokacija
+
+ <%= link_to @suggestion.location.name, location_path(@suggestion.location), class: "text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300" %> +
+
+ <% end %> +
+
+
+ + + <% if @suggestion.admin_notes.present? %> +
+
+

Bilješke admina

+
+ <%= @suggestion.admin_notes %> +
+
+
+ <% end %> +
diff --git a/app/views/curator/admin/plan_suggestions/show.html.erb b/app/views/curator/admin/plan_suggestions/show.html.erb new file mode 100644 index 00000000..aa829082 --- /dev/null +++ b/app/views/curator/admin/plan_suggestions/show.html.erb @@ -0,0 +1,303 @@ +
+
+

+ Prijedlog za plan: <%= @suggestion.plan&.title || "Novi plan" %> +

+
+ <%= link_to "Nazad na pregled", curator_admin_suggestions_path, + class: "inline-flex items-center rounded-md bg-white dark:bg-gray-800 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700" %> +
+
+ + +
+
+
+
+
+ Status: + <% case @suggestion.status %> + <% when "pending" %> + + Na čekanju + + <% when "approved" %> + + Odobreno + + <% when "rejected" %> + + Odbijeno + + <% end %> +
+
+ Tip izmjene: + + <% case @suggestion.change_type %> + <% when "create_resource" %> + Kreiranje + <% when "update_resource" %> + Izmjena + <% when "delete_resource" %> + Brisanje + <% end %> + +
+
+ Porijeklo: + + <% if @suggestion.origin_human? %> + Kurator + <% else %> + AI generisano + <% end %> + +
+
+ + <% if @suggestion.pending? %> +
+ <%= form_with url: approve_curator_admin_plan_suggestion_path(@suggestion), method: :post, class: "flex flex-col gap-2" do |f| %> + <%= f.text_field :admin_notes, + placeholder: "Bilješke admina (opcionalno)", + class: "rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white text-sm w-full" %> + <%= f.submit "Odobri prijedlog", + class: "inline-flex items-center justify-center rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500 cursor-pointer w-full", + data: { turbo_confirm: "Jeste li sigurni da želite odobriti ovaj prijedlog?" } %> + <% end %> + + <%= form_with url: reject_curator_admin_plan_suggestion_path(@suggestion), method: :post, class: "flex flex-col gap-2" do |f| %> + <%= f.text_field :admin_notes, + placeholder: "Razlog odbijanja", + class: "rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white text-sm w-full" %> + <%= f.submit "Odbij prijedlog", + class: "inline-flex items-center justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 cursor-pointer w-full", + data: { turbo_confirm: "Jeste li sigurni da želite odbiti ovaj prijedlog?" } %> + <% end %> +
+ <% end %> +
+ + <% if @suggestion.reviewed_by.present? %> +
+ Pregledao: <%= @suggestion.reviewed_by.username %> - <%= @suggestion.reviewed_at.strftime("%d.%m.%Y %H:%M") %> +
+ <% end %> +
+
+ + + <% if @suggestion.proposed_cover_photo.attached? %> +
+
+
+ <%= image_tag @suggestion.proposed_cover_photo, class: "max-h-96 object-contain rounded-lg" %> +
+

+ Naslovna fotografija +

+
+
+ <% end %> + + +
+
+

Predložene izmjene

+
+ <% if @suggestion.proposed_title.present? %> +
+
Naslov
+ <% if @suggestion.update_resource? && @suggestion.plan.present? %> +
+
+ <%= @suggestion.plan.title %> +
+
+ <%= @suggestion.proposed_title %> +
+
+ <% else %> +
+ <%= @suggestion.proposed_title %> +
+ <% end %> +
+ <% end %> + + <% if @suggestion.proposed_notes.present? %> +
+
Bilješke
+ <% if @suggestion.update_resource? && @suggestion.plan.present? && @suggestion.plan.notes.present? %> +
+
+ <%= @suggestion.plan.notes %> +
+
+ <%= @suggestion.proposed_notes %> +
+
+ <% else %> +
+ <%= @suggestion.proposed_notes %> +
+ <% end %> +
+ <% end %> + + <% if @suggestion.proposed_city_name.present? %> +
+
Grad
+ <% if @suggestion.update_resource? && @suggestion.plan.present? %> +
+
+ <%= @suggestion.plan.city_name %> +
+
+ <%= @suggestion.proposed_city_name %> +
+
+ <% else %> +
+ <%= @suggestion.proposed_city_name %> +
+ <% end %> +
+ <% end %> + + <% if @suggestion.proposed_visibility.present? %> +
+
Vidljivost
+ <% if @suggestion.update_resource? && @suggestion.plan.present? %> +
+
+ <%= @suggestion.plan.visibility %> +
+
+ <%= @suggestion.proposed_visibility %> +
+
+ <% else %> +
+ <%= @suggestion.proposed_visibility %> +
+ <% end %> +
+ <% end %> + + <% if @suggestion.proposed_experience_days.present? %> +
+
Iskustva po danima
+
+
+ <% @suggestion.proposed_experience_days.each do |day, experience_uuids| %> +
+
Dan <%= day %>
+ <% experiences = Experience.where(uuid: experience_uuids) %> + <% if experiences.any? %> +
    + <% experiences.each do |experience| %> +
  • + <%= link_to experience.title, experience_path(experience), class: "text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300" %> +
  • + <% end %> +
+ <% else %> + + <%= experience_uuids.count %> iskustava + + <% end %> +
+ <% end %> +
+
+
+ <% end %> + + <% if @suggestion.proposed_preferences.present? %> +
+
Preferencije
+
+
+ <% @suggestion.proposed_preferences.each do |key, value| %> + + <%= key %>: <%= value %> + + <% end %> +
+
+
+ <% end %> +
+
+
+ + + <% if @suggestion.contributions.any? %> +
+
+

Doprinosi (<%= @suggestion.contributions.count %>)

+
+ <% @suggestion.contributions.order(created_at: :asc).each do |contribution| %> +
+
+ + <%= contribution.user.username %> + + + <%= contribution.created_at.strftime("%d.%m.%Y %H:%M") %> + +
+ <% if contribution.notes.present? %> +

<%= contribution.notes %>

+ <% end %> +
+ Izmijenjeno polja: <%= contribution.attributes.keys.select { |k| k.start_with?("proposed_") && contribution[k].present? }.map { |k| k.gsub("proposed_", "") }.join(", ") %> +
+
+ <% end %> +
+
+
+ <% end %> + + +
+
+

Informacije

+
+
+
Predložio
+
<%= @suggestion.user.username %>
+
+ +
+
Datum predlaganja
+
<%= @suggestion.created_at.strftime("%d.%m.%Y %H:%M") %>
+
+ + <% if @suggestion.plan.present? %> +
+
Postojeći plan
+
+ <%= link_to @suggestion.plan.title, plan_path(@suggestion.plan), class: "text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300" %> +
+
+ <% end %> +
+
+
+ + + <% if @suggestion.admin_notes.present? %> +
+
+

Bilješke admina

+
+ <%= @suggestion.admin_notes %> +
+
+
+ <% end %> +
diff --git a/app/views/curator/admin/reviews/index.html.erb b/app/views/curator/admin/reviews/index.html.erb new file mode 100644 index 00000000..c1e90761 --- /dev/null +++ b/app/views/curator/admin/reviews/index.html.erb @@ -0,0 +1,218 @@ +
+
+

Moderacija recenzija

+
+ + +
+
+
Ukupno
+
<%= @stats[:total] %>
+
+
+
Nepregledan
+
<%= @stats[:unreviewed] %>
+
+
+
Prijavljen
+
<%= @stats[:flagged] %>
+
+
+
Odobren
+
<%= @stats[:approved] %>
+
+
+
Uklonjen
+
<%= @stats[:removed] %>
+
+
+ + +
+ <%= link_to "Sve", curator_admin_reviews_path, + class: "px-3 py-1 rounded-full text-xs sm:text-sm #{params[:moderation_status].blank? ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'}" %> + <%= link_to "Nepregledano", curator_admin_reviews_path(moderation_status: :unreviewed), + class: "px-3 py-1 rounded-full text-xs sm:text-sm #{params[:moderation_status] == 'unreviewed' ? 'bg-yellow-500 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'}" %> + <%= link_to "Prijavljeno", curator_admin_reviews_path(moderation_status: :flagged), + class: "px-3 py-1 rounded-full text-xs sm:text-sm #{params[:moderation_status] == 'flagged' ? 'bg-orange-500 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'}" %> + <%= link_to "Odobreno", curator_admin_reviews_path(moderation_status: :approved), + class: "px-3 py-1 rounded-full text-xs sm:text-sm #{params[:moderation_status] == 'approved' ? 'bg-green-500 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'}" %> + <%= link_to "Uklonjeno", curator_admin_reviews_path(moderation_status: :removed), + class: "px-3 py-1 rounded-full text-xs sm:text-sm #{params[:moderation_status] == 'removed' ? 'bg-red-500 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'}" %> +
+ + +
+ Tip: + <%= link_to "Sve", curator_admin_reviews_path(moderation_status: params[:moderation_status]), + class: "px-3 py-1 rounded-full text-xs sm:text-sm #{params[:type].blank? ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'}" %> + <%= link_to "Location", curator_admin_reviews_path(type: "Location", moderation_status: params[:moderation_status]), + class: "px-3 py-1 rounded-full text-xs sm:text-sm #{params[:type] == 'Location' ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'}" %> + <%= link_to "Experience", curator_admin_reviews_path(type: "Experience", moderation_status: params[:moderation_status]), + class: "px-3 py-1 rounded-full text-xs sm:text-sm #{params[:type] == 'Experience' ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'}" %> + <%= link_to "Plan", curator_admin_reviews_path(type: "Plan", moderation_status: params[:moderation_status]), + class: "px-3 py-1 rounded-full text-xs sm:text-sm #{params[:type] == 'Plan' ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'}" %> +
+ + + + + +
+ <% @reviews.each do |review| %> +
+
+ + <%= review.author_name.presence || review.user&.username || "Anonimno" %> + + <% case review.moderation_status %> + <% when "unreviewed" %> + + Nepregledano + + <% when "flagged" %> + + Prijavljeno + + <% when "approved" %> + + Odobreno + + <% when "removed" %> + + Uklonjeno + + <% end %> +
+ +
+ <% review.rating.times do %> + + + + <% end %> +
+ +

+ <%= review.comment.presence || "-" %> +

+ + <% if review.reviewable %> +

+ <%= review.reviewable.try(:name) || review.reviewable.try(:title) %> + (<%= review.reviewable_type %>) +

+ <% else %> +

Resurs obrisan

+ <% end %> + + <% if review.review_flags.any? %> +

+ <%= review.review_flags.count %> prijava +

+ <% end %> + + <%= link_to "Prikaži detalje", curator_admin_review_path(review), + class: "text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300 font-medium" %> +
+ <% end %> + + <% if @reviews.empty? %> +
+ Nema recenzija. +
+ <% end %> +
+ + + <% if @reviews.total_pages > 1 %> +
+ <%= paginate @reviews %> +
+ <% end %> +
diff --git a/app/views/curator/admin/reviews/show.html.erb b/app/views/curator/admin/reviews/show.html.erb new file mode 100644 index 00000000..2f59cdc4 --- /dev/null +++ b/app/views/curator/admin/reviews/show.html.erb @@ -0,0 +1,132 @@ +
+
+

Recenzija

+ <%= link_to "Nazad na listu", curator_admin_reviews_path, class: "text-sm text-emerald-600 dark:text-emerald-400 hover:text-emerald-900 dark:hover:text-emerald-300" %> +
+ + +
+
+
+
+ <% @review.rating.times do %> + + + + <% end %> + <% (5 - @review.rating).times do %> + + + + <% end %> +
+ <%= @review.rating %>/5 +
+ +
+
+
Autor
+
+ <%= @review.author_name.presence || @review.user&.username || "Anonimno" %> + <% if @review.user %> + (Korisnik) + <% end %> +
+
+
+
Tip resursa
+
<%= @review.reviewable_type %>
+
+
+
Resurs
+
+ <% if @review.reviewable %> + <%= @review.reviewable.try(:name) || @review.reviewable.try(:title) %> + <% else %> + Obrisan + <% end %> +
+
+
+
Kreirano
+
<%= @review.created_at.strftime("%d.%m.%Y %H:%M") %>
+
+
+
Status moderacije
+
+ <% case @review.moderation_status %> + <% when "unreviewed" %> + + Nepregledano + + <% when "flagged" %> + + Prijavljeno + + <% when "approved" %> + + Odobreno + + <% when "removed" %> + + Uklonjeno + + <% end %> +
+
+
+
Komentar
+
<%= @review.comment.presence || "Bez komentara" %>
+
+
+ + + <% unless @review.approved? || @review.removed? %> +
+ <%= button_to "Odobri recenziju", approve_curator_admin_review_path(@review), + method: :post, + class: "inline-flex items-center rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500" %> + <%= button_to "Ukloni recenziju", remove_curator_admin_review_path(@review), + method: :post, + class: "inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500", + data: { turbo_confirm: "Jeste li sigurni da želite ukloniti ovu recenziju?" } %> +
+ <% end %> +
+
+ + + <% if @review.review_flags.any? %> +
+
+

Prijave (<%= @review.review_flags.count %>)

+
+
+
+ <% @review.review_flags.includes(:user).order(created_at: :desc).each do |flag| %> +
+
+
+ <%= flag.user.username %> + + <%= flag.reason.humanize %> + +
+ <%= flag.created_at.strftime("%d.%m.%Y %H:%M") %> +
+ <% if flag.notes.present? %> +

<%= flag.notes %>

+ <% end %> +
+ <% end %> +
+
+
+ <% else %> +
+
+

Nema prijava za ovu recenziju.

+
+
+ <% end %> +
diff --git a/app/views/curator/admin/suggestions/index.html.erb b/app/views/curator/admin/suggestions/index.html.erb new file mode 100644 index 00000000..61070f4f --- /dev/null +++ b/app/views/curator/admin/suggestions/index.html.erb @@ -0,0 +1,403 @@ +
+
+

Prijedlozi za pregled

+
+ + +
+
+
Ukupno
+
<%= @stats[:total_pending] %>
+
+
+
Lokacije
+
<%= @stats[:location_pending] %>
+
+
+
Iskustva
+
<%= @stats[:experience_pending] %>
+
+
+
Planovi
+
<%= @stats[:plan_pending] %>
+
+
+ + + <% if @location_suggestions.any? %> +
+

Lokacije

+ + + + + +
+ <% @location_suggestions.each do |suggestion| %> +
+
+ + <%= suggestion.location&.name || "Nova lokacija" %> + + <% case suggestion.change_type %> + <% when "create_resource" %> + + Kreiranje + + <% when "update_resource" %> + + Izmjena + + <% when "delete_resource" %> + + Brisanje + + <% end %> +
+
+ <% if suggestion.origin_human? %> + + Kurator + + <% else %> + + AI + + <% end %> + + <%= suggestion.user.username %> + +
+
+ + <%= suggestion.created_at.strftime("%d.%m.%Y") %> • <%= suggestion.contributions.count %> doprinosa + + <%= link_to "Pregledaj", curator_admin_location_suggestion_path(suggestion), + class: "text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300 font-medium" %> +
+
+ <% end %> +
+
+ <% end %> + + + <% if @experience_suggestions.any? %> +
+

Iskustva

+ + + + + +
+ <% @experience_suggestions.each do |suggestion| %> +
+
+ + <%= suggestion.experience&.title || "Novo iskustvo" %> + + <% case suggestion.change_type %> + <% when "create_resource" %> + + Kreiranje + + <% when "update_resource" %> + + Izmjena + + <% when "delete_resource" %> + + Brisanje + + <% end %> +
+
+ <% if suggestion.origin_human? %> + + Kurator + + <% else %> + + AI + + <% end %> + + <%= suggestion.user.username %> + +
+
+ + <%= suggestion.created_at.strftime("%d.%m.%Y") %> • <%= suggestion.contributions.count %> doprinosa + + <%= link_to "Pregledaj", curator_admin_experience_suggestion_path(suggestion), + class: "text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300 font-medium" %> +
+
+ <% end %> +
+
+ <% end %> + + + <% if @plan_suggestions.any? %> +
+

Planovi

+ + + + + +
+ <% @plan_suggestions.each do |suggestion| %> +
+
+ + <%= suggestion.plan&.title || "Novi plan" %> + + <% case suggestion.change_type %> + <% when "create_resource" %> + + Kreiranje + + <% when "update_resource" %> + + Izmjena + + <% when "delete_resource" %> + + Brisanje + + <% end %> +
+
+ <% if suggestion.origin_human? %> + + Kurator + + <% else %> + + AI + + <% end %> + + <%= suggestion.user.username %> + +
+
+ + <%= suggestion.created_at.strftime("%d.%m.%Y") %> • <%= suggestion.contributions.count %> doprinosa + + <%= link_to "Pregledaj", curator_admin_plan_suggestion_path(suggestion), + class: "text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300 font-medium" %> +
+
+ <% end %> +
+
+ <% end %> + + + <% if @location_suggestions.empty? && @experience_suggestions.empty? && @plan_suggestions.empty? %> +
+
+ Nema prijedloga za pregled +
+
+ <% end %> +
diff --git a/app/views/curator/audio_tours/index.html.erb b/app/views/curator/audio_tours/index.html.erb index 16f44d2c..f7e38017 100644 --- a/app/views/curator/audio_tours/index.html.erb +++ b/app/views/curator/audio_tours/index.html.erb @@ -1,7 +1,7 @@

<%= t("curator.audio_tours.title") %>

- <% if Flipper.enabled?(:curator_edit_delete) %> + <% if admin_direct_crud? %> <%= link_to t("curator.audio_tours.new"), new_curator_audio_tour_path, class: "inline-flex items-center justify-center rounded-md bg-emerald-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-emerald-500 w-full sm:w-auto" %> <% end %> @@ -95,7 +95,7 @@ <%= link_to t("curator.audio_tours.view"), curator_audio_tour_path(audio_tour), class: "text-emerald-600 dark:text-emerald-400 hover:text-emerald-900 dark:hover:text-emerald-300" %> - <% if Flipper.enabled?(:curator_edit_delete) %> + <% if admin_direct_crud? %> <%= link_to t("curator.audio_tours.edit"), edit_curator_audio_tour_path(audio_tour), class: "text-emerald-600 dark:text-emerald-400 hover:text-emerald-900 dark:hover:text-emerald-300" %> <%= button_to curator_audio_tour_path(audio_tour), method: :delete, @@ -158,7 +158,7 @@ <%= link_to curator_audio_tour_path(audio_tour), class: "flex-1 text-center py-2 text-sm font-medium text-emerald-600 dark:text-emerald-400 hover:text-emerald-900 dark:hover:text-emerald-300" do %> <%= t("curator.audio_tours.view") %> <% end %> - <% if Flipper.enabled?(:curator_edit_delete) %> + <% if admin_direct_crud? %> <%= link_to edit_curator_audio_tour_path(audio_tour), class: "flex-1 text-center py-2 text-sm font-medium text-emerald-600 dark:text-emerald-400 hover:text-emerald-900 dark:hover:text-emerald-300" do %> <%= t("curator.audio_tours.edit") %> <% end %> diff --git a/app/views/curator/audio_tours/show.html.erb b/app/views/curator/audio_tours/show.html.erb index a2d7fa99..8a68f5f8 100644 --- a/app/views/curator/audio_tours/show.html.erb +++ b/app/views/curator/audio_tours/show.html.erb @@ -70,7 +70,7 @@ <% end %> - <% if Flipper.enabled?(:curator_edit_delete) %> + <% if admin_direct_crud? %>
<%= link_to edit_curator_audio_tour_path(@audio_tour), class: "inline-flex items-center rounded-md bg-emerald-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-emerald-500" do %> diff --git a/app/views/curator/dashboard/index.html.erb b/app/views/curator/dashboard/index.html.erb index dcd25062..0203d9db 100644 --- a/app/views/curator/dashboard/index.html.erb +++ b/app/views/curator/dashboard/index.html.erb @@ -63,7 +63,7 @@

<%= t("curator.dashboard.quick_actions") %>

- <% if Flipper.enabled?(:curator_edit_delete) %> + <% if admin_direct_crud? %> <%= link_to new_curator_location_path, class: "inline-flex items-center justify-center rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-emerald-500" do %> <%= t("curator.dashboard.new_location") %> diff --git a/app/views/curator/experiences/index.html.erb b/app/views/curator/experiences/index.html.erb index 99072803..099937ec 100644 --- a/app/views/curator/experiences/index.html.erb +++ b/app/views/curator/experiences/index.html.erb @@ -1,7 +1,7 @@

<%= t("curator.experiences.title") %>

- <% if Flipper.enabled?(:curator_edit_delete) %> + <% if admin_direct_crud? %> <%= link_to t("curator.experiences.new"), new_curator_experience_path, class: "inline-flex items-center justify-center rounded-md bg-emerald-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-emerald-500 w-full sm:w-auto" %> <% end %> diff --git a/app/views/curator/experiences/show.html.erb b/app/views/curator/experiences/show.html.erb index fcc26bcf..44aa8e4d 100644 --- a/app/views/curator/experiences/show.html.erb +++ b/app/views/curator/experiences/show.html.erb @@ -12,7 +12,7 @@ <%= t("curator.experiences.view_in_app", default: "View in App") %> <% end %> - <% if Flipper.enabled?(:curator_edit_delete) %> + <% if admin_direct_crud? %> <%= link_to edit_curator_experience_path(@experience), class: "inline-flex items-center justify-center rounded-md bg-emerald-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-emerald-500" do %> diff --git a/app/views/curator/locations/index.html.erb b/app/views/curator/locations/index.html.erb index 29bcdc6d..88070708 100644 --- a/app/views/curator/locations/index.html.erb +++ b/app/views/curator/locations/index.html.erb @@ -1,7 +1,7 @@

<%= t("curator.locations.title") %>

- <% if Flipper.enabled?(:curator_edit_delete) %> + <% if admin_direct_crud? %> <%= link_to t("curator.locations.new"), new_curator_location_path, class: "inline-flex items-center justify-center rounded-md bg-emerald-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-emerald-500 w-full sm:w-auto" %> <% end %> diff --git a/app/views/curator/locations/show.html.erb b/app/views/curator/locations/show.html.erb index c10b6987..b01e2e1c 100644 --- a/app/views/curator/locations/show.html.erb +++ b/app/views/curator/locations/show.html.erb @@ -12,7 +12,7 @@ <%= t("curator.locations.view_in_app", default: "View in App") %> <% end %> - <% if Flipper.enabled?(:curator_edit_delete) %> + <% if admin_direct_crud? %> <%= link_to edit_curator_location_path(@location), class: "inline-flex items-center justify-center rounded-md bg-emerald-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-emerald-500" do %> @@ -217,29 +217,51 @@ <% end %> <%# Audio Tours %> - <% if @location.has_audio_tours? %> -
-
-
-

<%= t("curator.locations.sections.audio", default: "Audio Tours") %>

- <%= link_to curator_audio_tours_path(location_id: @location.to_param), class: "text-sm text-emerald-600 dark:text-emerald-400 hover:text-emerald-700 dark:hover:text-emerald-300" do %> - <%= t("curator.locations.manage_all", default: "Manage all") %> → - <% end %> -
-
- <% @location.audio_tours.with_audio.each do |audio_tour| %> +
+
+
+

Audio Ture

+ <%= link_to curator_audio_tours_path(location_id: @location.to_param), class: "text-sm text-emerald-600 dark:text-emerald-400 hover:text-emerald-700 dark:hover:text-emerald-300" do %> + Upravljaj svima → + <% end %> +
+ + <% audio_tours_with_audio = @location.audio_tours.with_audio %> + <% if audio_tours_with_audio.any? %> +
+ <% audio_tours_with_audio.each do |audio_tour| %>
- <%= audio_tour.language_name %> -
<% end %>
-
+ <% else %> +

Nema audio tura za ovu lokaciju.

+ <% end %> + + <% if current_user.admin? %> +
+

Generiši audio turu

+ <%= form_with url: generate_audio_tour_curator_location_path(@location), method: :post, class: "flex flex-col sm:flex-row items-stretch sm:items-center gap-3" do |f| %> + <%= f.select :locale, + AudioTour.locale_options, + { selected: "bs" }, + class: "block w-full sm:w-auto rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white text-sm shadow-sm focus:border-emerald-500 focus:ring-emerald-500" %> + <%= f.submit "Generiši audio turu", + class: "inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 cursor-pointer", + data: { turbo_confirm: "Generisanje audio ture košta ~$1-1.50 (ElevenLabs). Nastaviti?" } %> + <% end %> +

Audio tura se generise u pozadini. Osvježite stranicu za status.

+
+ <% end %>
- <% end %> +
<%# Metadata %>
diff --git a/app/views/curator/plans/index.html.erb b/app/views/curator/plans/index.html.erb index bbed2ea3..458ea2f6 100644 --- a/app/views/curator/plans/index.html.erb +++ b/app/views/curator/plans/index.html.erb @@ -1,7 +1,7 @@

<%= t("curator.plans.title") %>

- <% if Flipper.enabled?(:curator_edit_delete) %> + <% if admin_direct_crud? %> <%= link_to t("curator.plans.new"), new_curator_plan_path, class: "inline-flex items-center justify-center rounded-md bg-emerald-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-emerald-500 w-full sm:w-auto" %> <% end %> diff --git a/app/views/curator/plans/show.html.erb b/app/views/curator/plans/show.html.erb index c5deb79f..1200a67a 100644 --- a/app/views/curator/plans/show.html.erb +++ b/app/views/curator/plans/show.html.erb @@ -19,7 +19,7 @@ <%= t("curator.plans.view_in_app", default: "View in App") %> <% end %> - <% if Flipper.enabled?(:curator_edit_delete) %> + <% if admin_direct_crud? %> <%= link_to edit_curator_plan_path(@plan), class: "inline-flex items-center justify-center rounded-md bg-emerald-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-emerald-500" do %> @@ -115,7 +115,7 @@
<% end %> - <% if Flipper.enabled?(:curator_edit_delete) %> + <% if admin_direct_crud? %>
<%= link_to edit_curator_plan_path(@plan), class: "inline-flex items-center rounded-md bg-emerald-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-emerald-500" do %> diff --git a/app/views/curator/reviews/show.html.erb b/app/views/curator/reviews/show.html.erb index 6d9b5ebe..51bf5133 100644 --- a/app/views/curator/reviews/show.html.erb +++ b/app/views/curator/reviews/show.html.erb @@ -61,4 +61,52 @@
+ + +
+
+

Prijavi neprimjerenu recenziju

+
+
+ <% if @review.flagged_by?(current_user) %> +
+

Već ste prijavili ovu recenziju.

+
+ <% else %> + <%= form_with url: flag_curator_review_path(@review), method: :post, class: "space-y-4" do |f| %> +
+ + +
+ +
+ + +
+ +
+ <%= f.submit "Prijavi recenziju", class: "inline-flex items-center rounded-md bg-orange-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-orange-500" %> +
+ <% end %> + <% end %> + + + <% if @review.review_flags.any? %> +
+

+ Ova recenzija ima <%= @review.review_flags.count %> prijava. +

+
+ <% end %> +
+
diff --git a/app/views/layouts/curator.html.erb b/app/views/layouts/curator.html.erb index 5b8f747f..196aa1b7 100644 --- a/app/views/layouts/curator.html.erb +++ b/app/views/layouts/curator.html.erb @@ -105,6 +105,10 @@ class: "block whitespace-nowrap px-4 py-2 text-sm #{request.path.start_with?(curator_admin_curator_applications_path) ? 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 font-medium' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}" %> <%= link_to t("curator.nav.admin_photo_suggestions", default: "Fotografije"), curator_admin_photo_suggestions_path, class: "block whitespace-nowrap px-4 py-2 text-sm #{request.path.start_with?(curator_admin_photo_suggestions_path) ? 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 font-medium' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}" %> + <%= link_to "Prijedlozi", curator_admin_suggestions_path, + class: "block whitespace-nowrap px-4 py-2 text-sm #{request.path.start_with?(curator_admin_suggestions_path) ? 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 font-medium' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}" %> + <%= link_to "Recenzije", curator_admin_reviews_path, + class: "block whitespace-nowrap px-4 py-2 text-sm #{request.path.start_with?(curator_admin_reviews_path) ? 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 font-medium' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}" %>
<%= link_to t("curator.nav.admin_users", default: "Korisnici"), curator_admin_users_path, class: "block whitespace-nowrap px-4 py-2 text-sm #{request.path.start_with?(curator_admin_users_path) ? 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 font-medium' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}" %> @@ -201,6 +205,10 @@ class: "block rounded-md px-3 py-2 text-base font-medium #{request.path.start_with?(curator_admin_curator_applications_path) ? 'bg-red-800 dark:bg-red-900 text-white' : 'text-red-200 dark:text-red-300 hover:bg-red-600 dark:hover:bg-red-800'}" %> <%= link_to t("curator.nav.admin_photo_suggestions", default: "Fotografije"), curator_admin_photo_suggestions_path, class: "block rounded-md px-3 py-2 text-base font-medium #{request.path.start_with?(curator_admin_photo_suggestions_path) ? 'bg-red-800 dark:bg-red-900 text-white' : 'text-red-200 dark:text-red-300 hover:bg-red-600 dark:hover:bg-red-800'}" %> + <%= link_to "Prijedlozi", curator_admin_suggestions_path, + class: "block rounded-md px-3 py-2 text-base font-medium #{request.path.start_with?(curator_admin_suggestions_path) ? 'bg-red-800 dark:bg-red-900 text-white' : 'text-red-200 dark:text-red-300 hover:bg-red-600 dark:hover:bg-red-800'}" %> + <%= link_to "Recenzije", curator_admin_reviews_path, + class: "block rounded-md px-3 py-2 text-base font-medium #{request.path.start_with?(curator_admin_reviews_path) ? 'bg-red-800 dark:bg-red-900 text-white' : 'text-red-200 dark:text-red-300 hover:bg-red-600 dark:hover:bg-red-800'}" %> <%= link_to t("curator.nav.admin_users", default: "Korisnici"), curator_admin_users_path, class: "block rounded-md px-3 py-2 text-base font-medium #{request.path.start_with?(curator_admin_users_path) ? 'bg-red-800 dark:bg-red-900 text-white' : 'text-red-200 dark:text-red-300 hover:bg-red-600 dark:hover:bg-red-800'}" %>
diff --git a/config/routes.rb b/config/routes.rb index 4306beac..9ebf0954 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -86,14 +86,26 @@ namespace :curator do resources :locations do resources :photo_suggestions, only: [ :new, :create ] + resources :location_suggestions, only: [ :new, :create, :edit, :update ] collection do get :needs_photos end + member do + post :generate_audio_tour + end + end + resources :experiences do + resources :experience_suggestions, only: [ :new, :create, :edit, :update ] + end + resources :reviews, only: [ :index, :show, :destroy ] do + member do + post :flag + end end - resources :experiences - resources :reviews, only: [ :index, :show, :destroy ] resources :audio_tours - resources :plans + resources :plans do + resources :plan_suggestions, only: [ :new, :create, :edit, :update ] + end resources :proposals, only: [ :index, :show ] do member do post :add_review @@ -103,12 +115,37 @@ # Admin features for admin users within curator dashboard namespace :admin do + resources :suggestions, only: [ :index ] resources :photo_suggestions, only: [ :index, :show ] do member do post :approve post :reject end end + resources :location_suggestions, only: [ :show ] do + member do + post :approve + post :reject + end + end + resources :experience_suggestions, only: [ :show ] do + member do + post :approve + post :reject + end + end + resources :plan_suggestions, only: [ :show ] do + member do + post :approve + post :reject + end + end + resources :reviews, only: [ :index, :show ] do + member do + post :approve + post :remove + end + end resources :users, only: [ :index, :show, :edit, :update ] do member do post :unblock diff --git a/db/migrate/20260205223400_create_location_suggestions.rb b/db/migrate/20260205223400_create_location_suggestions.rb new file mode 100644 index 00000000..8fe95da2 --- /dev/null +++ b/db/migrate/20260205223400_create_location_suggestions.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class CreateLocationSuggestions < ActiveRecord::Migration[8.0] + def change + create_table :location_suggestions do |t| + t.references :location, foreign_key: true # nil for create_resource + t.references :user, null: false, foreign_key: true + t.references :reviewed_by, foreign_key: { to_table: :users } + + t.integer :status, default: 0, null: false + t.integer :change_type, default: 0, null: false + t.integer :origin, default: 0, null: false + t.string :ai_service + t.datetime :reviewed_at + t.text :admin_notes + + # Typed proposed fields matching Location attributes + t.string :proposed_name + t.string :proposed_city + t.text :proposed_description + t.text :proposed_historical_context + t.decimal :proposed_lat, precision: 10, scale: 6 + t.decimal :proposed_lng, precision: 10, scale: 6 + t.integer :proposed_budget + t.string :proposed_phone + t.string :proposed_email + t.string :proposed_website + t.jsonb :proposed_video_urls, default: [] + t.jsonb :proposed_social_links, default: {} + t.jsonb :proposed_tags, default: [] + t.jsonb :proposed_category_ids, default: [] + t.jsonb :proposed_experience_type_ids, default: [] + + t.timestamps + end + + # One pending suggestion per location + add_index :location_suggestions, :location_id, + unique: true, + where: "status = 0 AND location_id IS NOT NULL", + name: "idx_one_pending_per_location" + end +end diff --git a/db/migrate/20260205223500_create_location_suggestion_contributions.rb b/db/migrate/20260205223500_create_location_suggestion_contributions.rb new file mode 100644 index 00000000..17f53e0d --- /dev/null +++ b/db/migrate/20260205223500_create_location_suggestion_contributions.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class CreateLocationSuggestionContributions < ActiveRecord::Migration[8.0] + def change + create_table :location_suggestion_contributions do |t| + t.references :location_suggestion, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.text :notes + + # Same typed proposed fields as LocationSuggestion + # Only non-nil fields = fields that this curator is changing + t.string :proposed_name + t.string :proposed_city + t.text :proposed_description + t.text :proposed_historical_context + t.decimal :proposed_lat, precision: 10, scale: 6 + t.decimal :proposed_lng, precision: 10, scale: 6 + t.integer :proposed_budget + t.string :proposed_phone + t.string :proposed_email + t.string :proposed_website + t.jsonb :proposed_video_urls + t.jsonb :proposed_social_links + t.jsonb :proposed_tags + t.jsonb :proposed_category_ids + t.jsonb :proposed_experience_type_ids + + t.timestamps + end + + add_index :location_suggestion_contributions, + [:location_suggestion_id, :user_id], + unique: true, + name: "idx_loc_suggestion_contrib_unique_user" + end +end diff --git a/db/migrate/20260205223600_create_experience_suggestions.rb b/db/migrate/20260205223600_create_experience_suggestions.rb new file mode 100644 index 00000000..9cb61681 --- /dev/null +++ b/db/migrate/20260205223600_create_experience_suggestions.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class CreateExperienceSuggestions < ActiveRecord::Migration[8.0] + def change + create_table :experience_suggestions do |t| + t.references :experience, foreign_key: true # nil for create_resource + t.references :user, null: false, foreign_key: true + t.references :reviewed_by, foreign_key: { to_table: :users } + + t.integer :status, default: 0, null: false + t.integer :change_type, default: 0, null: false + t.integer :origin, default: 0, null: false + t.string :ai_service + t.datetime :reviewed_at + t.text :admin_notes + + # Typed proposed fields matching Experience attributes + t.string :proposed_title + t.text :proposed_description + t.bigint :proposed_experience_category_id + t.integer :proposed_estimated_duration + t.string :proposed_contact_name + t.string :proposed_contact_email + t.string :proposed_contact_phone + t.string :proposed_contact_website + t.jsonb :proposed_seasons, default: [] + t.jsonb :proposed_video_urls, default: [] + t.jsonb :proposed_location_uuids, default: [] + + t.timestamps + end + + # One pending suggestion per experience + add_index :experience_suggestions, :experience_id, + unique: true, + where: "status = 0 AND experience_id IS NOT NULL", + name: "idx_one_pending_per_experience" + end +end diff --git a/db/migrate/20260205223700_create_experience_suggestion_contributions.rb b/db/migrate/20260205223700_create_experience_suggestion_contributions.rb new file mode 100644 index 00000000..da805a49 --- /dev/null +++ b/db/migrate/20260205223700_create_experience_suggestion_contributions.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class CreateExperienceSuggestionContributions < ActiveRecord::Migration[8.0] + def change + create_table :experience_suggestion_contributions do |t| + t.references :experience_suggestion, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.text :notes + + # Same typed proposed fields as ExperienceSuggestion + # Only non-nil fields = fields that this curator is changing + t.string :proposed_title + t.text :proposed_description + t.bigint :proposed_experience_category_id + t.integer :proposed_estimated_duration + t.string :proposed_contact_name + t.string :proposed_contact_email + t.string :proposed_contact_phone + t.string :proposed_contact_website + t.jsonb :proposed_seasons + t.jsonb :proposed_video_urls + t.jsonb :proposed_location_uuids + + t.timestamps + end + + add_index :experience_suggestion_contributions, + [:experience_suggestion_id, :user_id], + unique: true, + name: "idx_exp_suggestion_contrib_unique_user" + end +end diff --git a/db/migrate/20260205223800_create_plan_suggestions.rb b/db/migrate/20260205223800_create_plan_suggestions.rb new file mode 100644 index 00000000..96d124ab --- /dev/null +++ b/db/migrate/20260205223800_create_plan_suggestions.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class CreatePlanSuggestions < ActiveRecord::Migration[8.0] + def change + create_table :plan_suggestions do |t| + t.references :plan, foreign_key: true # nil for create_resource + t.references :user, null: false, foreign_key: true + t.references :reviewed_by, foreign_key: { to_table: :users } + + t.integer :status, default: 0, null: false + t.integer :change_type, default: 0, null: false + t.integer :origin, default: 0, null: false + t.string :ai_service + t.datetime :reviewed_at + t.text :admin_notes + + # Typed proposed fields matching Plan attributes + t.string :proposed_title + t.text :proposed_notes + t.string :proposed_city_name + t.date :proposed_start_date + t.date :proposed_end_date + t.integer :proposed_visibility + t.jsonb :proposed_preferences, default: {} + t.jsonb :proposed_experience_days, default: {} + + t.timestamps + end + + # One pending suggestion per plan + add_index :plan_suggestions, :plan_id, + unique: true, + where: "status = 0 AND plan_id IS NOT NULL", + name: "idx_one_pending_per_plan" + end +end diff --git a/db/migrate/20260205223900_create_plan_suggestion_contributions.rb b/db/migrate/20260205223900_create_plan_suggestion_contributions.rb new file mode 100644 index 00000000..98ec8e36 --- /dev/null +++ b/db/migrate/20260205223900_create_plan_suggestion_contributions.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class CreatePlanSuggestionContributions < ActiveRecord::Migration[8.0] + def change + create_table :plan_suggestion_contributions do |t| + t.references :plan_suggestion, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.text :notes + + # Same typed proposed fields as PlanSuggestion + # Only non-nil fields = fields that this curator is changing + t.string :proposed_title + t.text :proposed_notes + t.string :proposed_city_name + t.date :proposed_start_date + t.date :proposed_end_date + t.integer :proposed_visibility + t.jsonb :proposed_preferences + t.jsonb :proposed_experience_days + + t.timestamps + end + + add_index :plan_suggestion_contributions, + [:plan_suggestion_id, :user_id], + unique: true, + name: "idx_plan_suggestion_contrib_unique_user" + end +end diff --git a/db/migrate/20260205224000_add_moderation_status_to_reviews.rb b/db/migrate/20260205224000_add_moderation_status_to_reviews.rb new file mode 100644 index 00000000..312582c4 --- /dev/null +++ b/db/migrate/20260205224000_add_moderation_status_to_reviews.rb @@ -0,0 +1,6 @@ +class AddModerationStatusToReviews < ActiveRecord::Migration[8.0] + def change + add_column :reviews, :moderation_status, :integer, default: 0, null: false + add_index :reviews, :moderation_status + end +end diff --git a/db/migrate/20260205224100_create_review_flags.rb b/db/migrate/20260205224100_create_review_flags.rb new file mode 100644 index 00000000..5234233e --- /dev/null +++ b/db/migrate/20260205224100_create_review_flags.rb @@ -0,0 +1,13 @@ +class CreateReviewFlags < ActiveRecord::Migration[8.0] + def change + create_table :review_flags do |t| + t.references :review, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.string :reason, null: false # spam, inappropriate, inaccurate, other + t.text :notes + t.timestamps + + t.index [ :review_id, :user_id ], unique: true # One flag per curator per review + end + end +end diff --git a/db/schema.rb b/db/schema.rb index b7e75f3b..3b49e08e 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_02_03_150742) do +ActiveRecord::Schema[8.1].define(version: 2026_02_05_224100) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -233,6 +233,57 @@ t.index ["location_id"], name: "index_experience_locations_on_location_id" end + create_table "experience_suggestion_contributions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "experience_suggestion_id", null: false + t.text "notes" + t.string "proposed_contact_email" + t.string "proposed_contact_name" + t.string "proposed_contact_phone" + t.string "proposed_contact_website" + t.text "proposed_description" + t.integer "proposed_estimated_duration" + t.bigint "proposed_experience_category_id" + t.jsonb "proposed_location_uuids" + t.jsonb "proposed_seasons" + t.string "proposed_title" + t.jsonb "proposed_video_urls" + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["experience_suggestion_id", "user_id"], name: "idx_exp_suggestion_contrib_unique_user", unique: true + t.index ["experience_suggestion_id"], name: "idx_on_experience_suggestion_id_86aa4e8014" + t.index ["user_id"], name: "index_experience_suggestion_contributions_on_user_id" + end + + create_table "experience_suggestions", force: :cascade do |t| + t.text "admin_notes" + t.string "ai_service" + t.integer "change_type", default: 0, null: false + t.datetime "created_at", null: false + t.bigint "experience_id" + t.integer "origin", default: 0, null: false + t.string "proposed_contact_email" + t.string "proposed_contact_name" + t.string "proposed_contact_phone" + t.string "proposed_contact_website" + t.text "proposed_description" + t.integer "proposed_estimated_duration" + t.bigint "proposed_experience_category_id" + t.jsonb "proposed_location_uuids", default: [] + t.jsonb "proposed_seasons", default: [] + t.string "proposed_title" + t.jsonb "proposed_video_urls", default: [] + t.datetime "reviewed_at" + t.bigint "reviewed_by_id" + t.integer "status", default: 0, null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["experience_id"], name: "idx_one_pending_per_experience", unique: true, where: "((status = 0) AND (experience_id IS NOT NULL))" + t.index ["experience_id"], name: "index_experience_suggestions_on_experience_id" + t.index ["reviewed_by_id"], name: "index_experience_suggestions_on_reviewed_by_id" + t.index ["user_id"], name: "index_experience_suggestions_on_user_id" + end + create_table "experience_types", force: :cascade do |t| t.boolean "active", default: true t.string "color" @@ -345,6 +396,65 @@ t.index ["location_id"], name: "index_location_experience_types_on_location_id" end + create_table "location_suggestion_contributions", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "location_suggestion_id", null: false + t.text "notes" + t.integer "proposed_budget" + t.jsonb "proposed_category_ids" + t.string "proposed_city" + t.text "proposed_description" + t.string "proposed_email" + t.jsonb "proposed_experience_type_ids" + t.text "proposed_historical_context" + t.decimal "proposed_lat", precision: 10, scale: 6 + t.decimal "proposed_lng", precision: 10, scale: 6 + t.string "proposed_name" + t.string "proposed_phone" + t.jsonb "proposed_social_links" + t.jsonb "proposed_tags" + t.jsonb "proposed_video_urls" + t.string "proposed_website" + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["location_suggestion_id", "user_id"], name: "idx_loc_suggestion_contrib_unique_user", unique: true + t.index ["location_suggestion_id"], name: "idx_on_location_suggestion_id_bc9b5ee6b7" + t.index ["user_id"], name: "index_location_suggestion_contributions_on_user_id" + end + + create_table "location_suggestions", force: :cascade do |t| + t.text "admin_notes" + t.string "ai_service" + t.integer "change_type", default: 0, null: false + t.datetime "created_at", null: false + t.bigint "location_id" + t.integer "origin", default: 0, null: false + t.integer "proposed_budget" + t.jsonb "proposed_category_ids", default: [] + t.string "proposed_city" + t.text "proposed_description" + t.string "proposed_email" + t.jsonb "proposed_experience_type_ids", default: [] + t.text "proposed_historical_context" + t.decimal "proposed_lat", precision: 10, scale: 6 + t.decimal "proposed_lng", precision: 10, scale: 6 + t.string "proposed_name" + t.string "proposed_phone" + t.jsonb "proposed_social_links", default: {} + t.jsonb "proposed_tags", default: [] + t.jsonb "proposed_video_urls", default: [] + t.string "proposed_website" + t.datetime "reviewed_at" + t.bigint "reviewed_by_id" + t.integer "status", default: 0, null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["location_id"], name: "idx_one_pending_per_location", unique: true, where: "((status = 0) AND (location_id IS NOT NULL))" + t.index ["location_id"], name: "index_location_suggestions_on_location_id" + t.index ["reviewed_by_id"], name: "index_location_suggestions_on_reviewed_by_id" + t.index ["user_id"], name: "index_location_suggestions_on_user_id" + end + create_table "locations", force: :cascade do |t| t.boolean "ai_generated", default: false, null: false t.jsonb "audio_tour_metadata" @@ -413,6 +523,51 @@ t.index ["plan_id"], name: "index_plan_experiences_on_plan_id" end + create_table "plan_suggestion_contributions", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "notes" + t.bigint "plan_suggestion_id", null: false + t.string "proposed_city_name" + t.date "proposed_end_date" + t.jsonb "proposed_experience_days" + t.text "proposed_notes" + t.jsonb "proposed_preferences" + t.date "proposed_start_date" + t.string "proposed_title" + t.integer "proposed_visibility" + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["plan_suggestion_id", "user_id"], name: "idx_plan_suggestion_contrib_unique_user", unique: true + t.index ["plan_suggestion_id"], name: "index_plan_suggestion_contributions_on_plan_suggestion_id" + t.index ["user_id"], name: "index_plan_suggestion_contributions_on_user_id" + end + + create_table "plan_suggestions", force: :cascade do |t| + t.text "admin_notes" + t.string "ai_service" + t.integer "change_type", default: 0, null: false + t.datetime "created_at", null: false + t.integer "origin", default: 0, null: false + t.bigint "plan_id" + t.string "proposed_city_name" + t.date "proposed_end_date" + t.jsonb "proposed_experience_days", default: {} + t.text "proposed_notes" + t.jsonb "proposed_preferences", default: {} + t.date "proposed_start_date" + t.string "proposed_title" + t.integer "proposed_visibility" + t.datetime "reviewed_at" + t.bigint "reviewed_by_id" + t.integer "status", default: 0, null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["plan_id"], name: "idx_one_pending_per_plan", unique: true, where: "((status = 0) AND (plan_id IS NOT NULL))" + t.index ["plan_id"], name: "index_plan_suggestions_on_plan_id" + t.index ["reviewed_by_id"], name: "index_plan_suggestions_on_reviewed_by_id" + t.index ["user_id"], name: "index_plan_suggestions_on_user_id" + end + create_table "plans", force: :cascade do |t| t.boolean "ai_generated", default: false, null: false t.decimal "average_rating", precision: 3, scale: 2, default: "0.0" @@ -445,16 +600,30 @@ t.index ["visibility"], name: "index_plans_on_visibility" end + create_table "review_flags", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "notes" + t.string "reason", null: false + t.bigint "review_id", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["review_id", "user_id"], name: "index_review_flags_on_review_id_and_user_id", unique: true + t.index ["review_id"], name: "index_review_flags_on_review_id" + t.index ["user_id"], name: "index_review_flags_on_user_id" + end + create_table "reviews", force: :cascade do |t| t.string "author_name" t.text "comment" t.datetime "created_at", null: false + t.integer "moderation_status", default: 0, null: false t.integer "rating", null: false t.bigint "reviewable_id", null: false t.string "reviewable_type", null: false t.datetime "updated_at", null: false t.bigint "user_id" t.string "uuid", limit: 36, null: false + t.index ["moderation_status"], name: "index_reviews_on_moderation_status" t.index ["rating"], name: "index_reviews_on_rating" t.index ["reviewable_type", "reviewable_id", "created_at"], name: "index_reviews_on_reviewable_and_created_at" t.index ["reviewable_type", "reviewable_id"], name: "index_reviews_on_reviewable" @@ -522,16 +691,33 @@ add_foreign_key "experience_category_types", "experience_types" add_foreign_key "experience_locations", "experiences" add_foreign_key "experience_locations", "locations" + add_foreign_key "experience_suggestion_contributions", "experience_suggestions" + add_foreign_key "experience_suggestion_contributions", "users" + add_foreign_key "experience_suggestions", "experiences" + add_foreign_key "experience_suggestions", "users" + add_foreign_key "experience_suggestions", "users", column: "reviewed_by_id" add_foreign_key "experiences", "experience_categories" add_foreign_key "location_category_assignments", "location_categories" add_foreign_key "location_category_assignments", "locations" add_foreign_key "location_experience_types", "experience_types" add_foreign_key "location_experience_types", "locations" + add_foreign_key "location_suggestion_contributions", "location_suggestions" + add_foreign_key "location_suggestion_contributions", "users" + add_foreign_key "location_suggestions", "locations" + add_foreign_key "location_suggestions", "users" + add_foreign_key "location_suggestions", "users", column: "reviewed_by_id" add_foreign_key "photo_suggestions", "locations" add_foreign_key "photo_suggestions", "users" add_foreign_key "photo_suggestions", "users", column: "reviewed_by_id" add_foreign_key "plan_experiences", "experiences" add_foreign_key "plan_experiences", "plans" + add_foreign_key "plan_suggestion_contributions", "plan_suggestions" + add_foreign_key "plan_suggestion_contributions", "users" + add_foreign_key "plan_suggestions", "plans" + add_foreign_key "plan_suggestions", "users" + add_foreign_key "plan_suggestions", "users", column: "reviewed_by_id" add_foreign_key "plans", "users" + add_foreign_key "review_flags", "reviews" + add_foreign_key "review_flags", "users" add_foreign_key "reviews", "users" end diff --git a/test/controllers/curator/admin/reviews_controller_test.rb b/test/controllers/curator/admin/reviews_controller_test.rb new file mode 100644 index 00000000..e82c32c1 --- /dev/null +++ b/test/controllers/curator/admin/reviews_controller_test.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "test_helper" + +module Curator + module Admin + class ReviewsControllerTest < ActionDispatch::IntegrationTest + setup do + @admin = users(:admin) + @curator = users(:one) + @location = locations(:one) + @review = Review.create!( + reviewable: @location, + rating: 4, + comment: "Great place!", + author_name: "Test User" + ) + login_as @admin + end + + def login_as(user) + post login_path, params: { + username: user.username, + password: "password123" + } + end + + test "should redirect non-admin users" do + login_as @curator + get curator_admin_reviews_path + assert_redirected_to curator_root_path + end + + test "should get index" do + get curator_admin_reviews_path + assert_response :success + assert_select "h1", text: "Moderacija recenzija" + end + + test "should show stats on index" do + get curator_admin_reviews_path + assert_response :success + # Check that stats are displayed (looking for the stat value) + assert_select "dd", minimum: 1 + end + + test "should filter by moderation_status" do + @review.update!(moderation_status: :flagged) + get curator_admin_reviews_path(moderation_status: :flagged) + assert_response :success + end + + test "should filter by type" do + get curator_admin_reviews_path(type: "Location") + assert_response :success + end + + test "should search reviews" do + get curator_admin_reviews_path(search: "Great") + assert_response :success + end + + test "should show review" do + get curator_admin_review_path(@review) + assert_response :success + assert_select "h1", text: "Recenzija" + end + + test "should approve review" do + assert_changes -> { @review.reload.moderation_status }, from: "unreviewed", to: "approved" do + post approve_curator_admin_review_path(@review) + end + assert_redirected_to curator_admin_reviews_path + assert_equal "Recenzija odobrena.", flash[:notice] + end + + test "should record activity when approving" do + assert_difference "CuratorActivity.count", 1 do + post approve_curator_admin_review_path(@review) + end + activity = CuratorActivity.last + assert_equal "approve_review", activity.action + assert_equal @review, activity.recordable + end + + test "should remove review" do + assert_changes -> { @review.reload.moderation_status }, from: "unreviewed", to: "removed" do + post remove_curator_admin_review_path(@review) + end + assert_redirected_to curator_admin_reviews_path + assert_equal "Recenzija uklonjena.", flash[:notice] + end + + test "should record activity when removing" do + assert_difference "CuratorActivity.count", 1 do + post remove_curator_admin_review_path(@review) + end + activity = CuratorActivity.last + assert_equal "remove_review", activity.action + assert_equal @review, activity.recordable + end + end + end +end diff --git a/test/controllers/curator/admin_direct_crud_test.rb b/test/controllers/curator/admin_direct_crud_test.rb new file mode 100644 index 00000000..182a25e9 --- /dev/null +++ b/test/controllers/curator/admin_direct_crud_test.rb @@ -0,0 +1,679 @@ +# frozen_string_literal: true + +require "test_helper" + +class Curator::AdminDirectCrudTest < ActionDispatch::IntegrationTest + setup do + @admin = User.create!( + username: "admin_#{SecureRandom.hex(4)}", + password: "password123", + user_type: :admin + ) + + @admin_without_flag = User.create!( + username: "admin_no_flag_#{SecureRandom.hex(4)}", + password: "password123", + user_type: :admin + ) + + @curator = User.create!( + username: "curator_#{SecureRandom.hex(4)}", + password: "password123", + user_type: :curator + ) + + # Enable the Flipper flag for @admin only + Flipper.enable_actor(:curator_edit_delete, @admin) + + # Create existing resources for update/delete tests + @location = Location.create!( + name: "Test Location", + description: "Test description", + city: "Sarajevo", + lat: 43.8563, + lng: 18.4131, + location_type: :place + ) + + @experience = Experience.create!( + title: "Test Experience", + description: "Test experience description", + experience_category: ExperienceCategory.first || ExperienceCategory.create!( + key: "culture", + name: "Culture", + active: true, + position: 1 + ) + ) + + @plan = Plan.create!( + title: "Test Plan", + city_name: "Sarajevo", + visibility: :public_plan, + user: @curator + ) + + # Create location category for tests + @category = LocationCategory.find_or_create_by!(key: "attraction") do |c| + c.name = "Attraction" + c.active = true + c.position = 1 + end + end + + teardown do + # Clean up content changes and activities first (foreign keys) + ContentChange.where(user: [ @admin, @admin_without_flag, @curator ]).destroy_all + ContentChange.where(changeable: [ @location, @experience, @plan ]).destroy_all + CuratorActivity.where(user: [ @admin, @admin_without_flag, @curator ]).destroy_all + + # Clean up resources + @location&.destroy + @experience&.destroy + @plan&.destroy + + # Clean up users + @admin&.destroy + @admin_without_flag&.destroy + @curator&.destroy + + # Disable Flipper flag + Flipper.disable(:curator_edit_delete) + end + + # ========================================================================== + # LocationsController - Admin Direct CRUD Tests + # ========================================================================== + + test "admin with flag creates location directly without proposal" do + login_as(@admin) + + assert_no_difference "ContentChange.count" do + assert_difference "Location.count", 1 do + post curator_locations_path, params: { + location: { + name: "Admin Direct Location", + description: "Created directly by admin", + city: "Mostar", + lat: 43.3438, + lng: 17.8078, + location_type: "place" + } + } + end + end + + assert_redirected_to curator_location_path(Location.last) + follow_redirect! + assert_response :success + + # Verify location was created + location = Location.last + assert_equal "Admin Direct Location", location.name + assert_equal "Mostar", location.city + + # Verify CuratorActivity was recorded with correct action + activity = CuratorActivity.last + assert_equal "resource_created", activity.action + assert_equal @admin, activity.user + assert_equal location, activity.recordable + assert_equal "Location", activity.metadata["type"] + assert_equal "Admin Direct Location", activity.metadata["name"] + + # Clean up + location.destroy + end + + test "admin with flag updates location directly without proposal" do + login_as(@admin) + original_name = @location.name + + assert_no_difference "ContentChange.count" do + patch curator_location_path(@location), params: { + location: { + name: "Updated by Admin", + description: "Updated description" + } + } + end + + assert_redirected_to curator_location_path(@location) + + # Verify location was updated directly + @location.reload + assert_equal "Updated by Admin", @location.name + assert_equal "Updated description", @location.description + assert_not_equal original_name, @location.name + + # Verify CuratorActivity was recorded + activity = CuratorActivity.last + assert_equal "resource_updated", activity.action + assert_equal @admin, activity.user + assert_equal @location, activity.recordable + end + + test "admin with flag deletes location directly without proposal" do + login_as(@admin) + location_to_delete = Location.create!( + name: "To Delete", + city: "Zenica", + lat: 44.2037, + lng: 17.9078, + location_type: :place + ) + + assert_no_difference "ContentChange.count" do + assert_difference "Location.count", -1 do + delete curator_location_path(location_to_delete) + end + end + + assert_redirected_to curator_locations_path + + # Verify CuratorActivity was recorded + activity = CuratorActivity.last + assert_equal "resource_deleted", activity.action + assert_equal @admin, activity.user + assert_nil activity.recordable # Resource deleted, so nil + assert_equal "Location", activity.metadata["type"] + assert_equal "To Delete", activity.metadata["name"] + end + + # ========================================================================== + # ExperiencesController - Admin Direct CRUD Tests + # ========================================================================== + + test "admin with flag creates experience directly without proposal" do + login_as(@admin) + + assert_no_difference "ContentChange.count" do + assert_difference "Experience.count", 1 do + post curator_experiences_path, params: { + experience: { + title: "Admin Direct Experience", + description: "Created directly by admin", + experience_category_id: @experience.experience_category_id + } + } + end + end + + assert_redirected_to curator_experience_path(Experience.last) + + # Verify experience was created + experience = Experience.last + assert_equal "Admin Direct Experience", experience.title + + # Verify CuratorActivity + activity = CuratorActivity.last + assert_equal "resource_created", activity.action + assert_equal @admin, activity.user + assert_equal experience, activity.recordable + assert_equal "Experience", activity.metadata["type"] + + # Clean up + experience.destroy + end + + test "admin with flag updates experience directly without proposal" do + login_as(@admin) + + assert_no_difference "ContentChange.count" do + patch curator_experience_path(@experience), params: { + experience: { + title: "Updated Experience by Admin", + description: "Updated by admin" + } + } + end + + assert_redirected_to curator_experience_path(@experience) + + # Verify experience was updated directly + @experience.reload + assert_equal "Updated Experience by Admin", @experience.title + + # Verify CuratorActivity + activity = CuratorActivity.last + assert_equal "resource_updated", activity.action + assert_equal @admin, activity.user + assert_equal @experience, activity.recordable + end + + test "admin with flag deletes experience directly without proposal" do + login_as(@admin) + experience_to_delete = Experience.create!( + title: "Experience to Delete", + description: "Will be deleted", + experience_category: @experience.experience_category + ) + + assert_no_difference "ContentChange.count" do + assert_difference "Experience.count", -1 do + delete curator_experience_path(experience_to_delete) + end + end + + assert_redirected_to curator_experiences_path + + # Verify CuratorActivity + activity = CuratorActivity.last + assert_equal "resource_deleted", activity.action + assert_equal @admin, activity.user + assert_nil activity.recordable + assert_equal "Experience", activity.metadata["type"] + assert_equal "Experience to Delete", activity.metadata["title"] + end + + # ========================================================================== + # PlansController - Admin Direct CRUD Tests + # ========================================================================== + + test "admin with flag creates plan directly without proposal" do + login_as(@admin) + + assert_no_difference "ContentChange.count" do + assert_difference "Plan.count", 1 do + post curator_plans_path, params: { + plan: { + title: "Admin Direct Plan", + city_name: "Tuzla", + visibility: "public_plan" + } + } + end + end + + assert_redirected_to curator_plan_path(Plan.last) + + # Verify plan was created + plan = Plan.last + assert_equal "Admin Direct Plan", plan.title + assert_equal "Tuzla", plan.city_name + + # Verify CuratorActivity + activity = CuratorActivity.last + assert_equal "resource_created", activity.action + assert_equal @admin, activity.user + assert_equal plan, activity.recordable + assert_equal "Plan", activity.metadata["type"] + + # Clean up + plan.destroy + end + + test "admin with flag updates plan directly without proposal" do + login_as(@admin) + + assert_no_difference "ContentChange.count" do + patch curator_plan_path(@plan), params: { + plan: { + title: "Updated Plan by Admin", + city_name: "Updated City" + } + } + end + + assert_redirected_to curator_plan_path(@plan) + + # Verify plan was updated directly + @plan.reload + assert_equal "Updated Plan by Admin", @plan.title + + # Verify CuratorActivity + activity = CuratorActivity.last + assert_equal "resource_updated", activity.action + assert_equal @admin, activity.user + assert_equal @plan, activity.recordable + end + + test "admin with flag deletes plan directly without proposal" do + login_as(@admin) + plan_to_delete = Plan.create!( + title: "Plan to Delete", + city_name: "Banja Luka", + visibility: :public_plan, + user: @admin + ) + + assert_no_difference "ContentChange.count" do + assert_difference "Plan.count", -1 do + delete curator_plan_path(plan_to_delete) + end + end + + assert_redirected_to curator_plans_path + + # Verify CuratorActivity + activity = CuratorActivity.last + assert_equal "resource_deleted", activity.action + assert_equal @admin, activity.user + assert_nil activity.recordable + assert_equal "Plan", activity.metadata["type"] + assert_equal "Plan to Delete", activity.metadata["title"] + end + + # ========================================================================== + # Curator still creates proposals (flag disabled) + # ========================================================================== + + test "curator without flag still creates location proposals" do + login_as(@curator) + + assert_difference "ContentChange.count", 1 do + assert_no_difference "Location.count" do + post curator_locations_path, params: { + location: { + name: "Curator Proposed Location", + city: "Bijeljina", + lat: 44.7597, + lng: 19.2144, + location_type: "place" + } + } + end + end + + assert_redirected_to curator_locations_path + + # Verify proposal was created + proposal = ContentChange.last + assert_equal "create_content", proposal.change_type + assert_equal "Location", proposal.changeable_class + assert_equal @curator, proposal.user + + # Verify CuratorActivity + activity = CuratorActivity.last + assert_equal "proposal_created", activity.action + assert_equal @curator, activity.user + assert_equal proposal, activity.recordable + + # Clean up + proposal.destroy + end + + test "curator without flag still creates update proposals for locations" do + login_as(@curator) + + assert_difference "ContentChange.count", 1 do + patch curator_location_path(@location), params: { + location: { + name: "Curator Updated Name" + } + } + end + + assert_redirected_to curator_location_path(@location) + + # Verify location was NOT updated + @location.reload + assert_not_equal "Curator Updated Name", @location.name + + # Verify proposal was created + proposal = ContentChange.last + assert_equal "update_content", proposal.change_type + assert_equal @location, proposal.changeable + + # Verify CuratorActivity + activity = CuratorActivity.last + assert_includes [ "proposal_updated", "proposal_contributed" ], activity.action + + # Clean up + proposal.destroy + end + + test "curator without flag still creates delete proposals for locations" do + login_as(@curator) + + assert_difference "ContentChange.count", 1 do + assert_no_difference "Location.count" do + delete curator_location_path(@location) + end + end + + assert_redirected_to curator_locations_path + + # Verify location was NOT deleted + assert Location.exists?(@location.id) + + # Verify delete proposal was created + proposal = ContentChange.last + assert_equal "delete_content", proposal.change_type + assert_equal @location, proposal.changeable + + # Verify CuratorActivity + activity = CuratorActivity.last + assert_equal "proposal_deleted", activity.action + + # Clean up + proposal.destroy + end + + test "curator without flag still creates experience proposals" do + login_as(@curator) + + assert_difference "ContentChange.count", 1 do + assert_no_difference "Experience.count" do + post curator_experiences_path, params: { + experience: { + title: "Curator Proposed Experience", + description: "Proposed by curator", + experience_category_id: @experience.experience_category_id + } + } + end + end + + proposal = ContentChange.last + assert_equal "create_content", proposal.change_type + assert_equal "Experience", proposal.changeable_class + + # Clean up + proposal.destroy + end + + test "curator without flag still creates plan proposals" do + login_as(@curator) + + assert_difference "ContentChange.count", 1 do + assert_no_difference "Plan.count" do + post curator_plans_path, params: { + plan: { + title: "Curator Proposed Plan", + city_name: "Mostar", + visibility: "public_plan" + } + } + end + end + + proposal = ContentChange.last + assert_equal "create_content", proposal.change_type + assert_equal "Plan", proposal.changeable_class + + # Clean up + proposal.destroy + end + + # ========================================================================== + # Admin without flag creates proposals + # ========================================================================== + + test "admin without flag still creates location proposals" do + login_as(@admin_without_flag) + + assert_difference "ContentChange.count", 1 do + assert_no_difference "Location.count" do + post curator_locations_path, params: { + location: { + name: "Admin No Flag Proposed Location", + city: "Prijedor", + lat: 44.9799, + lng: 16.7089, + location_type: "place" + } + } + end + end + + proposal = ContentChange.last + assert_equal "create_content", proposal.change_type + assert_equal @admin_without_flag, proposal.user + + # Verify CuratorActivity + activity = CuratorActivity.last + assert_equal "proposal_created", activity.action + + # Clean up + proposal.destroy + end + + test "admin without flag still creates update proposals" do + login_as(@admin_without_flag) + + assert_difference "ContentChange.count", 1 do + patch curator_location_path(@location), params: { + location: { + name: "Admin No Flag Update" + } + } + end + + # Verify location was NOT updated + @location.reload + assert_not_equal "Admin No Flag Update", @location.name + + proposal = ContentChange.last + assert_equal "update_content", proposal.change_type + + # Clean up + proposal.destroy + end + + test "admin without flag still creates delete proposals" do + login_as(@admin_without_flag) + + assert_difference "ContentChange.count", 1 do + assert_no_difference "Location.count" do + delete curator_location_path(@location) + end + end + + # Verify location was NOT deleted + assert Location.exists?(@location.id) + + proposal = ContentChange.last + assert_equal "delete_content", proposal.change_type + + # Clean up + proposal.destroy + end + + test "admin without flag still creates experience proposals" do + login_as(@admin_without_flag) + + assert_difference "ContentChange.count", 1 do + assert_no_difference "Experience.count" do + post curator_experiences_path, params: { + experience: { + title: "Admin No Flag Experience", + description: "Proposed", + experience_category_id: @experience.experience_category_id + } + } + end + end + + proposal = ContentChange.last + assert_equal "create_content", proposal.change_type + assert_equal @admin_without_flag, proposal.user + + # Clean up + proposal.destroy + end + + test "admin without flag still creates plan proposals" do + login_as(@admin_without_flag) + + assert_difference "ContentChange.count", 1 do + assert_no_difference "Plan.count" do + post curator_plans_path, params: { + plan: { + title: "Admin No Flag Plan", + city_name: "Livno", + visibility: "public_plan" + } + } + end + end + + proposal = ContentChange.last + assert_equal "create_content", proposal.change_type + assert_equal @admin_without_flag, proposal.user + + # Clean up + proposal.destroy + end + + # ========================================================================== + # Edge Cases and Validation + # ========================================================================== + + test "admin direct create with invalid data renders form" do + login_as(@admin) + + assert_no_difference [ "Location.count", "ContentChange.count" ] do + post curator_locations_path, params: { + location: { + name: "", # Invalid - blank name + city: "Test" + } + } + end + + assert_response :unprocessable_entity + end + + test "admin direct update with invalid data renders form" do + login_as(@admin) + + patch curator_location_path(@location), params: { + location: { + name: "" # Invalid - blank name + } + } + + assert_response :unprocessable_entity + + # Verify location was not updated + @location.reload + assert_not_equal "", @location.name + end + + test "admin_direct_crud? helper method works correctly" do + # Admin with flag + login_as(@admin) + get curator_locations_path + assert_response :success + # The helper is called internally, we're just verifying no errors + + # Admin without flag + login_as(@admin_without_flag) + get curator_locations_path + assert_response :success + + # Curator + login_as(@curator) + get curator_locations_path + assert_response :success + end + + private + + def login_as(user) + post login_path, params: { + username: user.username, + password: "password123" + } + end +end diff --git a/test/controllers/curator/experience_suggestions_controller_test.rb b/test/controllers/curator/experience_suggestions_controller_test.rb new file mode 100644 index 00000000..685b904e --- /dev/null +++ b/test/controllers/curator/experience_suggestions_controller_test.rb @@ -0,0 +1,303 @@ +# frozen_string_literal: true + +require "test_helper" + +class Curator::ExperienceSuggestionsControllerTest < ActionDispatch::IntegrationTest + setup do + @curator = User.create!( + username: "test_curator_#{SecureRandom.hex(4)}", + password: "password123", + user_type: :curator + ) + @other_curator = User.create!( + username: "other_curator_#{SecureRandom.hex(4)}", + password: "password123", + user_type: :curator + ) + @basic_user = User.create!( + username: "basic_user_#{SecureRandom.hex(4)}", + password: "password123", + user_type: :basic + ) + @category = ExperienceCategory.create!(key: "adventure", name: "Adventure") + @experience = Experience.create!( + title: "Test Experience", + description: "Test description", + experience_category: @category + ) + end + + teardown do + ExperienceSuggestion.destroy_all + @experience&.destroy + @category&.destroy + @curator&.destroy + @other_curator&.destroy + @basic_user&.destroy + end + + # Authentication tests + test "new requires login" do + get new_curator_experience_experience_suggestion_path(@experience) + assert_redirected_to login_path + end + + test "new requires curator role" do + login_as(@basic_user) + get new_curator_experience_experience_suggestion_path(@experience) + assert_redirected_to root_path + end + + # New action tests + test "new creates pending suggestion and redirects to edit" do + login_as(@curator) + + assert_difference "ExperienceSuggestion.count", 1 do + get new_curator_experience_experience_suggestion_path(@experience) + end + + suggestion = ExperienceSuggestion.last + assert_redirected_to edit_curator_experience_experience_suggestion_path(@experience, suggestion) + assert_equal @curator, suggestion.user + assert_equal @experience, suggestion.experience + assert_equal "pending", suggestion.status + assert_equal "update_resource", suggestion.change_type + assert_equal "human", suggestion.origin + end + + test "new finds existing pending suggestion instead of creating new one" do + login_as(@curator) + + # Create existing suggestion + existing = ExperienceSuggestion.create!( + experience: @experience, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human + ) + + assert_no_difference "ExperienceSuggestion.count" do + get new_curator_experience_experience_suggestion_path(@experience) + end + + assert_redirected_to edit_curator_experience_experience_suggestion_path(@experience, existing) + end + + # Edit action tests + test "edit requires login" do + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human + ) + + get edit_curator_experience_experience_suggestion_path(@experience, suggestion) + assert_redirected_to login_path + end + + # Note: Skipping edit shows form test since views are created separately + + # Create action tests + test "create requires login" do + post curator_experience_experience_suggestions_path(@experience), params: { + experience_suggestion: { proposed_title: "New Title" } + } + assert_redirected_to login_path + end + + test "create by original creator updates suggestion directly" do + login_as(@curator) + + # Create pending suggestion + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human + ) + + post curator_experience_experience_suggestions_path(@experience), params: { + experience_suggestion: { + proposed_title: "Updated Title", + proposed_description: "Updated description", + proposed_seasons: ["summer", "autumn"] + } + } + + assert_redirected_to curator_experience_path(@experience) + assert_match "Prijedlog uspješno kreiran", flash[:notice] + + suggestion.reload + assert_equal "Updated Title", suggestion.proposed_title + assert_equal "Updated description", suggestion.proposed_description + assert_equal ["summer", "autumn"], suggestion.proposed_seasons + end + + test "create by different curator adds contribution" do + login_as(@curator) + + # Create pending suggestion by first curator + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human, + proposed_title: "Original Title" + ) + + # Other curator adds contribution + login_as(@other_curator) + + assert_difference "ExperienceSuggestionContribution.count", 1 do + post curator_experience_experience_suggestions_path(@experience), params: { + experience_suggestion: { + proposed_title: "Contributed Title", + contribution_notes: "I suggest this title instead" + } + } + end + + assert_redirected_to curator_experience_path(@experience) + assert_match "Doprinos prijedlogu uspješno dodan", flash[:notice] + + suggestion.reload + assert_equal "Contributed Title", suggestion.proposed_title + end + + test "create records curator activity for original creator" do + login_as(@curator) + + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human + ) + + assert_difference "CuratorActivity.count", 1 do + post curator_experience_experience_suggestions_path(@experience), params: { + experience_suggestion: { proposed_title: "Activity Test" } + } + end + + activity = CuratorActivity.last + assert_equal "suggestion_created", activity.action + assert_equal @curator, activity.user + assert_equal suggestion, activity.recordable + end + + test "create records curator activity for contributor" do + login_as(@curator) + + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human + ) + + login_as(@other_curator) + + assert_difference "CuratorActivity.count", 1 do + post curator_experience_experience_suggestions_path(@experience), params: { + experience_suggestion: { + proposed_title: "Contribution", + contribution_notes: "Note" + } + } + end + + activity = CuratorActivity.last + assert_equal "suggestion_contributed", activity.action + assert_equal @other_curator, activity.user + end + + # Update action tests + test "update requires login" do + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human + ) + + patch curator_experience_experience_suggestion_path(@experience, suggestion), params: { + experience_suggestion: { proposed_title: "New Title" } + } + assert_redirected_to login_path + end + + test "update by original creator updates suggestion directly" do + login_as(@curator) + + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human, + proposed_title: "Original" + ) + + patch curator_experience_experience_suggestion_path(@experience, suggestion), params: { + experience_suggestion: { + proposed_title: "Updated via PATCH", + proposed_contact_name: "John Doe" + } + } + + assert_redirected_to curator_experience_path(@experience) + assert_match "Prijedlog ažuriran", flash[:notice] + + suggestion.reload + assert_equal "Updated via PATCH", suggestion.proposed_title + assert_equal "John Doe", suggestion.proposed_contact_name + end + + test "update by different curator adds contribution" do + login_as(@curator) + + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human, + proposed_title: "Original" + ) + + login_as(@other_curator) + + assert_difference "ExperienceSuggestionContribution.count", 1 do + patch curator_experience_experience_suggestion_path(@experience, suggestion), params: { + experience_suggestion: { + proposed_title: "Contributed via PATCH", + contribution_notes: "Better title" + } + } + end + + assert_redirected_to curator_experience_path(@experience) + assert_match "Doprinos prijedlogu uspješno dodan", flash[:notice] + + suggestion.reload + assert_equal "Contributed via PATCH", suggestion.proposed_title + end + + private + + def login_as(user) + post login_path, params: { + username: user.username, + password: "password123" + } + end +end diff --git a/test/controllers/curator/location_suggestions_controller_test.rb b/test/controllers/curator/location_suggestions_controller_test.rb new file mode 100644 index 00000000..1c311c86 --- /dev/null +++ b/test/controllers/curator/location_suggestions_controller_test.rb @@ -0,0 +1,301 @@ +# frozen_string_literal: true + +require "test_helper" + +class Curator::LocationSuggestionsControllerTest < ActionDispatch::IntegrationTest + setup do + @curator = User.create!( + username: "test_curator_#{SecureRandom.hex(4)}", + password: "password123", + user_type: :curator + ) + @other_curator = User.create!( + username: "other_curator_#{SecureRandom.hex(4)}", + password: "password123", + user_type: :curator + ) + @basic_user = User.create!( + username: "basic_user_#{SecureRandom.hex(4)}", + password: "password123", + user_type: :basic + ) + @location = Location.create!( + name: "Test Location", + city: "Sarajevo", + lat: 43.8563, + lng: 18.4131, + location_type: :place + ) + end + + teardown do + LocationSuggestion.destroy_all + @location&.destroy + @curator&.destroy + @other_curator&.destroy + @basic_user&.destroy + end + + # Authentication tests + test "new requires login" do + get new_curator_location_location_suggestion_path(@location) + assert_redirected_to login_path + end + + test "new requires curator role" do + login_as(@basic_user) + get new_curator_location_location_suggestion_path(@location) + assert_redirected_to root_path + end + + # New action tests + test "new creates pending suggestion and redirects to edit" do + login_as(@curator) + + assert_difference "LocationSuggestion.count", 1 do + get new_curator_location_location_suggestion_path(@location) + end + + suggestion = LocationSuggestion.last + assert_redirected_to edit_curator_location_location_suggestion_path(@location, suggestion) + assert_equal @curator, suggestion.user + assert_equal @location, suggestion.location + assert_equal "pending", suggestion.status + assert_equal "update_resource", suggestion.change_type + assert_equal "human", suggestion.origin + end + + test "new finds existing pending suggestion instead of creating new one" do + login_as(@curator) + + # Create existing suggestion + existing = LocationSuggestion.create!( + location: @location, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human + ) + + assert_no_difference "LocationSuggestion.count" do + get new_curator_location_location_suggestion_path(@location) + end + + assert_redirected_to edit_curator_location_location_suggestion_path(@location, existing) + end + + # Edit action tests + test "edit requires login" do + suggestion = LocationSuggestion.create!( + location: @location, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human + ) + + get edit_curator_location_location_suggestion_path(@location, suggestion) + assert_redirected_to login_path + end + + # Note: Skipping edit shows form test since views are created separately + + # Create action tests + test "create requires login" do + post curator_location_location_suggestions_path(@location), params: { + location_suggestion: { proposed_name: "New Name" } + } + assert_redirected_to login_path + end + + test "create by original creator updates suggestion directly" do + login_as(@curator) + + # Create pending suggestion + suggestion = LocationSuggestion.create!( + location: @location, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human + ) + + post curator_location_location_suggestions_path(@location), params: { + location_suggestion: { + proposed_name: "Updated Name", + proposed_description: "Updated description" + } + } + + assert_redirected_to curator_location_path(@location) + assert_match "Prijedlog uspješno kreiran", flash[:notice] + + suggestion.reload + assert_equal "Updated Name", suggestion.proposed_name + assert_equal "Updated description", suggestion.proposed_description + end + + test "create by different curator adds contribution" do + login_as(@curator) + + # Create pending suggestion by first curator + suggestion = LocationSuggestion.create!( + location: @location, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human, + proposed_name: "Original Name" + ) + + # Other curator adds contribution + login_as(@other_curator) + + assert_difference "LocationSuggestionContribution.count", 1 do + post curator_location_location_suggestions_path(@location), params: { + location_suggestion: { + proposed_name: "Contributed Name", + contribution_notes: "I suggest this name instead" + } + } + end + + assert_redirected_to curator_location_path(@location) + assert_match "Doprinos prijedlogu uspješno dodan", flash[:notice] + + suggestion.reload + assert_equal "Contributed Name", suggestion.proposed_name + end + + test "create records curator activity for original creator" do + login_as(@curator) + + suggestion = LocationSuggestion.create!( + location: @location, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human + ) + + assert_difference "CuratorActivity.count", 1 do + post curator_location_location_suggestions_path(@location), params: { + location_suggestion: { proposed_name: "Activity Test" } + } + end + + activity = CuratorActivity.last + assert_equal "suggestion_created", activity.action + assert_equal @curator, activity.user + assert_equal suggestion, activity.recordable + end + + test "create records curator activity for contributor" do + login_as(@curator) + + suggestion = LocationSuggestion.create!( + location: @location, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human + ) + + login_as(@other_curator) + + assert_difference "CuratorActivity.count", 1 do + post curator_location_location_suggestions_path(@location), params: { + location_suggestion: { + proposed_name: "Contribution", + contribution_notes: "Note" + } + } + end + + activity = CuratorActivity.last + assert_equal "suggestion_contributed", activity.action + assert_equal @other_curator, activity.user + end + + # Update action tests + test "update requires login" do + suggestion = LocationSuggestion.create!( + location: @location, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human + ) + + patch curator_location_location_suggestion_path(@location, suggestion), params: { + location_suggestion: { proposed_name: "New Name" } + } + assert_redirected_to login_path + end + + test "update by original creator updates suggestion directly" do + login_as(@curator) + + suggestion = LocationSuggestion.create!( + location: @location, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human, + proposed_name: "Original" + ) + + patch curator_location_location_suggestion_path(@location, suggestion), params: { + location_suggestion: { + proposed_name: "Updated via PATCH", + proposed_city: "Mostar" + } + } + + assert_redirected_to curator_location_path(@location) + assert_match "Prijedlog ažuriran", flash[:notice] + + suggestion.reload + assert_equal "Updated via PATCH", suggestion.proposed_name + assert_equal "Mostar", suggestion.proposed_city + end + + test "update by different curator adds contribution" do + login_as(@curator) + + suggestion = LocationSuggestion.create!( + location: @location, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human, + proposed_name: "Original" + ) + + login_as(@other_curator) + + assert_difference "LocationSuggestionContribution.count", 1 do + patch curator_location_location_suggestion_path(@location, suggestion), params: { + location_suggestion: { + proposed_name: "Contributed via PATCH", + contribution_notes: "Better name" + } + } + end + + assert_redirected_to curator_location_path(@location) + assert_match "Doprinos prijedlogu uspješno dodan", flash[:notice] + + suggestion.reload + assert_equal "Contributed via PATCH", suggestion.proposed_name + end + + private + + def login_as(user) + post login_path, params: { + username: user.username, + password: "password123" + } + end +end diff --git a/test/controllers/curator/locations_controller_test.rb b/test/controllers/curator/locations_controller_test.rb index a6e6d02f..5dc19737 100644 --- a/test/controllers/curator/locations_controller_test.rb +++ b/test/controllers/curator/locations_controller_test.rb @@ -661,6 +661,73 @@ class Curator::LocationsControllerTest < ActionDispatch::IntegrationTest ContentChange.last.destroy end + # ========================================================================== + # Audio Tour Generation Tests + # ========================================================================== + + test "generate_audio_tour requires admin" do + login_as(@curator) + post generate_audio_tour_curator_location_path(@location), params: { locale: "bs" } + assert_redirected_to curator_location_path(@location) + follow_redirect! + assert_match "Samo admin može generisati audio ture.", response.body + end + + test "admin can trigger audio tour generation" do + login_as(@admin) + + assert_enqueued_with(job: AudioTourGenerateJob) do + post generate_audio_tour_curator_location_path(@location), params: { locale: "en" } + end + + assert_redirected_to curator_location_path(@location) + follow_redirect! + assert_match "Audio tura za EN se generise u pozadini.", response.body + end + + test "generate_audio_tour records curator activity" do + login_as(@admin) + + assert_difference "CuratorActivity.count", 1 do + post generate_audio_tour_curator_location_path(@location), params: { locale: "bs" } + end + + activity = CuratorActivity.last + assert_equal "audio_tour_generation_requested", activity.action + assert_equal @admin, activity.user + assert_equal @location, activity.recordable + assert_equal "bs", activity.metadata["locale"] + end + + test "generate_audio_tour prevents duplicate requests within 10 minutes" do + login_as(@admin) + + # First request + CuratorActivity.record( + user: @admin, + action: "audio_tour_generation_requested", + recordable: @location, + metadata: { locale: "bs" } + ) + + # Second request within 10 minutes should be blocked + assert_no_enqueued_jobs do + post generate_audio_tour_curator_location_path(@location), params: { locale: "bs" } + end + + assert_redirected_to curator_location_path(@location) + follow_redirect! + assert_match "Generisanje je već pokrenuto", response.body + end + + test "generate_audio_tour uses bs locale by default" do + login_as(@admin) + + assert_enqueued_with(job: AudioTourGenerateJob, args: [ { location_id: @location.id, locale: "bs", requested_by_id: @admin.id } ]) do + post generate_audio_tour_curator_location_path(@location) + end + end + # ========================================================================== # Edge Cases # ========================================================================== diff --git a/test/controllers/curator/plan_suggestions_controller_test.rb b/test/controllers/curator/plan_suggestions_controller_test.rb new file mode 100644 index 00000000..ea3db974 --- /dev/null +++ b/test/controllers/curator/plan_suggestions_controller_test.rb @@ -0,0 +1,302 @@ +# frozen_string_literal: true + +require "test_helper" + +class Curator::PlanSuggestionsControllerTest < ActionDispatch::IntegrationTest + setup do + @curator = User.create!( + username: "test_curator_#{SecureRandom.hex(4)}", + password: "password123", + user_type: :curator + ) + @other_curator = User.create!( + username: "other_curator_#{SecureRandom.hex(4)}", + password: "password123", + user_type: :curator + ) + @basic_user = User.create!( + username: "basic_user_#{SecureRandom.hex(4)}", + password: "password123", + user_type: :basic + ) + @plan = Plan.create!( + user: @curator, + title: "Test Plan", + city_name: "Sarajevo", + visibility: :public_plan + ) + end + + teardown do + PlanSuggestion.destroy_all + @plan&.destroy + @curator&.destroy + @other_curator&.destroy + @basic_user&.destroy + end + + # Authentication tests + test "new requires login" do + get new_curator_plan_plan_suggestion_path(@plan) + assert_redirected_to login_path + end + + test "new requires curator role" do + login_as(@basic_user) + get new_curator_plan_plan_suggestion_path(@plan) + assert_redirected_to root_path + end + + # New action tests + test "new creates pending suggestion and redirects to edit" do + login_as(@curator) + + assert_difference "PlanSuggestion.count", 1 do + get new_curator_plan_plan_suggestion_path(@plan) + end + + suggestion = PlanSuggestion.last + assert_redirected_to edit_curator_plan_plan_suggestion_path(@plan, suggestion) + assert_equal @curator, suggestion.user + assert_equal @plan, suggestion.plan + assert_equal "pending", suggestion.status + assert_equal "update_resource", suggestion.change_type + assert_equal "human", suggestion.origin + end + + test "new finds existing pending suggestion instead of creating new one" do + login_as(@curator) + + # Create existing suggestion + existing = PlanSuggestion.create!( + plan: @plan, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human + ) + + assert_no_difference "PlanSuggestion.count" do + get new_curator_plan_plan_suggestion_path(@plan) + end + + assert_redirected_to edit_curator_plan_plan_suggestion_path(@plan, existing) + end + + # Edit action tests + test "edit requires login" do + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human + ) + + get edit_curator_plan_plan_suggestion_path(@plan, suggestion) + assert_redirected_to login_path + end + + # Note: Skipping edit shows form test since views are created separately + + # Create action tests + test "create requires login" do + post curator_plan_plan_suggestions_path(@plan), params: { + plan_suggestion: { proposed_title: "New Title" } + } + assert_redirected_to login_path + end + + test "create by original creator updates suggestion directly" do + login_as(@curator) + + # Create pending suggestion + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human + ) + + post curator_plan_plan_suggestions_path(@plan), params: { + plan_suggestion: { + proposed_title: "Updated Title", + proposed_notes: "Updated notes", + proposed_city_name: "Mostar" + } + } + + assert_redirected_to curator_plan_path(@plan) + assert_match "Prijedlog uspješno kreiran", flash[:notice] + + suggestion.reload + assert_equal "Updated Title", suggestion.proposed_title + assert_equal "Updated notes", suggestion.proposed_notes + assert_equal "Mostar", suggestion.proposed_city_name + end + + test "create by different curator adds contribution" do + login_as(@curator) + + # Create pending suggestion by first curator + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human, + proposed_title: "Original Title" + ) + + # Other curator adds contribution + login_as(@other_curator) + + assert_difference "PlanSuggestionContribution.count", 1 do + post curator_plan_plan_suggestions_path(@plan), params: { + plan_suggestion: { + proposed_title: "Contributed Title", + contribution_notes: "I suggest this title instead" + } + } + end + + assert_redirected_to curator_plan_path(@plan) + assert_match "Doprinos prijedlogu uspješno dodan", flash[:notice] + + suggestion.reload + assert_equal "Contributed Title", suggestion.proposed_title + end + + test "create records curator activity for original creator" do + login_as(@curator) + + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human + ) + + assert_difference "CuratorActivity.count", 1 do + post curator_plan_plan_suggestions_path(@plan), params: { + plan_suggestion: { proposed_title: "Activity Test" } + } + end + + activity = CuratorActivity.last + assert_equal "suggestion_created", activity.action + assert_equal @curator, activity.user + assert_equal suggestion, activity.recordable + end + + test "create records curator activity for contributor" do + login_as(@curator) + + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human + ) + + login_as(@other_curator) + + assert_difference "CuratorActivity.count", 1 do + post curator_plan_plan_suggestions_path(@plan), params: { + plan_suggestion: { + proposed_title: "Contribution", + contribution_notes: "Note" + } + } + end + + activity = CuratorActivity.last + assert_equal "suggestion_contributed", activity.action + assert_equal @other_curator, activity.user + end + + # Update action tests + test "update requires login" do + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human + ) + + patch curator_plan_plan_suggestion_path(@plan, suggestion), params: { + plan_suggestion: { proposed_title: "New Title" } + } + assert_redirected_to login_path + end + + test "update by original creator updates suggestion directly" do + login_as(@curator) + + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human, + proposed_title: "Original" + ) + + patch curator_plan_plan_suggestion_path(@plan, suggestion), params: { + plan_suggestion: { + proposed_title: "Updated via PATCH", + proposed_visibility: "private_plan" + } + } + + assert_redirected_to curator_plan_path(@plan) + assert_match "Prijedlog ažuriran", flash[:notice] + + suggestion.reload + assert_equal "Updated via PATCH", suggestion.proposed_title + assert_equal 0, suggestion.proposed_visibility + end + + test "update by different curator adds contribution" do + login_as(@curator) + + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @curator, + status: :pending, + change_type: :update_resource, + origin: :human, + proposed_title: "Original" + ) + + login_as(@other_curator) + + assert_difference "PlanSuggestionContribution.count", 1 do + patch curator_plan_plan_suggestion_path(@plan, suggestion), params: { + plan_suggestion: { + proposed_title: "Contributed via PATCH", + contribution_notes: "Better title" + } + } + end + + assert_redirected_to curator_plan_path(@plan) + assert_match "Doprinos prijedlogu uspješno dodan", flash[:notice] + + suggestion.reload + assert_equal "Contributed via PATCH", suggestion.proposed_title + end + + private + + def login_as(user) + post login_path, params: { + username: user.username, + password: "password123" + } + end +end diff --git a/test/controllers/curator/reviews_controller_test.rb b/test/controllers/curator/reviews_controller_test.rb new file mode 100644 index 00000000..bc3b1822 --- /dev/null +++ b/test/controllers/curator/reviews_controller_test.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "test_helper" + +module Curator + class ReviewsControllerTest < ActionDispatch::IntegrationTest + setup do + @curator = users(:one) + @location = locations(:one) + @review = Review.create!( + reviewable: @location, + rating: 4, + comment: "Great place!", + author_name: "Test User" + ) + login_as @curator + end + + def login_as(user) + post login_path, params: { + username: user.username, + password: "password123" + } + end + + test "should get index" do + get curator_reviews_path + assert_response :success + end + + test "should filter by moderation_status" do + @review.update!(moderation_status: :flagged) + get curator_reviews_path(moderation_status: :flagged) + assert_response :success + end + + test "should show review" do + get curator_review_path(@review) + assert_response :success + end + + test "should flag review" do + assert_difference "ReviewFlag.count", 1 do + post flag_curator_review_path(@review), params: { + reason: "spam", + notes: "This is spam content" + } + end + assert_redirected_to curator_reviews_path + assert_equal "Recenzija prijavljena.", flash[:notice] + end + + test "should record activity when flagging" do + assert_difference "CuratorActivity.count", 1 do + post flag_curator_review_path(@review), params: { + reason: "spam", + notes: "This is spam" + } + end + activity = CuratorActivity.last + assert_equal "review_flagged", activity.action + assert_equal @review, activity.recordable + end + + test "should prevent duplicate flags from same user" do + ReviewFlag.create!( + review: @review, + user: @curator, + reason: "spam" + ) + + assert_no_difference "ReviewFlag.count" do + post flag_curator_review_path(@review), params: { + reason: "inappropriate" + } + end + assert_redirected_to curator_reviews_path + assert_equal "Već ste prijavili ovu recenziju.", flash[:alert] + end + + test "should allow different users to flag same review" do + another_curator = users(:two) + ReviewFlag.create!( + review: @review, + user: @curator, + reason: "spam" + ) + + login_as another_curator + assert_difference "ReviewFlag.count", 1 do + post flag_curator_review_path(@review), params: { + reason: "inappropriate" + } + end + assert_redirected_to curator_reviews_path + end + + test "flagging should update review moderation_status to flagged" do + assert_changes -> { @review.reload.moderation_status }, from: "unreviewed", to: "flagged" do + post flag_curator_review_path(@review), params: { + reason: "spam" + } + end + end + + test "should submit delete proposal" do + assert_difference "ContentChange.count", 1 do + delete curator_review_path(@review) + end + assert_redirected_to curator_reviews_path + end + end +end diff --git a/test/fixtures/locations.yml b/test/fixtures/locations.yml new file mode 100644 index 00000000..ad6eac96 --- /dev/null +++ b/test/fixtures/locations.yml @@ -0,0 +1,15 @@ +one: + uuid: "11111111-1111-1111-1111-111111111111" + name: "Stari Most" + city: "Mostar" + lat: 43.3377 + lng: 17.8154 + description: "Famous bridge in Mostar" + +two: + uuid: "22222222-2222-2222-2222-222222222222" + name: "Baščaršija" + city: "Sarajevo" + lat: 43.8594 + lng: 18.4320 + description: "Historic market in Sarajevo" diff --git a/test/fixtures/reviews.yml b/test/fixtures/reviews.yml new file mode 100644 index 00000000..f4d9a035 --- /dev/null +++ b/test/fixtures/reviews.yml @@ -0,0 +1,13 @@ +one: + uuid: "55555555-5555-5555-5555-555555555555" + reviewable: one (Location) + rating: 5 + comment: "Great place!" + moderation_status: 0 + +two: + uuid: "66666666-6666-6666-6666-666666666666" + reviewable: two (Location) + rating: 4 + comment: "Nice location" + moderation_status: 0 diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 00000000..6d339ce4 --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,17 @@ +one: + uuid: "33333333-3333-3333-3333-333333333333" + username: curator1 + password_digest: <%= BCrypt::Password.create("password123") %> + user_type: 1 + +two: + uuid: "44444444-4444-4444-4444-444444444444" + username: curator2 + password_digest: <%= BCrypt::Password.create("password123") %> + user_type: 1 + +admin: + uuid: "55555555-5555-5555-5555-555555555555" + username: admin1 + password_digest: <%= BCrypt::Password.create("password123") %> + user_type: 2 diff --git a/test/jobs/audio_tour_generate_job_test.rb b/test/jobs/audio_tour_generate_job_test.rb new file mode 100644 index 00000000..8fae2b0b --- /dev/null +++ b/test/jobs/audio_tour_generate_job_test.rb @@ -0,0 +1,19 @@ +require "test_helper" + +class AudioTourGenerateJobTest < ActiveJob::TestCase + test "enqueues job" do + location = Location.create!(name: "Test", city: "Sarajevo", lat: 43.8, lng: 18.4) + user = User.create!(username: "admin_test", password: "password123", user_type: :admin) + + assert_enqueued_with(job: AudioTourGenerateJob, args: [ { location_id: location.id, locale: "bs", requested_by_id: user.id } ]) do + AudioTourGenerateJob.perform_later( + location_id: location.id, + locale: "bs", + requested_by_id: user.id + ) + end + + location.destroy + user.destroy + end +end diff --git a/test/models/concerns/suggestable_test.rb b/test/models/concerns/suggestable_test.rb new file mode 100644 index 00000000..f484d167 --- /dev/null +++ b/test/models/concerns/suggestable_test.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require "test_helper" + +# Test Suggestable concern through LocationSuggestion +# (concerns are tested through their including models) +class SuggestableTest < ActiveSupport::TestCase + setup do + @user = User.create!( + username: "test_curator", + password: "password123", + user_type: :curator + ) + @admin = User.create!( + username: "test_admin", + password: "password123", + user_type: :admin + ) + @location = Location.create!( + name: "Test Location", + city: "Sarajevo", + lat: 43.8563, + lng: 18.4131 + ) + end + + teardown do + LocationSuggestion.destroy_all + Location.destroy_all + User.destroy_all + end + + test "includes status enum" do + suggestion = LocationSuggestion.create!( + location: @location, + user: @user, + proposed_name: "Test" + ) + + assert suggestion.respond_to?(:status) + assert suggestion.respond_to?(:pending?) + assert suggestion.respond_to?(:approved?) + assert suggestion.respond_to?(:rejected?) + end + + test "includes change_type enum" do + suggestion = LocationSuggestion.create!( + location: @location, + user: @user, + change_type: :update_resource, + proposed_name: "Test" + ) + + assert suggestion.respond_to?(:change_type) + assert suggestion.respond_to?(:create_resource?) + assert suggestion.respond_to?(:update_resource?) + assert suggestion.respond_to?(:delete_resource?) + end + + test "includes origin enum" do + suggestion = LocationSuggestion.create!( + location: @location, + user: @user, + origin: :human, + proposed_name: "Test" + ) + + assert suggestion.respond_to?(:origin) + assert suggestion.respond_to?(:origin_human?) + assert suggestion.respond_to?(:origin_ai_generated?) + end + + test "validates user presence" do + suggestion = LocationSuggestion.new( + location: @location, + proposed_name: "Test" + ) + + assert_not suggestion.valid? + assert_includes suggestion.errors[:user], "can't be blank" + end + + test "approve! changes status and records reviewer" do + suggestion = LocationSuggestion.create!( + location: @location, + user: @user, + change_type: :update_resource, + proposed_name: "Updated Name" + ) + + suggestion.approve!(@admin, notes: "Approved") + + assert suggestion.approved? + assert_equal @admin, suggestion.reviewed_by + assert_not_nil suggestion.reviewed_at + assert_equal "Approved", suggestion.admin_notes + end + + test "reject! changes status without applying changes" do + original_name = @location.name + suggestion = LocationSuggestion.create!( + location: @location, + user: @user, + proposed_name: "New Name" + ) + + suggestion.reject!(@admin, notes: "Rejected") + + assert suggestion.rejected? + assert_equal @admin, suggestion.reviewed_by + assert_not_nil suggestion.reviewed_at + assert_equal "Rejected", suggestion.admin_notes + + @location.reload + assert_equal original_name, @location.name + end + + test "scopes work correctly" do + pending = LocationSuggestion.create!( + location: @location, + user: @user, + status: :pending, + proposed_name: "Pending" + ) + + approved = LocationSuggestion.create!( + location: Location.create!(name: "Another", city: "Mostar", lat: 43.3, lng: 17.8), + user: @user, + status: :approved, + change_type: :update_resource, + proposed_name: "Approved" + ) + + assert_includes LocationSuggestion.pending_review, pending + assert_not_includes LocationSuggestion.pending_review, approved + end + + test "proposed_changes returns only populated fields" do + suggestion = LocationSuggestion.create!( + location: @location, + user: @user, + proposed_name: "New Name", + proposed_city: "Mostar" + ) + + changes = suggestion.proposed_changes + assert changes.key?("proposed_name") + assert changes.key?("proposed_city") + assert_equal "New Name", changes["proposed_name"] + assert_equal "Mostar", changes["proposed_city"] + end + + test "applies changes via apply_changes! method" do + # LocationSuggestion implements apply_changes!, so we test through it + suggestion = LocationSuggestion.create!( + location: @location, + user: @user, + change_type: :update_resource, + proposed_name: "Updated Name" + ) + + # Should respond to apply_changes! (implemented by LocationSuggestion) + assert suggestion.respond_to?(:apply_changes!, true) + + # approve! should call apply_changes! + suggestion.approve!(@admin) + @location.reload + assert_equal "Updated Name", @location.name + end +end diff --git a/test/models/experience_suggestion_contribution_test.rb b/test/models/experience_suggestion_contribution_test.rb new file mode 100644 index 00000000..cad5bf59 --- /dev/null +++ b/test/models/experience_suggestion_contribution_test.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "test_helper" + +class ExperienceSuggestionContributionTest < ActiveSupport::TestCase + setup do + @user_a = User.create!( + username: "curator_a", + password: "password123", + user_type: :curator + ) + @user_b = User.create!( + username: "curator_b", + password: "password123", + user_type: :curator + ) + @experience = Experience.create!( + title: "Test Experience", + estimated_duration: 120 + ) + @suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @user_a, + proposed_title: "Original Title" + ) + end + + teardown do + ExperienceSuggestionContribution.destroy_all + ExperienceSuggestion.destroy_all + Experience.destroy_all + User.destroy_all + end + + test "creates contribution with valid attributes" do + contribution = ExperienceSuggestionContribution.create!( + experience_suggestion: @suggestion, + user: @user_b, + notes: "Improved title", + proposed_title: "Better Title" + ) + + assert contribution.persisted? + assert_equal @suggestion, contribution.experience_suggestion + assert_equal @user_b, contribution.user + assert_equal "Improved title", contribution.notes + assert_equal "Better Title", contribution.proposed_title + end + + test "requires experience_suggestion" do + contribution = ExperienceSuggestionContribution.new(user: @user_b) + assert_not contribution.valid? + assert_includes contribution.errors[:experience_suggestion], "must exist" + end + + test "requires user" do + contribution = ExperienceSuggestionContribution.new(experience_suggestion: @suggestion) + assert_not contribution.valid? + assert_includes contribution.errors[:user], "must exist" + end + + test "enforces unique user per suggestion" do + ExperienceSuggestionContribution.create!( + experience_suggestion: @suggestion, + user: @user_b, + proposed_title: "First contribution" + ) + + duplicate = ExperienceSuggestionContribution.new( + experience_suggestion: @suggestion, + user: @user_b, + proposed_title: "Second contribution" + ) + + assert_not duplicate.valid? + assert_includes duplicate.errors[:user_id], "already contributed to this suggestion" + end + + test "allows same user to contribute to different suggestions" do + suggestion_two = ExperienceSuggestion.create!( + experience: Experience.create!(title: "Another Experience", estimated_duration: 60), + user: @user_a, + proposed_title: "Another Title" + ) + + first = ExperienceSuggestionContribution.create!( + experience_suggestion: @suggestion, + user: @user_b, + proposed_title: "First" + ) + + second = ExperienceSuggestionContribution.create!( + experience_suggestion: suggestion_two, + user: @user_b, + proposed_title: "Second" + ) + + assert first.persisted? + assert second.persisted? + end + + test "tracks multiple fields in contribution" do + contribution = ExperienceSuggestionContribution.create!( + experience_suggestion: @suggestion, + user: @user_b, + notes: "Multiple updates", + proposed_title: "New Title", + proposed_description: "New description", + proposed_estimated_duration: 90, + proposed_contact_name: "John Doe" + ) + + assert_equal "New Title", contribution.proposed_title + assert_equal "New description", contribution.proposed_description + assert_equal 90, contribution.proposed_estimated_duration + assert_equal "John Doe", contribution.proposed_contact_name + end + + test "allows nil for optional proposed fields" do + contribution = ExperienceSuggestionContribution.create!( + experience_suggestion: @suggestion, + user: @user_b, + proposed_title: "Just title" + ) + + assert_nil contribution.proposed_description + assert_nil contribution.proposed_estimated_duration + assert_nil contribution.proposed_contact_name + end +end diff --git a/test/models/experience_suggestion_test.rb b/test/models/experience_suggestion_test.rb new file mode 100644 index 00000000..e556540e --- /dev/null +++ b/test/models/experience_suggestion_test.rb @@ -0,0 +1,458 @@ +# frozen_string_literal: true + +require "test_helper" + +class ExperienceSuggestionTest < ActiveSupport::TestCase + setup do + @user = User.create!( + username: "test_curator", + password: "password123", + user_type: :curator + ) + @admin = User.create!( + username: "test_admin", + password: "password123", + user_type: :admin + ) + @curator_two = User.create!( + username: "test_curator_two", + password: "password123", + user_type: :curator + ) + @category = ExperienceCategory.create!( + key: "adventure", + name: "Adventure" + ) + @experience = Experience.create!( + title: "Test Experience", + estimated_duration: 120 + ) + @experience_two = Experience.create!( + title: "Test Experience Two", + estimated_duration: 60 + ) + @location = Location.create!( + name: "Test Location", + city: "Sarajevo", + lat: 43.8563, + lng: 18.4131 + ) + end + + teardown do + ExperienceSuggestion.destroy_all + ExperienceSuggestionContribution.destroy_all + Experience.destroy_all + ExperienceCategory.destroy_all + Location.destroy_all + User.destroy_all + end + + # Basic creation and validation + test "creates experience suggestion with valid attributes" do + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @user, + status: :pending, + change_type: :update_resource, + origin: :human, + proposed_title: "Updated Title" + ) + + assert suggestion.persisted? + assert_equal "Updated Title", suggestion.proposed_title + assert suggestion.origin_human? + end + + test "requires user" do + suggestion = ExperienceSuggestion.new(experience: @experience) + assert_not suggestion.valid? + assert_includes suggestion.errors[:user], "can't be blank" + end + + test "allows nil experience for create_resource" do + suggestion = ExperienceSuggestion.create!( + experience: nil, + user: @user, + status: :pending, + change_type: :create_resource, + origin: :human, + proposed_title: "New Experience" + ) + + assert suggestion.persisted? + assert_nil suggestion.experience + end + + # Status transitions + test "pending is default status" do + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @user, + proposed_title: "Test" + ) + assert suggestion.pending? + end + + test "changes status to approved" do + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @user, + change_type: :update_resource, + proposed_title: "Updated Title" + ) + + suggestion.approve!(@admin, notes: "Looks good") + + assert suggestion.approved? + assert_equal @admin, suggestion.reviewed_by + assert_not_nil suggestion.reviewed_at + assert_equal "Looks good", suggestion.admin_notes + end + + test "changes status to rejected" do + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @user, + proposed_title: "Bad Title" + ) + + suggestion.reject!(@admin, notes: "Not accurate") + + assert suggestion.rejected? + assert_equal @admin, suggestion.reviewed_by + assert_not_nil suggestion.reviewed_at + assert_equal "Not accurate", suggestion.admin_notes + end + + # Apply changes + test "approve applies changes to existing experience" do + original_title = @experience.title + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @user, + change_type: :update_resource, + proposed_title: "New Title", + proposed_description: "New description" + ) + + suggestion.approve!(@admin) + + @experience.reload + assert_equal "New Title", @experience.title + assert_equal "New description", @experience.description + end + + test "approve creates new experience for create_resource" do + suggestion = ExperienceSuggestion.create!( + experience: nil, + user: @user, + change_type: :create_resource, + proposed_title: "Brand New Experience", + proposed_description: "Amazing adventure", + proposed_estimated_duration: 180 + ) + + assert_difference "Experience.count", 1 do + suggestion.approve!(@admin) + end + + suggestion.reload + assert_not_nil suggestion.experience + assert_equal "Brand New Experience", suggestion.experience.title + assert_equal "Amazing adventure", suggestion.experience.description + assert_equal 180, suggestion.experience.estimated_duration + end + + test "reject does not change experience" do + original_title = @experience.title + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @user, + proposed_title: "New Title" + ) + + suggestion.reject!(@admin) + + @experience.reload + assert_equal original_title, @experience.title + end + + test "approve applies location associations" do + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @user, + change_type: :update_resource, + proposed_title: "Experience with Location", + proposed_location_uuids: [@location.uuid] + ) + + suggestion.approve!(@admin) + + @experience.reload + assert_includes @experience.locations, @location + end + + # Multi-curator contributions + test "add_contribution from another user" do + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @user, + proposed_title: "Title from A", + proposed_description: "Description from A" + ) + + suggestion.add_contribution( + user: @curator_two, + notes: "Improved description", + proposed_description: "Better description", + proposed_estimated_duration: 90 + ) + + suggestion.reload + assert_equal "Better description", suggestion.proposed_description + assert_equal 90, suggestion.proposed_estimated_duration + assert_equal 1, suggestion.contributions.count + + contribution = suggestion.contributions.first + assert_equal @curator_two, contribution.user + assert_equal "Improved description", contribution.notes + assert_equal "Better description", contribution.proposed_description + end + + # Unique constraint + test "enforces one pending suggestion per experience" do + ExperienceSuggestion.create!( + experience: @experience, + user: @user, + status: :pending, + proposed_title: "First" + ) + + assert_raises ActiveRecord::RecordNotUnique do + ExperienceSuggestion.create!( + experience: @experience, + user: @curator_two, + status: :pending, + proposed_title: "Second" + ) + end + end + + test "allows multiple approved suggestions for same experience" do + ExperienceSuggestion.create!( + experience: @experience, + user: @user, + status: :approved, + change_type: :update_resource, + proposed_title: "First" + ) + + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @curator_two, + status: :approved, + change_type: :update_resource, + proposed_title: "Second" + ) + + assert suggestion.persisted? + end + + # find_or_create_pending! + test "find_or_create_pending! returns existing pending suggestion" do + existing = ExperienceSuggestion.create!( + experience: @experience, + user: @user, + status: :pending, + proposed_title: "Existing" + ) + + found = ExperienceSuggestion.find_or_create_pending!( + @experience, + user: @curator_two, + proposed_title: "Should not create" + ) + + assert_equal existing.id, found.id + end + + test "find_or_create_pending! creates new suggestion if none pending" do + suggestion = ExperienceSuggestion.find_or_create_pending!( + @experience, + user: @user, + proposed_title: "New" + ) + + assert suggestion.persisted? + assert suggestion.pending? + assert_equal "New", suggestion.proposed_title + end + + # Cover photo validation + test "has acceptable_cover_photo validation method" do + suggestion = ExperienceSuggestion.new( + experience: @experience, + user: @user, + proposed_title: "Test" + ) + assert suggestion.respond_to?(:acceptable_cover_photo, true) + end + + test "has proposed_cover_photo attachment" do + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @user, + proposed_title: "Test" + ) + assert suggestion.respond_to?(:proposed_cover_photo) + end + + # proposed_changes + test "proposed_changes returns non-nil proposed fields" do + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @user, + proposed_title: "New Title", + proposed_description: "New description", + proposed_contact_name: nil + ) + + changes = suggestion.proposed_changes + assert_includes changes.keys, "proposed_title" + assert_includes changes.keys, "proposed_description" + assert_not_includes changes.keys, "proposed_contact_name" + end + + # Origin enum + test "origin defaults to human" do + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @user, + proposed_title: "Test" + ) + assert suggestion.origin_human? + end + + test "origin can be ai_generated" do + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @user, + origin: :ai_generated, + ai_service: "experience_enricher", + proposed_title: "AI Generated" + ) + assert suggestion.origin_ai_generated? + assert_equal "experience_enricher", suggestion.ai_service + end + + # Scopes + test "pending_review scope" do + pending = ExperienceSuggestion.create!( + experience: @experience, + user: @user, + status: :pending, + proposed_title: "Pending" + ) + approved = ExperienceSuggestion.create!( + experience: @experience_two, + user: @user, + status: :approved, + change_type: :update_resource, + proposed_title: "Approved" + ) + + results = ExperienceSuggestion.pending_review + assert_includes results, pending + assert_not_includes results, approved + end + + test "human_suggestions scope" do + human = ExperienceSuggestion.create!( + experience: @experience, + user: @user, + origin: :human, + proposed_title: "Human" + ) + ai = ExperienceSuggestion.create!( + experience: @experience_two, + user: @user, + origin: :ai_generated, + ai_service: "test", + proposed_title: "AI" + ) + + results = ExperienceSuggestion.human_suggestions + assert_includes results, human + assert_not_includes results, ai + end + + test "ai_suggestions scope" do + human = ExperienceSuggestion.create!( + experience: @experience, + user: @user, + origin: :human, + proposed_title: "Human" + ) + ai = ExperienceSuggestion.create!( + experience: @experience_two, + user: @user, + origin: :ai_generated, + ai_service: "test", + proposed_title: "AI" + ) + + results = ExperienceSuggestion.ai_suggestions + assert_not_includes results, human + assert_includes results, ai + end + + # Experience-specific features + test "applies category changes" do + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @user, + change_type: :update_resource, + proposed_experience_category_id: @category.id + ) + + suggestion.approve!(@admin) + + @experience.reload + assert_equal @category.id, @experience.experience_category_id + end + + test "applies seasons changes" do + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @user, + change_type: :update_resource, + proposed_seasons: ["summer", "spring"] + ) + + suggestion.approve!(@admin) + + @experience.reload + assert_equal ["summer", "spring"], @experience.seasons + end + + test "applies contact information changes" do + suggestion = ExperienceSuggestion.create!( + experience: @experience, + user: @user, + change_type: :update_resource, + proposed_contact_name: "John Doe", + proposed_contact_email: "john@example.com", + proposed_contact_phone: "+387 33 123 456", + proposed_contact_website: "https://example.com" + ) + + suggestion.approve!(@admin) + + @experience.reload + assert_equal "John Doe", @experience.contact_name + assert_equal "john@example.com", @experience.contact_email + assert_equal "+387 33 123 456", @experience.contact_phone + assert_equal "https://example.com", @experience.contact_website + end +end diff --git a/test/models/location_suggestion_test.rb b/test/models/location_suggestion_test.rb new file mode 100644 index 00000000..4314dda3 --- /dev/null +++ b/test/models/location_suggestion_test.rb @@ -0,0 +1,388 @@ +# frozen_string_literal: true + +require "test_helper" + +class LocationSuggestionTest < ActiveSupport::TestCase + setup do + @user = User.create!( + username: "test_curator", + password: "password123", + user_type: :curator + ) + @admin = User.create!( + username: "test_admin", + password: "password123", + user_type: :admin + ) + @curator_two = User.create!( + username: "test_curator_two", + password: "password123", + user_type: :curator + ) + @location = Location.create!( + name: "Test Location", + city: "Sarajevo", + lat: 43.8563, + lng: 18.4131 + ) + @location_two = Location.create!( + name: "Test Location Two", + city: "Mostar", + lat: 43.3438, + lng: 17.8078 + ) + end + + teardown do + LocationSuggestion.destroy_all + LocationSuggestionContribution.destroy_all + Location.destroy_all + User.destroy_all + end + + # Basic creation and validation + test "creates location suggestion with valid attributes" do + suggestion = LocationSuggestion.create!( + location: @location, + user: @user, + status: :pending, + change_type: :update_resource, + origin: :human, + proposed_name: "Updated Name" + ) + + assert suggestion.persisted? + assert_equal "Updated Name", suggestion.proposed_name + assert suggestion.origin_human? + end + + test "requires user" do + suggestion = LocationSuggestion.new(location: @location) + assert_not suggestion.valid? + assert_includes suggestion.errors[:user], "can't be blank" + end + + test "allows nil location for create_resource" do + suggestion = LocationSuggestion.create!( + location: nil, + user: @user, + status: :pending, + change_type: :create_resource, + origin: :human, + proposed_name: "New Location" + ) + + assert suggestion.persisted? + assert_nil suggestion.location + end + + # Status transitions + test "pending is default status" do + suggestion = LocationSuggestion.create!( + location: @location, + user: @user, + proposed_name: "Test" + ) + assert suggestion.pending? + end + + test "changes status to approved" do + suggestion = LocationSuggestion.create!( + location: @location, + user: @user, + change_type: :update_resource, + proposed_name: "Updated Name" + ) + + suggestion.approve!(@admin, notes: "Looks good") + + assert suggestion.approved? + assert_equal @admin, suggestion.reviewed_by + assert_not_nil suggestion.reviewed_at + assert_equal "Looks good", suggestion.admin_notes + end + + test "changes status to rejected" do + suggestion = LocationSuggestion.create!( + location: @location, + user: @user, + proposed_name: "Bad Name" + ) + + suggestion.reject!(@admin, notes: "Not accurate") + + assert suggestion.rejected? + assert_equal @admin, suggestion.reviewed_by + assert_not_nil suggestion.reviewed_at + assert_equal "Not accurate", suggestion.admin_notes + end + + # Apply changes + test "approve applies changes to existing location" do + original_name = @location.name + suggestion = LocationSuggestion.create!( + location: @location, + user: @user, + change_type: :update_resource, + proposed_name: "New Name", + proposed_city: "Mostar" + ) + + suggestion.approve!(@admin) + + @location.reload + assert_equal "New Name", @location.name + assert_equal "Mostar", @location.city + end + + test "approve creates new location for create_resource" do + suggestion = LocationSuggestion.create!( + location: nil, + user: @user, + change_type: :create_resource, + proposed_name: "Brand New Location", + proposed_city: "Sarajevo", + proposed_lat: 43.8564, + proposed_lng: 18.4131 + ) + + assert_difference "Location.count", 1 do + suggestion.approve!(@admin) + end + + suggestion.reload + assert_not_nil suggestion.location + assert_equal "Brand New Location", suggestion.location.name + assert_equal "Sarajevo", suggestion.location.city + end + + test "reject does not change location" do + original_name = @location.name + suggestion = LocationSuggestion.create!( + location: @location, + user: @user, + proposed_name: "New Name" + ) + + suggestion.reject!(@admin) + + @location.reload + assert_equal original_name, @location.name + end + + # Multi-curator contributions + test "add_contribution from another user" do + suggestion = LocationSuggestion.create!( + location: @location, + user: @user, + proposed_name: "Name from A", + proposed_city: "Mostar" + ) + + suggestion.add_contribution( + user: @curator_two, + notes: "Improved description", + proposed_description: "Better description", + proposed_city: "Updated City" + ) + + suggestion.reload + assert_equal "Better description", suggestion.proposed_description + assert_equal "Updated City", suggestion.proposed_city + assert_equal 1, suggestion.contributions.count + + contribution = suggestion.contributions.first + assert_equal @curator_two, contribution.user + assert_equal "Improved description", contribution.notes + assert_equal "Better description", contribution.proposed_description + end + + # Unique constraint + test "enforces one pending suggestion per location" do + LocationSuggestion.create!( + location: @location, + user: @user, + status: :pending, + proposed_name: "First" + ) + + assert_raises ActiveRecord::RecordNotUnique do + LocationSuggestion.create!( + location: @location, + user: @curator_two, + status: :pending, + proposed_name: "Second" + ) + end + end + + test "allows multiple approved suggestions for same location" do + LocationSuggestion.create!( + location: @location, + user: @user, + status: :approved, + change_type: :update_resource, + proposed_name: "First" + ) + + suggestion = LocationSuggestion.create!( + location: @location, + user: @curator_two, + status: :approved, + change_type: :update_resource, + proposed_name: "Second" + ) + + assert suggestion.persisted? + end + + # find_or_create_pending! + test "find_or_create_pending! returns existing pending suggestion" do + existing = LocationSuggestion.create!( + location: @location, + user: @user, + status: :pending, + proposed_name: "Existing" + ) + + found = LocationSuggestion.find_or_create_pending!( + @location, + user: @curator_two, + proposed_name: "Should not create" + ) + + assert_equal existing.id, found.id + end + + test "find_or_create_pending! creates new suggestion if none pending" do + suggestion = LocationSuggestion.find_or_create_pending!( + @location, + user: @user, + proposed_name: "New" + ) + + assert suggestion.persisted? + assert suggestion.pending? + assert_equal "New", suggestion.proposed_name + end + + # Photo validation + # Note: Full Active Storage validation tests would require actual file fixtures + # These tests verify the validation methods exist and basic structure + test "has acceptable_photos validation method" do + suggestion = LocationSuggestion.new( + location: @location, + user: @user, + proposed_name: "Test" + ) + assert suggestion.respond_to?(:acceptable_photos, true) + end + + test "has proposed_photos attachment" do + suggestion = LocationSuggestion.create!( + location: @location, + user: @user, + proposed_name: "Test" + ) + assert suggestion.respond_to?(:proposed_photos) + end + + # proposed_changes + test "proposed_changes returns non-nil proposed fields" do + suggestion = LocationSuggestion.create!( + location: @location, + user: @user, + proposed_name: "New Name", + proposed_city: "Mostar", + proposed_description: nil + ) + + changes = suggestion.proposed_changes + assert_includes changes.keys, "proposed_name" + assert_includes changes.keys, "proposed_city" + assert_not_includes changes.keys, "proposed_description" + end + + # Origin enum + test "origin defaults to human" do + suggestion = LocationSuggestion.create!( + location: @location, + user: @user, + proposed_name: "Test" + ) + assert suggestion.origin_human? + end + + test "origin can be ai_generated" do + suggestion = LocationSuggestion.create!( + location: @location, + user: @user, + origin: :ai_generated, + ai_service: "location_enricher", + proposed_name: "AI Generated" + ) + assert suggestion.origin_ai_generated? + assert_equal "location_enricher", suggestion.ai_service + end + + # Scopes + test "pending_review scope" do + pending = LocationSuggestion.create!( + location: @location, + user: @user, + status: :pending, + proposed_name: "Pending" + ) + approved = LocationSuggestion.create!( + location: @location_two, + user: @user, + status: :approved, + change_type: :update_resource, + proposed_name: "Approved" + ) + + results = LocationSuggestion.pending_review + assert_includes results, pending + assert_not_includes results, approved + end + + test "human_suggestions scope" do + human = LocationSuggestion.create!( + location: @location, + user: @user, + origin: :human, + proposed_name: "Human" + ) + ai = LocationSuggestion.create!( + location: @location_two, + user: @user, + origin: :ai_generated, + ai_service: "test", + proposed_name: "AI" + ) + + results = LocationSuggestion.human_suggestions + assert_includes results, human + assert_not_includes results, ai + end + + test "ai_suggestions scope" do + human = LocationSuggestion.create!( + location: @location, + user: @user, + origin: :human, + proposed_name: "Human" + ) + ai = LocationSuggestion.create!( + location: @location_two, + user: @user, + origin: :ai_generated, + ai_service: "test", + proposed_name: "AI" + ) + + results = LocationSuggestion.ai_suggestions + assert_not_includes results, human + assert_includes results, ai + end +end diff --git a/test/models/plan_suggestion_test.rb b/test/models/plan_suggestion_test.rb new file mode 100644 index 00000000..58ed0eeb --- /dev/null +++ b/test/models/plan_suggestion_test.rb @@ -0,0 +1,395 @@ +# frozen_string_literal: true + +require "test_helper" + +class PlanSuggestionTest < ActiveSupport::TestCase + setup do + @user = User.create!( + username: "test_curator", + password: "password123", + user_type: :curator + ) + @admin = User.create!( + username: "test_admin", + password: "password123", + user_type: :admin + ) + @curator_two = User.create!( + username: "test_curator_two", + password: "password123", + user_type: :curator + ) + @plan = Plan.create!( + title: "Test Plan", + city_name: "Sarajevo", + user: @user + ) + @plan_two = Plan.create!( + title: "Test Plan Two", + city_name: "Mostar", + user: @user + ) + end + + teardown do + PlanSuggestion.destroy_all + PlanSuggestionContribution.destroy_all + Plan.destroy_all + User.destroy_all + end + + # Basic creation and validation + test "creates plan suggestion with valid attributes" do + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @user, + status: :pending, + change_type: :update_resource, + origin: :human, + proposed_title: "Updated Title" + ) + + assert suggestion.persisted? + assert_equal "Updated Title", suggestion.proposed_title + assert suggestion.origin_human? + end + + test "requires user" do + suggestion = PlanSuggestion.new(plan: @plan) + assert_not suggestion.valid? + assert_includes suggestion.errors[:user], "can't be blank" + end + + test "allows nil plan for create_resource" do + suggestion = PlanSuggestion.create!( + plan: nil, + user: @user, + status: :pending, + change_type: :create_resource, + origin: :human, + proposed_title: "New Plan", + proposed_city_name: "Sarajevo" + ) + + assert suggestion.persisted? + assert_nil suggestion.plan + end + + # Status transitions + test "pending is default status" do + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @user, + proposed_title: "Test" + ) + assert suggestion.pending? + end + + test "changes status to approved" do + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @user, + change_type: :update_resource, + proposed_title: "Updated Title" + ) + + suggestion.approve!(@admin, notes: "Looks good") + + assert suggestion.approved? + assert_equal @admin, suggestion.reviewed_by + assert_not_nil suggestion.reviewed_at + assert_equal "Looks good", suggestion.admin_notes + end + + test "changes status to rejected" do + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @user, + proposed_title: "Bad Title" + ) + + suggestion.reject!(@admin, notes: "Not accurate") + + assert suggestion.rejected? + assert_equal @admin, suggestion.reviewed_by + assert_not_nil suggestion.reviewed_at + assert_equal "Not accurate", suggestion.admin_notes + end + + # Apply changes + test "approve applies changes to existing plan" do + original_title = @plan.title + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @user, + change_type: :update_resource, + proposed_title: "New Title", + proposed_city_name: "Mostar" + ) + + suggestion.approve!(@admin) + + @plan.reload + assert_equal "New Title", @plan.title + assert_equal "Mostar", @plan.city_name + end + + test "approve creates new plan for create_resource" do + suggestion = PlanSuggestion.create!( + plan: nil, + user: @user, + change_type: :create_resource, + proposed_title: "Brand New Plan", + proposed_city_name: "Sarajevo" + ) + + assert_difference "Plan.count", 1 do + suggestion.approve!(@admin) + end + + suggestion.reload + assert_not_nil suggestion.plan + assert_equal "Brand New Plan", suggestion.plan.title + assert_equal "Sarajevo", suggestion.plan.city_name + end + + test "reject does not change plan" do + original_title = @plan.title + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @user, + proposed_title: "New Title" + ) + + suggestion.reject!(@admin) + + @plan.reload + assert_equal original_title, @plan.title + end + + # Multi-curator contributions + test "add_contribution from another user" do + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @user, + proposed_title: "Title from A", + proposed_city_name: "Mostar" + ) + + suggestion.add_contribution( + user: @curator_two, + notes: "Improved title and city", + proposed_title: "Better Title", + proposed_city_name: "Updated City" + ) + + suggestion.reload + assert_equal "Better Title", suggestion.proposed_title + assert_equal "Updated City", suggestion.proposed_city_name + assert_equal 1, suggestion.contributions.count + + contribution = suggestion.contributions.first + assert_equal @curator_two, contribution.user + assert_equal "Improved title and city", contribution.notes + assert_equal "Better Title", contribution.proposed_title + end + + # Unique constraint + test "enforces one pending suggestion per plan" do + PlanSuggestion.create!( + plan: @plan, + user: @user, + status: :pending, + proposed_title: "First" + ) + + assert_raises ActiveRecord::RecordNotUnique do + PlanSuggestion.create!( + plan: @plan, + user: @curator_two, + status: :pending, + proposed_title: "Second" + ) + end + end + + test "allows multiple approved suggestions for same plan" do + PlanSuggestion.create!( + plan: @plan, + user: @user, + status: :approved, + change_type: :update_resource, + proposed_title: "First" + ) + + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @curator_two, + status: :approved, + change_type: :update_resource, + proposed_title: "Second" + ) + + assert suggestion.persisted? + end + + # find_or_create_pending! + test "find_or_create_pending! returns existing pending suggestion" do + existing = PlanSuggestion.create!( + plan: @plan, + user: @user, + status: :pending, + proposed_title: "Existing" + ) + + found = PlanSuggestion.find_or_create_pending!( + @plan, + user: @curator_two, + proposed_title: "Should not create" + ) + + assert_equal existing.id, found.id + end + + test "find_or_create_pending! creates new suggestion if none pending" do + suggestion = PlanSuggestion.find_or_create_pending!( + @plan, + user: @user, + proposed_title: "New" + ) + + assert suggestion.persisted? + assert suggestion.pending? + assert_equal "New", suggestion.proposed_title + end + + # Cover photo attachment + test "has proposed_cover_photo attachment" do + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @user, + proposed_title: "Test" + ) + assert suggestion.respond_to?(:proposed_cover_photo) + end + + # proposed_changes + test "proposed_changes returns non-nil proposed fields" do + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @user, + proposed_title: "New Title", + proposed_city_name: "Mostar", + proposed_notes: nil + ) + + changes = suggestion.proposed_changes + assert_includes changes.keys, "proposed_title" + assert_includes changes.keys, "proposed_city_name" + assert_not_includes changes.keys, "proposed_notes" + end + + # Origin enum + test "origin defaults to human" do + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @user, + proposed_title: "Test" + ) + assert suggestion.origin_human? + end + + test "origin can be ai_generated" do + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @user, + origin: :ai_generated, + ai_service: "plan_generator", + proposed_title: "AI Generated" + ) + assert suggestion.origin_ai_generated? + assert_equal "plan_generator", suggestion.ai_service + end + + # Scopes + test "pending_review scope" do + pending = PlanSuggestion.create!( + plan: @plan, + user: @user, + status: :pending, + proposed_title: "Pending" + ) + approved = PlanSuggestion.create!( + plan: @plan_two, + user: @user, + status: :approved, + change_type: :update_resource, + proposed_title: "Approved" + ) + + results = PlanSuggestion.pending_review + assert_includes results, pending + assert_not_includes results, approved + end + + test "human_suggestions scope" do + human = PlanSuggestion.create!( + plan: @plan, + user: @user, + origin: :human, + proposed_title: "Human" + ) + ai = PlanSuggestion.create!( + plan: @plan_two, + user: @user, + origin: :ai_generated, + ai_service: "test", + proposed_title: "AI" + ) + + results = PlanSuggestion.human_suggestions + assert_includes results, human + assert_not_includes results, ai + end + + test "ai_suggestions scope" do + human = PlanSuggestion.create!( + plan: @plan, + user: @user, + origin: :human, + proposed_title: "Human" + ) + ai = PlanSuggestion.create!( + plan: @plan_two, + user: @user, + origin: :ai_generated, + ai_service: "test", + proposed_title: "AI" + ) + + results = PlanSuggestion.ai_suggestions + assert_not_includes results, human + assert_includes results, ai + end + + # Experience days handling + test "approve applies experience_days to plan" do + exp1 = Experience.create!(title: "Exp 1") + exp2 = Experience.create!(title: "Exp 2") + + suggestion = PlanSuggestion.create!( + plan: @plan, + user: @user, + change_type: :update_resource, + proposed_experience_days: { + "1" => [exp1.uuid, exp2.uuid] + } + ) + + suggestion.approve!(@admin) + + @plan.reload + days = @plan.experience_days + assert_equal [exp1.uuid, exp2.uuid], days["1"] + end +end diff --git a/test/models/review_flag_test.rb b/test/models/review_flag_test.rb new file mode 100644 index 00000000..02d04951 --- /dev/null +++ b/test/models/review_flag_test.rb @@ -0,0 +1,114 @@ +require "test_helper" + +class ReviewFlagTest < ActiveSupport::TestCase + test "valid creation with spam reason" do + review = reviews(:one) + user = users(:one) + flag = ReviewFlag.create!(review: review, user: user, reason: "spam") + + assert flag.persisted? + assert_equal "spam", flag.reason + end + + test "valid creation with inappropriate reason" do + review = reviews(:one) + user = users(:one) + flag = ReviewFlag.create!(review: review, user: user, reason: "inappropriate") + + assert_equal "inappropriate", flag.reason + end + + test "valid creation with inaccurate reason" do + review = reviews(:one) + user = users(:one) + flag = ReviewFlag.create!(review: review, user: user, reason: "inaccurate") + + assert_equal "inaccurate", flag.reason + end + + test "valid creation with other reason" do + review = reviews(:one) + user = users(:one) + flag = ReviewFlag.create!(review: review, user: user, reason: "other") + + assert_equal "other", flag.reason + end + + test "validates uniqueness of user per review" do + review = reviews(:one) + user = users(:one) + + ReviewFlag.create!(review: review, user: user, reason: "spam") + + duplicate_flag = ReviewFlag.new(review: review, user: user, reason: "inappropriate") + assert_not duplicate_flag.valid? + assert_includes duplicate_flag.errors[:user_id], "has already flagged this review" + end + + test "different users can flag the same review" do + review = reviews(:one) + user1 = users(:one) + user2 = users(:two) + + flag1 = ReviewFlag.create!(review: review, user: user1, reason: "spam") + flag2 = ReviewFlag.create!(review: review, user: user2, reason: "inappropriate") + + assert flag1.persisted? + assert flag2.persisted? + end + + test "after create updates review moderation_status to flagged" do + review = reviews(:one) + review.update!(moderation_status: :unreviewed) + user = users(:one) + + assert_equal "unreviewed", review.moderation_status + + ReviewFlag.create!(review: review, user: user, reason: "spam") + review.reload + + assert_equal "flagged", review.moderation_status + end + + test "does not update moderation_status if review is already approved" do + review = reviews(:one) + review.update!(moderation_status: :approved) + user = users(:one) + + ReviewFlag.create!(review: review, user: user, reason: "spam") + review.reload + + assert_equal "approved", review.moderation_status + end + + test "invalid reason is rejected" do + review = reviews(:one) + user = users(:one) + flag = ReviewFlag.new(review: review, user: user, reason: "invalid_reason") + + assert_not flag.valid? + assert_includes flag.errors[:reason], "is not included in the list" + end + + test "requires reason" do + review = reviews(:one) + user = users(:one) + flag = ReviewFlag.new(review: review, user: user, reason: nil) + + assert_not flag.valid? + assert_includes flag.errors[:reason], "can't be blank" + end + + test "can have optional notes" do + review = reviews(:one) + user = users(:one) + flag = ReviewFlag.create!( + review: review, + user: user, + reason: "spam", + notes: "This is clearly automated spam" + ) + + assert_equal "This is clearly automated spam", flag.notes + end +end diff --git a/test/models/review_moderation_test.rb b/test/models/review_moderation_test.rb new file mode 100644 index 00000000..64845d33 --- /dev/null +++ b/test/models/review_moderation_test.rb @@ -0,0 +1,140 @@ +require "test_helper" + +class ReviewModerationTest < ActiveSupport::TestCase + test "default moderation_status is unreviewed" do + review = Review.new( + reviewable: locations(:one), + rating: 5, + comment: "Great place!" + ) + review.save! + + assert_equal "unreviewed", review.moderation_status + assert review.unreviewed? + end + + test "can set moderation_status to approved" do + review = reviews(:one) + review.update!(moderation_status: :approved) + + assert_equal "approved", review.moderation_status + assert review.approved? + end + + test "can set moderation_status to flagged" do + review = reviews(:one) + review.update!(moderation_status: :flagged) + + assert_equal "flagged", review.moderation_status + assert review.flagged? + end + + test "can set moderation_status to removed" do + review = reviews(:one) + review.update!(moderation_status: :removed) + + assert_equal "removed", review.moderation_status + assert review.removed? + end + + test "needs_moderation scope returns unreviewed and flagged reviews" do + review1 = reviews(:one) + review2 = reviews(:two) + review3 = Review.create!(reviewable: locations(:one), rating: 5, moderation_status: :unreviewed) + review4 = Review.create!(reviewable: locations(:one), rating: 4, moderation_status: :flagged) + review5 = Review.create!(reviewable: locations(:one), rating: 3, moderation_status: :approved) + review6 = Review.create!(reviewable: locations(:one), rating: 2, moderation_status: :removed) + + # Set test data to known states + review1.update!(moderation_status: :unreviewed) + review2.update!(moderation_status: :flagged) + + needs_moderation = Review.needs_moderation + + assert_includes needs_moderation, review1 + assert_includes needs_moderation, review2 + assert_includes needs_moderation, review3 + assert_includes needs_moderation, review4 + assert_not_includes needs_moderation, review5 + assert_not_includes needs_moderation, review6 + end + + test "moderated scope returns approved and removed reviews" do + review1 = reviews(:one) + review2 = reviews(:two) + review3 = Review.create!(reviewable: locations(:one), rating: 5, moderation_status: :unreviewed) + review4 = Review.create!(reviewable: locations(:one), rating: 4, moderation_status: :flagged) + review5 = Review.create!(reviewable: locations(:one), rating: 3, moderation_status: :approved) + review6 = Review.create!(reviewable: locations(:one), rating: 2, moderation_status: :removed) + + # Set test data to known states + review1.update!(moderation_status: :approved) + review2.update!(moderation_status: :removed) + + moderated = Review.moderated + + assert_includes moderated, review1 + assert_includes moderated, review2 + assert_includes moderated, review5 + assert_includes moderated, review6 + assert_not_includes moderated, review3 + assert_not_includes moderated, review4 + end + + test "flagged_by? returns true if user flagged the review" do + review = reviews(:one) + user = users(:one) + + ReviewFlag.create!(review: review, user: user, reason: "spam") + + assert review.flagged_by?(user) + end + + test "flagged_by? returns false if user has not flagged the review" do + review = reviews(:one) + user = users(:one) + + assert_not review.flagged_by?(user) + end + + test "flagged_by? returns false if user is nil" do + review = reviews(:one) + + assert_not review.flagged_by?(nil) + end + + test "flag_count returns number of flags" do + review = reviews(:one) + user1 = users(:one) + user2 = users(:two) + + assert_equal 0, review.flag_count + + ReviewFlag.create!(review: review, user: user1, reason: "spam") + assert_equal 1, review.flag_count + + ReviewFlag.create!(review: review, user: user2, reason: "inappropriate") + assert_equal 2, review.flag_count + end + + test "has_many review_flags association" do + review = reviews(:one) + user = users(:one) + + flag = ReviewFlag.create!(review: review, user: user, reason: "spam") + + assert_includes review.review_flags, flag + end + + test "dependent destroy on review_flags" do + review = reviews(:one) + user = users(:one) + + flag = ReviewFlag.create!(review: review, user: user, reason: "spam") + flag_id = flag.id + + review.destroy + + assert_nil ReviewFlag.find_by(id: flag_id) + end +end