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_tour.locale.upcase %> · + <%= audio_tour.duration || "N/A" %> · + <%= audio_tour.word_count || "N/A" %> riječi +
++ Nema audio ture za ovu lokaciju. +
+ <% end %> + + <% if current_user.admin? %> + +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 @@ ++ Naslovna fotografija +
+<%= contribution.notes %>
+ <% end %> +<%= contribution.notes %>
+ <% end %> ++ Naslovna fotografija +
+<%= contribution.notes %>
+ <% end %> +| Autor | +Ocjena | +Komentar | +Resurs | +Status | +Prijave | ++ Akcije + | +
|---|---|---|---|---|---|---|
| + <%= review.author_name.presence || review.user&.username || "Anonimno" %> + | +
+
+ <% review.rating.times do %>
+
+ <% end %>
+
+ |
+ + <%= review.comment.presence || "-" %> + | +
+ <% if review.reviewable %>
+ <%= review.reviewable.try(:name) || review.reviewable.try(:title) %>
+ <%= review.reviewable_type %>
+ <% else %>
+ Obrisano
+ <% end %>
+ |
+ + <% case review.moderation_status %> + <% when "unreviewed" %> + + Nepregledano + + <% when "flagged" %> + + Prijavljeno + + <% when "approved" %> + + Odobreno + + <% when "removed" %> + + Uklonjeno + + <% end %> + | ++ <% if review.review_flags.any? %> + + <%= review.review_flags.count %> + + <% else %> + - + <% end %> + | ++ <%= link_to "Prikaži", curator_admin_review_path(review), class: "text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300" %> + | +
| + Nema recenzija. + | +||||||
+ <%= 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" %> +<%= flag.notes %>
+ <% end %> +Nema prijava za ovu recenziju.
+| Lokacija | +Tip izmjene | +Porijeklo | +Kurator | +Datum | +Doprinosi | +Akcije | +
|---|---|---|---|---|---|---|
|
+
+ <%= 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 %> + | ++ <%= link_to "Pregledaj", curator_admin_location_suggestion_path(suggestion), + class: "text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300 font-medium" %> + | +
| Iskustvo | +Tip izmjene | +Porijeklo | +Kurator | +Datum | +Doprinosi | +Akcije | +
|---|---|---|---|---|---|---|
|
+
+ <%= 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 %> + | ++ <%= link_to "Pregledaj", curator_admin_experience_suggestion_path(suggestion), + class: "text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300 font-medium" %> + | +
| Plan | +Tip izmjene | +Porijeklo | +Kurator | +Datum | +Doprinosi | +Akcije | +
|---|---|---|---|---|---|---|
|
+
+ <%= 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 %> + | ++ <%= link_to "Pregledaj", curator_admin_plan_suggestion_path(suggestion), + class: "text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300 font-medium" %> + | +