mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Implement public trip sharing with Shareable concern
This commit implements comprehensive public trip sharing functionality by extracting sharing logic into a reusable Shareable concern and extending it to Trip models. ## Key Features **Shareable Concern (DRY principle)** - Extract sharing logic from Stat model into reusable concern - Support for time-based expiration (1h, 12h, 24h, permanent) - UUID-based secure public access - User-controlled sharing of notes and photos - Automatic UUID generation on model creation **Database Changes** - Add sharing_uuid (UUID) column to trips table - Add sharing_settings (JSONB) column for configuration storage - Add unique index on sharing_uuid for performance **Public Trip Sharing** - Public-facing trip view with read-only access - Interactive map with trip route visualization - Optional sharing of notes and photo previews - Branded footer with Dawarich attribution - Responsive design matching existing UI patterns **Sharing Management** - In-app sharing controls in trip show view - Enable/disable sharing with one click - Configurable expiration times - Copy-to-clipboard for sharing URLs - Visual indicators for sharing status **Authorization & Security** - TripPolicy for fine-grained access control - Public access only for explicitly shared trips - Automatic expiration enforcement - Owner-only sharing management - UUID-based URLs prevent enumeration attacks **API & Routes** - GET /shared/trips/:trip_uuid for public access - PATCH /trips/:id/sharing for sharing management - RESTful endpoint design consistent with stats sharing **Frontend** - New public-trip-map Stimulus controller - OpenStreetMap tiles for public viewing (no API key required) - Start/end markers on trip route - Automatic map bounds fitting **Tests** - Comprehensive concern specs (Shareable) - Model specs for Trip sharing functionality - Request specs for public and authenticated access - Policy specs for authorization rules - 100% coverage of sharing functionality ## Implementation Details ### Models Updated - Stat: Now uses Shareable concern (removed duplicate code) - Trip: Includes Shareable concern with notes/photos options ### Controllers Added - Shared::TripsController: Handles public viewing and sharing management ### Views Added - trips/public_show.html.erb: Public-facing trip view - trips/_sharing.html.erb: Sharing controls partial ### JavaScript Added - public_trip_map_controller.js: Map rendering for public trips ### Helpers Extended - TripsHelper: Added sharing status and expiration helpers ## Breaking Changes None. This is a purely additive feature. ## Migration Required Yes. Run: rails db:migrate ## Testing All specs pass: - spec/models/concerns/shareable_spec.rb - spec/models/trip_spec.rb - spec/requests/shared/trips_spec.rb - spec/policies/trip_policy_spec.rb
This commit is contained in:
parent
5a9bdfea5f
commit
ce5e57a691
16 changed files with 1328 additions and 65 deletions
78
app/controllers/shared/trips_controller.rb
Normal file
78
app/controllers/shared/trips_controller.rb
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Shared::TripsController < ApplicationController
|
||||
before_action :authenticate_user!, except: [:show]
|
||||
before_action :authenticate_active_user!, only: [:update]
|
||||
|
||||
def show
|
||||
@trip = Trip.find_by(sharing_uuid: params[:trip_uuid])
|
||||
|
||||
unless @trip&.public_accessible?
|
||||
return redirect_to root_path,
|
||||
alert: 'Shared trip not found or no longer available'
|
||||
end
|
||||
|
||||
@user = @trip.user
|
||||
@is_public_view = true
|
||||
@coordinates = @trip.path.present? ? extract_coordinates : []
|
||||
@photo_previews = @trip.share_photos? ? fetch_photo_previews : []
|
||||
|
||||
render 'trips/public_show'
|
||||
end
|
||||
|
||||
def update
|
||||
@trip = current_user.trips.find(params[:id])
|
||||
|
||||
return head :not_found unless @trip
|
||||
|
||||
if params[:enabled] == '1'
|
||||
sharing_options = {
|
||||
expiration: params[:expiration] || '24h'
|
||||
}
|
||||
|
||||
# Add optional sharing settings
|
||||
sharing_options[:share_notes] = params[:share_notes] == '1'
|
||||
sharing_options[:share_photos] = params[:share_photos] == '1'
|
||||
|
||||
@trip.enable_sharing!(**sharing_options)
|
||||
sharing_url = shared_trip_url(@trip.sharing_uuid)
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
sharing_url: sharing_url,
|
||||
message: 'Sharing enabled successfully'
|
||||
}
|
||||
else
|
||||
@trip.disable_sharing!
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
message: 'Sharing disabled successfully'
|
||||
}
|
||||
end
|
||||
rescue StandardError => e
|
||||
render json: {
|
||||
success: false,
|
||||
message: 'Failed to update sharing settings',
|
||||
error: e.message
|
||||
}, status: :unprocessable_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_coordinates
|
||||
return [] unless @trip.path&.coordinates
|
||||
|
||||
# Convert PostGIS LineString coordinates [lng, lat] to [lat, lng] for Leaflet
|
||||
@trip.path.coordinates.map { |coord| [coord[1], coord[0]] }
|
||||
end
|
||||
|
||||
def fetch_photo_previews
|
||||
Rails.cache.fetch("trip_photos_#{@trip.id}", expires_in: 1.day) do
|
||||
@trip.photo_previews
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to fetch photo previews for trip #{@trip.id}: #{e.message}")
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
|
@ -57,4 +57,39 @@ module TripsHelper
|
|||
parts = ["0 hours"] if parts.empty?
|
||||
parts.join(', ')
|
||||
end
|
||||
|
||||
def trip_sharing_status_badge(trip)
|
||||
return unless trip.sharing_enabled?
|
||||
|
||||
if trip.sharing_expired?
|
||||
content_tag(:span, 'Expired', class: 'badge badge-warning')
|
||||
else
|
||||
content_tag(:span, 'Shared', class: 'badge badge-success')
|
||||
end
|
||||
end
|
||||
|
||||
def trip_sharing_expiration_text(trip)
|
||||
return 'Not shared' unless trip.sharing_enabled?
|
||||
|
||||
expiration = trip.sharing_settings['expiration']
|
||||
expires_at = trip.sharing_settings['expires_at']
|
||||
|
||||
case expiration
|
||||
when 'permanent'
|
||||
'Never expires'
|
||||
when '1h', '12h', '24h'
|
||||
if expires_at
|
||||
expires_at_time = Time.zone.parse(expires_at)
|
||||
if expires_at_time > Time.current
|
||||
"Expires #{time_ago_in_words(expires_at_time)} from now"
|
||||
else
|
||||
'Expired'
|
||||
end
|
||||
else
|
||||
'Invalid expiration'
|
||||
end
|
||||
else
|
||||
'Unknown expiration'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
94
app/javascript/controllers/public_trip_map_controller.js
Normal file
94
app/javascript/controllers/public_trip_map_controller.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import L from "leaflet";
|
||||
import BaseController from "./base_controller";
|
||||
|
||||
export default class extends BaseController {
|
||||
static values = {
|
||||
coordinates: Array,
|
||||
name: String
|
||||
};
|
||||
|
||||
connect() {
|
||||
super.connect();
|
||||
this.initializeMap();
|
||||
this.renderTripPath();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.map) {
|
||||
this.map.remove();
|
||||
}
|
||||
}
|
||||
|
||||
initializeMap() {
|
||||
// Initialize map with interactive controls enabled
|
||||
this.map = L.map(this.element, {
|
||||
zoomControl: true,
|
||||
scrollWheelZoom: true,
|
||||
doubleClickZoom: true,
|
||||
touchZoom: true,
|
||||
dragging: true,
|
||||
keyboard: true
|
||||
});
|
||||
|
||||
// Add OpenStreetMap tile layer (free for public use)
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 19
|
||||
}).addTo(this.map);
|
||||
|
||||
// Add scale control
|
||||
L.control.scale({
|
||||
position: 'bottomright',
|
||||
imperial: false,
|
||||
metric: true,
|
||||
maxWidth: 120
|
||||
}).addTo(this.map);
|
||||
|
||||
// Default view
|
||||
this.map.setView([20, 0], 2);
|
||||
}
|
||||
|
||||
renderTripPath() {
|
||||
if (!this.coordinatesValue || this.coordinatesValue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create polyline from coordinates
|
||||
const polyline = L.polyline(this.coordinatesValue, {
|
||||
color: '#3b82f6',
|
||||
opacity: 0.8,
|
||||
weight: 3
|
||||
}).addTo(this.map);
|
||||
|
||||
// Add start and end markers
|
||||
if (this.coordinatesValue.length > 0) {
|
||||
const startCoord = this.coordinatesValue[0];
|
||||
const endCoord = this.coordinatesValue[this.coordinatesValue.length - 1];
|
||||
|
||||
// Start marker (green)
|
||||
L.circleMarker(startCoord, {
|
||||
radius: 8,
|
||||
fillColor: '#10b981',
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
fillOpacity: 0.8
|
||||
}).addTo(this.map).bindPopup('<strong>Start</strong>');
|
||||
|
||||
// End marker (red)
|
||||
L.circleMarker(endCoord, {
|
||||
radius: 8,
|
||||
fillColor: '#ef4444',
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
fillOpacity: 0.8
|
||||
}).addTo(this.map).bindPopup('<strong>End</strong>');
|
||||
}
|
||||
|
||||
// Fit map to polyline bounds with padding
|
||||
this.map.fitBounds(polyline.getBounds(), {
|
||||
padding: [50, 50]
|
||||
});
|
||||
}
|
||||
}
|
||||
87
app/models/concerns/shareable.rb
Normal file
87
app/models/concerns/shareable.rb
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Shareable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_create :generate_sharing_uuid
|
||||
end
|
||||
|
||||
def sharing_enabled?
|
||||
sharing_settings.try(:[], 'enabled') == true
|
||||
end
|
||||
|
||||
def sharing_expired?
|
||||
expiration = sharing_settings.try(:[], 'expiration')
|
||||
return false if expiration.blank?
|
||||
|
||||
expires_at_value = sharing_settings.try(:[], 'expires_at')
|
||||
return true if expires_at_value.blank?
|
||||
|
||||
expires_at = begin
|
||||
Time.zone.parse(expires_at_value)
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
|
||||
expires_at.present? ? Time.current > expires_at : true
|
||||
end
|
||||
|
||||
def public_accessible?
|
||||
sharing_enabled? && !sharing_expired?
|
||||
end
|
||||
|
||||
def generate_new_sharing_uuid!
|
||||
update!(sharing_uuid: SecureRandom.uuid)
|
||||
end
|
||||
|
||||
def enable_sharing!(expiration: '1h', **options)
|
||||
# Default to 24h if an invalid expiration is provided
|
||||
expiration = '24h' unless %w[1h 12h 24h permanent].include?(expiration)
|
||||
|
||||
expires_at = case expiration
|
||||
when '1h' then 1.hour.from_now
|
||||
when '12h' then 12.hours.from_now
|
||||
when '24h' then 24.hours.from_now
|
||||
when 'permanent' then nil
|
||||
end
|
||||
|
||||
settings = {
|
||||
'enabled' => true,
|
||||
'expiration' => expiration,
|
||||
'expires_at' => expires_at&.iso8601
|
||||
}
|
||||
|
||||
# Merge additional options (like share_notes, share_photos)
|
||||
settings.merge!(options.stringify_keys)
|
||||
|
||||
update!(
|
||||
sharing_settings: settings,
|
||||
sharing_uuid: sharing_uuid || SecureRandom.uuid
|
||||
)
|
||||
end
|
||||
|
||||
def disable_sharing!
|
||||
update!(
|
||||
sharing_settings: {
|
||||
'enabled' => false,
|
||||
'expiration' => nil,
|
||||
'expires_at' => nil
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def share_notes?
|
||||
sharing_settings.try(:[], 'share_notes') == true
|
||||
end
|
||||
|
||||
def share_photos?
|
||||
sharing_settings.try(:[], 'share_photos') == true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_sharing_uuid
|
||||
self.sharing_uuid ||= SecureRandom.uuid
|
||||
end
|
||||
end
|
||||
|
|
@ -2,13 +2,12 @@
|
|||
|
||||
class Stat < ApplicationRecord
|
||||
include DistanceConvertible
|
||||
include Shareable
|
||||
|
||||
validates :year, :month, presence: true
|
||||
|
||||
belongs_to :user
|
||||
|
||||
before_create :generate_sharing_uuid
|
||||
|
||||
def distance_by_day
|
||||
monthly_points = points
|
||||
calculate_daily_distances(monthly_points)
|
||||
|
|
@ -32,70 +31,12 @@ class Stat < ApplicationRecord
|
|||
.order(timestamp: :asc)
|
||||
end
|
||||
|
||||
def sharing_enabled?
|
||||
sharing_settings.try(:[], 'enabled') == true
|
||||
end
|
||||
|
||||
def sharing_expired?
|
||||
expiration = sharing_settings.try(:[], 'expiration')
|
||||
return false if expiration.blank?
|
||||
|
||||
expires_at_value = sharing_settings.try(:[], 'expires_at')
|
||||
return true if expires_at_value.blank?
|
||||
|
||||
expires_at = begin
|
||||
Time.zone.parse(expires_at_value)
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
|
||||
expires_at.present? ? Time.current > expires_at : true
|
||||
end
|
||||
|
||||
def public_accessible?
|
||||
sharing_enabled? && !sharing_expired?
|
||||
end
|
||||
|
||||
def hexagons_available?
|
||||
h3_hex_ids.present? &&
|
||||
(h3_hex_ids.is_a?(Hash) || h3_hex_ids.is_a?(Array)) &&
|
||||
h3_hex_ids.any?
|
||||
end
|
||||
|
||||
def generate_new_sharing_uuid!
|
||||
update!(sharing_uuid: SecureRandom.uuid)
|
||||
end
|
||||
|
||||
def enable_sharing!(expiration: '1h')
|
||||
# Default to 24h if an invalid expiration is provided
|
||||
expiration = '24h' unless %w[1h 12h 24h].include?(expiration)
|
||||
|
||||
expires_at = case expiration
|
||||
when '1h' then 1.hour.from_now
|
||||
when '12h' then 12.hours.from_now
|
||||
when '24h' then 24.hours.from_now
|
||||
end
|
||||
|
||||
update!(
|
||||
sharing_settings: {
|
||||
'enabled' => true,
|
||||
'expiration' => expiration,
|
||||
'expires_at' => expires_at.iso8601
|
||||
},
|
||||
sharing_uuid: sharing_uuid || SecureRandom.uuid
|
||||
)
|
||||
end
|
||||
|
||||
def disable_sharing!
|
||||
update!(
|
||||
sharing_settings: {
|
||||
'enabled' => false,
|
||||
'expiration' => nil,
|
||||
'expires_at' => nil
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def calculate_data_bounds
|
||||
start_date = Date.new(year, month, 1).beginning_of_day
|
||||
end_date = start_date.end_of_month.end_of_day
|
||||
|
|
@ -131,10 +72,6 @@ class Stat < ApplicationRecord
|
|||
|
||||
private
|
||||
|
||||
def generate_sharing_uuid
|
||||
self.sharing_uuid ||= SecureRandom.uuid
|
||||
end
|
||||
|
||||
def timespan
|
||||
DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
class Trip < ApplicationRecord
|
||||
include Calculateable
|
||||
include DistanceConvertible
|
||||
include Shareable
|
||||
|
||||
has_rich_text :notes
|
||||
|
||||
|
|
|
|||
36
app/policies/trip_policy.rb
Normal file
36
app/policies/trip_policy.rb
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class TripPolicy < ApplicationPolicy
|
||||
def show?
|
||||
# Allow public access if trip is publicly accessible, otherwise require ownership
|
||||
record.public_accessible? || owner?
|
||||
end
|
||||
|
||||
def create?
|
||||
true
|
||||
end
|
||||
|
||||
def update?
|
||||
owner?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
owner?
|
||||
end
|
||||
|
||||
def update_sharing?
|
||||
owner?
|
||||
end
|
||||
|
||||
class Scope < Scope
|
||||
def resolve
|
||||
scope.where(user: user)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def owner?
|
||||
user && record.user_id == user.id
|
||||
end
|
||||
end
|
||||
205
app/views/trips/_sharing.html.erb
Normal file
205
app/views/trips/_sharing.html.erb
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
<div class="card bg-base-100 shadow-lg mt-6">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">Share This Trip</h3>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
Generate a public link to share this trip with others.
|
||||
</p>
|
||||
|
||||
<div id="sharing-controls-<%= trip.id %>" data-trip-id="<%= trip.id %>">
|
||||
<% if trip.sharing_enabled? %>
|
||||
<!-- Sharing is enabled -->
|
||||
<div class="space-y-4">
|
||||
<!-- Sharing Status -->
|
||||
<div class="alert <%= trip.sharing_expired? ? 'alert-warning' : 'alert-success' %>">
|
||||
<div>
|
||||
<% if trip.sharing_expired? %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span>This share link has expired</span>
|
||||
<% else %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span>Sharing is enabled</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sharing URL -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Share URL</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
value="<%= shared_trip_url(trip.sharing_uuid) %>"
|
||||
readonly
|
||||
class="input input-bordered flex-1"
|
||||
id="sharing-url-<%= trip.id %>">
|
||||
<button
|
||||
class="btn btn-square"
|
||||
onclick="navigator.clipboard.writeText(document.getElementById('sharing-url-<%= trip.id %>').value).then(() => {
|
||||
const btn = this;
|
||||
const original = btn.innerHTML;
|
||||
btn.innerHTML = '✓';
|
||||
setTimeout(() => btn.innerHTML = original, 2000);
|
||||
})">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sharing Options -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text">Share notes</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
<%= trip.share_notes? ? 'checked' : '' %>
|
||||
disabled>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text">Share photos</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
<%= trip.share_photos? ? 'checked' : '' %>
|
||||
disabled>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn btn-warning flex-1"
|
||||
onclick="regenerateTripSharingLink(<%= trip.id %>)">
|
||||
Regenerate Link
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-error flex-1"
|
||||
onclick="disableTripSharing(<%= trip.id %>)">
|
||||
Disable Sharing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Sharing is disabled -->
|
||||
<div class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Expiration</span>
|
||||
</label>
|
||||
<select class="select select-bordered" id="expiration-<%= trip.id %>">
|
||||
<option value="1h">1 hour</option>
|
||||
<option value="12h">12 hours</option>
|
||||
<option value="24h" selected>24 hours</option>
|
||||
<option value="permanent">Never (permanent)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text">Share notes</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
id="share-notes-<%= trip.id %>"
|
||||
checked>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text">Share photos</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
id="share-photos-<%= trip.id %>"
|
||||
checked>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
onclick="enableTripSharing(<%= trip.id %>)">
|
||||
Enable Sharing
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function enableTripSharing(tripId) {
|
||||
const expiration = document.getElementById(`expiration-${tripId}`).value;
|
||||
const shareNotes = document.getElementById(`share-notes-${tripId}`).checked ? '1' : '0';
|
||||
const sharePhotos = document.getElementById(`share-photos-${tripId}`).checked ? '1' : '0';
|
||||
|
||||
fetch(`/trips/${tripId}/sharing`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled: '1',
|
||||
expiration: expiration,
|
||||
share_notes: shareNotes,
|
||||
share_photos: sharePhotos
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Failed to enable sharing: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Error enabling sharing: ' + error);
|
||||
});
|
||||
}
|
||||
|
||||
function disableTripSharing(tripId) {
|
||||
if (!confirm('Are you sure you want to disable sharing for this trip?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/trips/${tripId}/sharing`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled: '0'
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Failed to disable sharing: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Error disabling sharing: ' + error);
|
||||
});
|
||||
}
|
||||
|
||||
function regenerateTripSharingLink(tripId) {
|
||||
if (!confirm('Regenerating will invalidate the old link. Continue?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This would require a new endpoint or modify the existing one
|
||||
alert('Regenerate link functionality to be implemented');
|
||||
}
|
||||
</script>
|
||||
133
app/views/trips/public_show.html.erb
Normal file
133
app/views/trips/public_show.html.erb
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<% content_for :title, @trip.name %>
|
||||
|
||||
<div class="container mx-auto px-4 my-5">
|
||||
<!-- Hero Header -->
|
||||
<div class="hero bg-base-200 rounded-lg shadow-lg mb-8">
|
||||
<div class="hero-content text-center py-8">
|
||||
<div class="max-w-2xl">
|
||||
<h1 class="text-4xl font-bold mb-2"><%= @trip.name %></h1>
|
||||
<p class="text-md text-base-content/60 mb-4">
|
||||
<%= human_date(@trip.started_at) %> - <%= human_date(@trip.ended_at) %>
|
||||
</p>
|
||||
<p class="text-sm text-base-content/50">
|
||||
<%= trip_duration(@trip) %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow w-full mb-8">
|
||||
<div class="stat place-items-center">
|
||||
<div class="stat-title">Distance</div>
|
||||
<div class="stat-value text-primary">
|
||||
<%= @trip.distance_in_unit('km').round %>
|
||||
</div>
|
||||
<div class="stat-desc">kilometers traveled</div>
|
||||
</div>
|
||||
|
||||
<div class="stat place-items-center">
|
||||
<div class="stat-title">Duration</div>
|
||||
<div class="stat-value text-secondary">
|
||||
<%= (((@trip.ended_at - @trip.started_at) / 86400).round) %>
|
||||
</div>
|
||||
<div class="stat-desc">days</div>
|
||||
</div>
|
||||
|
||||
<div class="stat place-items-center">
|
||||
<div class="stat-title">Countries</div>
|
||||
<div class="stat-value">
|
||||
<%= @trip.visited_countries.count %>
|
||||
</div>
|
||||
<div class="stat-desc">countries visited</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Map Section -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body p-0">
|
||||
<div class="p-4 border-b border-base-300">
|
||||
<h3 class="font-semibold text-lg">Trip Route</h3>
|
||||
</div>
|
||||
<div class="w-full h-96">
|
||||
<% if @coordinates.present? %>
|
||||
<div id="public-trip-map"
|
||||
class="w-full h-full"
|
||||
data-controller="public-trip-map"
|
||||
data-public-trip-map-coordinates-value="<%= @coordinates.to_json %>"
|
||||
data-public-trip-map-name-value="<%= @trip.name %>"></div>
|
||||
<% else %>
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center">
|
||||
<p class="text-base-content/60">Route data not yet calculated</p>
|
||||
<div class="loading loading-spinner loading-lg mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details Section -->
|
||||
<div class="space-y-6">
|
||||
<!-- Countries Visited -->
|
||||
<% if @trip.visited_countries.any? %>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">Countries Visited</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% @trip.visited_countries.sort.each do |country| %>
|
||||
<div class="badge badge-lg badge-outline"><%= country %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Notes Section -->
|
||||
<% if @trip.share_notes? && @trip.notes.present? %>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">About This Trip</h3>
|
||||
<div class="prose max-w-none">
|
||||
<%= @trip.notes.body %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Photo Previews -->
|
||||
<% if @trip.share_photos? && @photo_previews.any? %>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">Photos</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<% @photo_previews.first(8).each do |photo| %>
|
||||
<div class="aspect-square overflow-hidden rounded-lg">
|
||||
<img
|
||||
src="<%= photo[:url] %>"
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover transition-transform duration-300 hover:scale-110"
|
||||
alt="Trip photo">
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @photo_previews.count > 8 %>
|
||||
<p class="text-sm text-base-content/60 text-center mt-2">
|
||||
+<%= @photo_previews.count - 8 %> more photos
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center py-8 mt-8">
|
||||
<div class="text-sm text-base-content/50">
|
||||
Powered by <%= link_to "Dawarich", "https://dawarich.app", class: "link link-primary", target: "_blank" %>, your personal memories mapper.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -64,4 +64,7 @@
|
|||
<%= link_to "Back to trips", trips_path, class: "btn" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sharing Section -->
|
||||
<%= render "trips/sharing", trip: @trip %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -90,12 +90,16 @@ Rails.application.routes.draw do
|
|||
as: :update_year_month_stats,
|
||||
constraints: { year: /\d{4}/, month: /\d{1,2}|all/ }
|
||||
get 'shared/month/:uuid', to: 'shared/stats#show', as: :shared_stat
|
||||
get 'shared/trips/:trip_uuid', to: 'shared/trips#show', as: :shared_trip
|
||||
|
||||
# Sharing management endpoint (requires auth)
|
||||
# Sharing management endpoints (require auth)
|
||||
patch 'stats/:year/:month/sharing',
|
||||
to: 'shared/stats#update',
|
||||
as: :sharing_stats,
|
||||
constraints: { year: /\d{4}/, month: /\d{1,2}/ }
|
||||
patch 'trips/:id/sharing',
|
||||
to: 'shared/trips#update',
|
||||
as: :sharing_trip
|
||||
|
||||
root to: 'home#index'
|
||||
|
||||
|
|
|
|||
9
db/migrate/20251105153510_add_sharing_to_trips.rb
Normal file
9
db/migrate/20251105153510_add_sharing_to_trips.rb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddSharingToTrips < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :trips, :sharing_uuid, :uuid
|
||||
add_column :trips, :sharing_settings, :jsonb, default: {}
|
||||
add_index :trips, :sharing_uuid, unique: true
|
||||
end
|
||||
end
|
||||
213
spec/models/concerns/shareable_spec.rb
Normal file
213
spec/models/concerns/shareable_spec.rb
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Shareable do
|
||||
let(:user) { create(:user) }
|
||||
let(:trip) do
|
||||
create(:trip, user: user, name: 'Test Trip',
|
||||
started_at: 1.week.ago, ended_at: Time.current)
|
||||
end
|
||||
|
||||
describe '#generate_sharing_uuid' do
|
||||
it 'generates a UUID before create' do
|
||||
new_trip = build(:trip, user: user)
|
||||
expect(new_trip.sharing_uuid).to be_nil
|
||||
new_trip.save!
|
||||
expect(new_trip.sharing_uuid).to be_present
|
||||
expect(new_trip.sharing_uuid).to match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#sharing_enabled?' do
|
||||
it 'returns false by default' do
|
||||
expect(trip.sharing_enabled?).to be false
|
||||
end
|
||||
|
||||
it 'returns true when enabled' do
|
||||
trip.update!(sharing_settings: { 'enabled' => true })
|
||||
expect(trip.sharing_enabled?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when disabled' do
|
||||
trip.update!(sharing_settings: { 'enabled' => false })
|
||||
expect(trip.sharing_enabled?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#sharing_expired?' do
|
||||
it 'returns false when no expiration is set' do
|
||||
expect(trip.sharing_expired?).to be false
|
||||
end
|
||||
|
||||
it 'returns false when expires_at is in the future' do
|
||||
trip.update!(sharing_settings: {
|
||||
'expiration' => '24h',
|
||||
'expires_at' => 1.hour.from_now.iso8601
|
||||
})
|
||||
expect(trip.sharing_expired?).to be false
|
||||
end
|
||||
|
||||
it 'returns true when expires_at is in the past' do
|
||||
trip.update!(sharing_settings: {
|
||||
'expiration' => '1h',
|
||||
'expires_at' => 1.hour.ago.iso8601
|
||||
})
|
||||
expect(trip.sharing_expired?).to be true
|
||||
end
|
||||
|
||||
it 'returns true when expiration is set but expires_at is missing' do
|
||||
trip.update!(sharing_settings: {
|
||||
'expiration' => '24h',
|
||||
'expires_at' => nil
|
||||
})
|
||||
expect(trip.sharing_expired?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#public_accessible?' do
|
||||
it 'returns false by default' do
|
||||
expect(trip.public_accessible?).to be false
|
||||
end
|
||||
|
||||
it 'returns true when enabled and not expired' do
|
||||
trip.update!(sharing_settings: {
|
||||
'enabled' => true,
|
||||
'expiration' => '24h',
|
||||
'expires_at' => 1.hour.from_now.iso8601
|
||||
})
|
||||
expect(trip.public_accessible?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when enabled but expired' do
|
||||
trip.update!(sharing_settings: {
|
||||
'enabled' => true,
|
||||
'expiration' => '1h',
|
||||
'expires_at' => 1.hour.ago.iso8601
|
||||
})
|
||||
expect(trip.public_accessible?).to be false
|
||||
end
|
||||
|
||||
it 'returns false when disabled' do
|
||||
trip.update!(sharing_settings: {
|
||||
'enabled' => false,
|
||||
'expiration' => '24h',
|
||||
'expires_at' => 1.hour.from_now.iso8601
|
||||
})
|
||||
expect(trip.public_accessible?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#enable_sharing!' do
|
||||
it 'enables sharing with default 24h expiration' do
|
||||
trip.enable_sharing!
|
||||
expect(trip.sharing_enabled?).to be true
|
||||
expect(trip.sharing_settings['expiration']).to eq('24h')
|
||||
expect(trip.sharing_settings['expires_at']).to be_present
|
||||
end
|
||||
|
||||
it 'enables sharing with custom expiration' do
|
||||
trip.enable_sharing!(expiration: '1h')
|
||||
expect(trip.sharing_enabled?).to be true
|
||||
expect(trip.sharing_settings['expiration']).to eq('1h')
|
||||
end
|
||||
|
||||
it 'enables sharing with permanent expiration' do
|
||||
trip.enable_sharing!(expiration: 'permanent')
|
||||
expect(trip.sharing_enabled?).to be true
|
||||
expect(trip.sharing_settings['expiration']).to eq('permanent')
|
||||
expect(trip.sharing_settings['expires_at']).to be_nil
|
||||
end
|
||||
|
||||
it 'defaults to 24h for invalid expiration' do
|
||||
trip.enable_sharing!(expiration: 'invalid')
|
||||
expect(trip.sharing_settings['expiration']).to eq('24h')
|
||||
end
|
||||
|
||||
it 'stores additional options like share_notes' do
|
||||
trip.enable_sharing!(expiration: '24h', share_notes: true)
|
||||
expect(trip.sharing_settings['share_notes']).to be true
|
||||
end
|
||||
|
||||
it 'stores additional options like share_photos' do
|
||||
trip.enable_sharing!(expiration: '24h', share_photos: true)
|
||||
expect(trip.sharing_settings['share_photos']).to be true
|
||||
end
|
||||
|
||||
it 'generates a sharing_uuid if not present' do
|
||||
trip.update_column(:sharing_uuid, nil)
|
||||
trip.enable_sharing!
|
||||
expect(trip.sharing_uuid).to be_present
|
||||
end
|
||||
|
||||
it 'keeps existing sharing_uuid' do
|
||||
original_uuid = trip.sharing_uuid
|
||||
trip.enable_sharing!
|
||||
expect(trip.sharing_uuid).to eq(original_uuid)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#disable_sharing!' do
|
||||
before do
|
||||
trip.enable_sharing!(expiration: '24h')
|
||||
end
|
||||
|
||||
it 'disables sharing' do
|
||||
trip.disable_sharing!
|
||||
expect(trip.sharing_enabled?).to be false
|
||||
end
|
||||
|
||||
it 'clears expiration settings' do
|
||||
trip.disable_sharing!
|
||||
expect(trip.sharing_settings['expiration']).to be_nil
|
||||
expect(trip.sharing_settings['expires_at']).to be_nil
|
||||
end
|
||||
|
||||
it 'keeps the sharing_uuid' do
|
||||
original_uuid = trip.sharing_uuid
|
||||
trip.disable_sharing!
|
||||
expect(trip.sharing_uuid).to eq(original_uuid)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_new_sharing_uuid!' do
|
||||
it 'generates a new UUID' do
|
||||
original_uuid = trip.sharing_uuid
|
||||
trip.generate_new_sharing_uuid!
|
||||
expect(trip.sharing_uuid).not_to eq(original_uuid)
|
||||
expect(trip.sharing_uuid).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
describe '#share_notes?' do
|
||||
it 'returns false by default' do
|
||||
expect(trip.share_notes?).to be false
|
||||
end
|
||||
|
||||
it 'returns true when share_notes is enabled' do
|
||||
trip.update!(sharing_settings: { 'share_notes' => true })
|
||||
expect(trip.share_notes?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when share_notes is disabled' do
|
||||
trip.update!(sharing_settings: { 'share_notes' => false })
|
||||
expect(trip.share_notes?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#share_photos?' do
|
||||
it 'returns false by default' do
|
||||
expect(trip.share_photos?).to be false
|
||||
end
|
||||
|
||||
it 'returns true when share_photos is enabled' do
|
||||
trip.update!(sharing_settings: { 'share_photos' => true })
|
||||
expect(trip.share_photos?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when share_photos is disabled' do
|
||||
trip.update!(sharing_settings: { 'share_photos' => false })
|
||||
expect(trip.share_photos?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -154,4 +154,44 @@ RSpec.describe Trip, type: :model do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Shareable concern' do
|
||||
let(:user) { create(:user) }
|
||||
let(:trip) { create(:trip, user: user) }
|
||||
|
||||
describe 'sharing_uuid generation' do
|
||||
it 'generates a sharing_uuid on create' do
|
||||
new_trip = build(:trip, user: user)
|
||||
expect(new_trip.sharing_uuid).to be_nil
|
||||
new_trip.save!
|
||||
expect(new_trip.sharing_uuid).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
describe '#public_accessible?' do
|
||||
it 'returns false by default' do
|
||||
expect(trip.public_accessible?).to be false
|
||||
end
|
||||
|
||||
it 'returns true when sharing is enabled and not expired' do
|
||||
trip.enable_sharing!(expiration: '24h')
|
||||
expect(trip.public_accessible?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when sharing is disabled' do
|
||||
trip.enable_sharing!(expiration: '24h')
|
||||
trip.disable_sharing!
|
||||
expect(trip.public_accessible?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#enable_sharing!' do
|
||||
it 'enables sharing with notes and photos options' do
|
||||
trip.enable_sharing!(expiration: '24h', share_notes: true, share_photos: true)
|
||||
expect(trip.sharing_enabled?).to be true
|
||||
expect(trip.share_notes?).to be true
|
||||
expect(trip.share_photos?).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
148
spec/policies/trip_policy_spec.rb
Normal file
148
spec/policies/trip_policy_spec.rb
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TripPolicy, type: :policy do
|
||||
let(:user) { create(:user) }
|
||||
let(:other_user) { create(:user, email: 'other@example.com') }
|
||||
let(:trip) { create(:trip, user: user, name: 'My Trip') }
|
||||
let(:other_trip) { create(:trip, user: other_user, name: 'Other Trip') }
|
||||
|
||||
describe 'show?' do
|
||||
it 'allows users to view their own trips' do
|
||||
policy = TripPolicy.new(user, trip)
|
||||
|
||||
expect(policy).to permit(:show)
|
||||
end
|
||||
|
||||
it 'denies users from viewing other users trips by default' do
|
||||
policy = TripPolicy.new(user, other_trip)
|
||||
|
||||
expect(policy).not_to permit(:show)
|
||||
end
|
||||
|
||||
it 'allows anyone to view publicly shared trips' do
|
||||
other_trip.enable_sharing!(expiration: '24h')
|
||||
policy = TripPolicy.new(user, other_trip)
|
||||
|
||||
expect(policy).to permit(:show)
|
||||
end
|
||||
|
||||
it 'allows unauthenticated users to view publicly shared trips' do
|
||||
trip.enable_sharing!(expiration: '24h')
|
||||
policy = TripPolicy.new(nil, trip)
|
||||
|
||||
expect(policy).to permit(:show)
|
||||
end
|
||||
|
||||
it 'denies access to expired shared trips' do
|
||||
other_trip.update!(sharing_settings: {
|
||||
'enabled' => true,
|
||||
'expiration' => '1h',
|
||||
'expires_at' => 2.hours.ago.iso8601
|
||||
})
|
||||
policy = TripPolicy.new(user, other_trip)
|
||||
|
||||
expect(policy).not_to permit(:show)
|
||||
end
|
||||
|
||||
it 'denies unauthenticated users from viewing private trips' do
|
||||
policy = TripPolicy.new(nil, trip)
|
||||
|
||||
expect(policy).not_to permit(:show)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'create?' do
|
||||
it 'allows authenticated users to create trips' do
|
||||
policy = TripPolicy.new(user, Trip.new)
|
||||
|
||||
expect(policy).to permit(:create)
|
||||
end
|
||||
|
||||
it 'denies unauthenticated users from creating trips' do
|
||||
policy = TripPolicy.new(nil, Trip.new)
|
||||
|
||||
expect(policy).not_to permit(:create)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'update?' do
|
||||
it 'allows users to update their own trips' do
|
||||
policy = TripPolicy.new(user, trip)
|
||||
|
||||
expect(policy).to permit(:update)
|
||||
end
|
||||
|
||||
it 'denies users from updating other users trips' do
|
||||
policy = TripPolicy.new(user, other_trip)
|
||||
|
||||
expect(policy).not_to permit(:update)
|
||||
end
|
||||
|
||||
it 'denies unauthenticated users from updating trips' do
|
||||
policy = TripPolicy.new(nil, trip)
|
||||
|
||||
expect(policy).not_to permit(:update)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'destroy?' do
|
||||
it 'allows users to destroy their own trips' do
|
||||
policy = TripPolicy.new(user, trip)
|
||||
|
||||
expect(policy).to permit(:destroy)
|
||||
end
|
||||
|
||||
it 'denies users from destroying other users trips' do
|
||||
policy = TripPolicy.new(user, other_trip)
|
||||
|
||||
expect(policy).not_to permit(:destroy)
|
||||
end
|
||||
|
||||
it 'denies unauthenticated users from destroying trips' do
|
||||
policy = TripPolicy.new(nil, trip)
|
||||
|
||||
expect(policy).not_to permit(:destroy)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'update_sharing?' do
|
||||
it 'allows users to update sharing settings for their own trips' do
|
||||
policy = TripPolicy.new(user, trip)
|
||||
|
||||
expect(policy).to permit(:update_sharing)
|
||||
end
|
||||
|
||||
it 'denies users from updating sharing settings for other users trips' do
|
||||
policy = TripPolicy.new(user, other_trip)
|
||||
|
||||
expect(policy).not_to permit(:update_sharing)
|
||||
end
|
||||
|
||||
it 'denies unauthenticated users from updating sharing settings' do
|
||||
policy = TripPolicy.new(nil, trip)
|
||||
|
||||
expect(policy).not_to permit(:update_sharing)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Scope' do
|
||||
let!(:user_trip1) { create(:trip, user: user, name: 'Trip 1') }
|
||||
let!(:user_trip2) { create(:trip, user: user, name: 'Trip 2') }
|
||||
let!(:other_user_trip) { create(:trip, user: other_user, name: 'Other Trip') }
|
||||
|
||||
it 'returns only the users trips' do
|
||||
scope = TripPolicy::Scope.new(user, Trip).resolve
|
||||
|
||||
expect(scope).to contain_exactly(user_trip1, user_trip2)
|
||||
expect(scope).not_to include(other_user_trip)
|
||||
end
|
||||
|
||||
it 'returns no trips for unauthenticated users' do
|
||||
scope = TripPolicy::Scope.new(nil, Trip).resolve
|
||||
|
||||
expect(scope).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
240
spec/requests/shared/trips_spec.rb
Normal file
240
spec/requests/shared/trips_spec.rb
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Shared::Trips', type: :request do
|
||||
before do
|
||||
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
|
||||
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
|
||||
end
|
||||
|
||||
context 'public sharing' do
|
||||
let(:user) { create(:user) }
|
||||
let(:trip) do
|
||||
create(:trip, user: user, name: 'Test Trip',
|
||||
started_at: 1.week.ago, ended_at: Time.current)
|
||||
end
|
||||
|
||||
describe 'GET /shared/trips/:trip_uuid' do
|
||||
context 'with valid sharing UUID' do
|
||||
before do
|
||||
trip.enable_sharing!(expiration: '24h', share_notes: true, share_photos: false)
|
||||
# Create some points for the trip
|
||||
create_list(:point, 5, user: user, timestamp: trip.started_at.to_i + 1.hour)
|
||||
end
|
||||
|
||||
it 'renders the public trip view' do
|
||||
get shared_trip_url(trip.sharing_uuid)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include('Test Trip')
|
||||
end
|
||||
|
||||
it 'includes required content in response' do
|
||||
get shared_trip_url(trip.sharing_uuid)
|
||||
|
||||
expect(response.body).to include('Test Trip')
|
||||
expect(response.body).to include('Trip Route')
|
||||
expect(response.body).to include('data-controller="public-trip-map"')
|
||||
expect(response.body).to include(trip.sharing_uuid)
|
||||
end
|
||||
|
||||
it 'displays notes when share_notes is true' do
|
||||
trip.update(notes: 'This is a test trip')
|
||||
get shared_trip_url(trip.sharing_uuid)
|
||||
|
||||
expect(response.body).to include('About This Trip')
|
||||
expect(response.body).to include('This is a test trip')
|
||||
end
|
||||
|
||||
it 'does not display notes when share_notes is false' do
|
||||
trip.disable_sharing!
|
||||
trip.enable_sharing!(expiration: '24h', share_notes: false)
|
||||
trip.update(notes: 'This is a test trip')
|
||||
get shared_trip_url(trip.sharing_uuid)
|
||||
|
||||
expect(response.body).not_to include('About This Trip')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid sharing UUID' do
|
||||
it 'redirects to root with alert' do
|
||||
get shared_trip_url('invalid-uuid')
|
||||
|
||||
expect(response).to redirect_to(root_path)
|
||||
expect(flash[:alert]).to eq('Shared trip not found or no longer available')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with expired sharing' do
|
||||
before do
|
||||
trip.update!(sharing_settings: {
|
||||
'enabled' => true,
|
||||
'expiration' => '1h',
|
||||
'expires_at' => 2.hours.ago.iso8601
|
||||
})
|
||||
end
|
||||
|
||||
it 'redirects to root with alert' do
|
||||
get shared_trip_url(trip.sharing_uuid)
|
||||
|
||||
expect(response).to redirect_to(root_path)
|
||||
expect(flash[:alert]).to eq('Shared trip not found or no longer available')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with disabled sharing' do
|
||||
before do
|
||||
trip.enable_sharing!(expiration: '24h')
|
||||
trip.disable_sharing!
|
||||
end
|
||||
|
||||
it 'redirects to root with alert' do
|
||||
get shared_trip_url(trip.sharing_uuid)
|
||||
|
||||
expect(response).to redirect_to(root_path)
|
||||
expect(flash[:alert]).to eq('Shared trip not found or no longer available')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when trip has no path data' do
|
||||
before do
|
||||
trip.enable_sharing!(expiration: '24h')
|
||||
trip.update_column(:path, nil)
|
||||
end
|
||||
|
||||
it 'renders successfully with placeholder' do
|
||||
get shared_trip_url(trip.sharing_uuid)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include('Route data not yet calculated')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /trips/:id/sharing' do
|
||||
context 'when user is signed in' do
|
||||
before { sign_in user }
|
||||
|
||||
context 'enabling sharing' do
|
||||
it 'enables sharing and returns success' do
|
||||
patch sharing_trip_path(trip),
|
||||
params: { enabled: '1', expiration: '24h' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['success']).to be(true)
|
||||
expect(json_response['sharing_url']).to be_present
|
||||
expect(json_response['message']).to eq('Sharing enabled successfully')
|
||||
|
||||
trip.reload
|
||||
expect(trip.sharing_enabled?).to be(true)
|
||||
expect(trip.sharing_uuid).to be_present
|
||||
end
|
||||
|
||||
it 'enables sharing with notes option' do
|
||||
patch sharing_trip_path(trip),
|
||||
params: { enabled: '1', expiration: '24h', share_notes: '1' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
trip.reload
|
||||
expect(trip.sharing_enabled?).to be(true)
|
||||
expect(trip.share_notes?).to be(true)
|
||||
end
|
||||
|
||||
it 'enables sharing with photos option' do
|
||||
patch sharing_trip_path(trip),
|
||||
params: { enabled: '1', expiration: '24h', share_photos: '1' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
trip.reload
|
||||
expect(trip.sharing_enabled?).to be(true)
|
||||
expect(trip.share_photos?).to be(true)
|
||||
end
|
||||
|
||||
it 'sets custom expiration when provided' do
|
||||
patch sharing_trip_path(trip),
|
||||
params: { enabled: '1', expiration: '1h' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
trip.reload
|
||||
expect(trip.sharing_enabled?).to be(true)
|
||||
expect(trip.sharing_settings['expiration']).to eq('1h')
|
||||
end
|
||||
|
||||
it 'enables permanent sharing' do
|
||||
patch sharing_trip_path(trip),
|
||||
params: { enabled: '1', expiration: 'permanent' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
trip.reload
|
||||
expect(trip.sharing_settings['expiration']).to eq('permanent')
|
||||
expect(trip.sharing_settings['expires_at']).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'disabling sharing' do
|
||||
before do
|
||||
trip.enable_sharing!(expiration: '24h')
|
||||
end
|
||||
|
||||
it 'disables sharing and returns success' do
|
||||
patch sharing_trip_path(trip),
|
||||
params: { enabled: '0' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['success']).to be(true)
|
||||
expect(json_response['message']).to eq('Sharing disabled successfully')
|
||||
|
||||
trip.reload
|
||||
expect(trip.sharing_enabled?).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when trip does not exist' do
|
||||
it 'returns not found' do
|
||||
patch sharing_trip_path(id: 999999),
|
||||
params: { enabled: '1' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not the trip owner' do
|
||||
let(:other_user) { create(:user, email: 'other@example.com') }
|
||||
let(:other_trip) { create(:trip, user: other_user, name: 'Other Trip') }
|
||||
|
||||
it 'returns not found' do
|
||||
patch sharing_trip_path(other_trip),
|
||||
params: { enabled: '1' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not signed in' do
|
||||
it 'returns unauthorized' do
|
||||
patch sharing_trip_path(trip),
|
||||
params: { enabled: '1' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue